From c4e06fbb6c180bffec61adf3c296d826c1465cd9 Mon Sep 17 00:00:00 2001 From: Kyma Bot Date: Tue, 24 Jul 2018 15:22:03 +0200 Subject: [PATCH] Initialize repository --- .github/ISSUE_TEMPLATE/bug-report.md | 33 + .github/ISSUE_TEMPLATE/feature-request.md | 22 + .github/issue-template.md | 9 + .github/pull-request-template.md | 19 + .gitignore | 59 + CODEOWNERS | 207 + CODE_OF_CONDUCT.md | 3 + CONTRIBUTING.md | 21 + Jenkinsfile | 107 + LICENSE | 202 + NOTICE.md | 1 + README.md | 43 + azure.Jenkinsfile | 117 + ci.Dockerfile | 62 + components/api-controller/Dockerfile | 6 + components/api-controller/Gopkg.lock | 201 + components/api-controller/Gopkg.toml | 25 + components/api-controller/Jenkinsfile | 100 + components/api-controller/README.md | 75 + .../api-controller/cmd/controller/main.go | 80 + components/api-controller/doc.go | 4 + components/api-controller/docs/.file | 0 .../api-controller/examples/cr-v1alpha1.yaml | 16 + .../api-controller/examples/cr-v1alpha2.yaml | 15 + .../hack/custom-boilerplate.go.txt | 0 .../api-controller/hack/update-codegen.sh | 29 + .../authentication.istio.io/v1alpha1/doc.go | 2 + .../v1alpha1/register.go | 42 + .../authentication.istio.io/v1alpha1/types.go | 77 + .../v1alpha1/zz_generated.deepcopy.go | 228 + .../pkg/apis/gateway.kyma.cx/meta/v1/types.go | 72 + .../pkg/apis/gateway.kyma.cx/v1alpha2/doc.go | 2 + .../apis/gateway.kyma.cx/v1alpha2/register.go | 42 + .../apis/gateway.kyma.cx/v1alpha2/types.go | 85 + .../v1alpha2/zz_generated.deepcopy.go | 179 + .../clientset/versioned/clientset.go | 84 + .../clientset/versioned/doc.go | 4 + .../versioned/fake/clientset_generated.go | 65 + .../clientset/versioned/fake/doc.go | 4 + .../clientset/versioned/fake/register.go | 38 + .../clientset/versioned/scheme/doc.go | 4 + .../clientset/versioned/scheme/register.go | 38 + .../authentication.istio.io_client.go | 74 + .../authentication.istio.io/v1alpha1/doc.go | 4 + .../v1alpha1/fake/doc.go | 4 + .../fake_authentication.istio.io_client.go | 24 + .../v1alpha1/fake/fake_policy.go | 112 + .../v1alpha1/generated_expansion.go | 5 + .../v1alpha1/policy.go | 141 + .../authentication.istio.io/interface.go | 30 + .../v1alpha1/interface.go | 29 + .../v1alpha1/policy.go | 73 + .../informers/externalversions/factory.go | 115 + .../informers/externalversions/generic.go | 46 + .../internalinterfaces/factory_interfaces.go | 22 + .../v1alpha1/expansion_generated.go | 11 + .../v1alpha1/policy.go | 78 + .../clientset/versioned/clientset.go | 84 + .../clientset/versioned/doc.go | 4 + .../versioned/fake/clientset_generated.go | 65 + .../clientset/versioned/fake/doc.go | 4 + .../clientset/versioned/fake/register.go | 38 + .../clientset/versioned/scheme/doc.go | 4 + .../clientset/versioned/scheme/register.go | 38 + .../typed/gateway.kyma.cx/v1alpha2/api.go | 158 + .../typed/gateway.kyma.cx/v1alpha2/doc.go | 4 + .../gateway.kyma.cx/v1alpha2/fake/doc.go | 4 + .../gateway.kyma.cx/v1alpha2/fake/fake_api.go | 124 + .../fake/fake_gateway.kyma.cx_client.go | 24 + .../v1alpha2/gateway.kyma.cx_client.go | 74 + .../v1alpha2/generated_expansion.go | 5 + .../informers/externalversions/factory.go | 115 + .../gateway.kyma.cx/interface.go | 30 + .../gateway.kyma.cx/v1alpha2/api.go | 73 + .../gateway.kyma.cx/v1alpha2/interface.go | 29 + .../informers/externalversions/generic.go | 46 + .../internalinterfaces/factory_interfaces.go | 22 + .../listers/gateway.kyma.cx/v1alpha2/api.go | 78 + .../v1alpha2/expansion_generated.go | 11 + .../authentication/v2/authenticationv2.go | 182 + .../v2/authenticationv2_integration_test.go | 111 + .../v2/authenticationv2_test.go | 377 + .../pkg/controller/authentication/v2/doc.go | 4 + .../controller/authentication/v2/interface.go | 43 + .../pkg/controller/commons/errors.go | 15 + .../api-controller/pkg/controller/crd/crd.go | 58 + .../pkg/controller/ingress/v1/doc.go | 4 + .../pkg/controller/ingress/v1/ingressv1.go | 141 + .../ingress/v1/ingressv1_integration_test.go | 75 + .../controller/ingress/v1/ingressv1_test.go | 140 + .../pkg/controller/ingress/v1/interface.go | 21 + .../api-controller/pkg/controller/meta/dto.go | 7 + .../pkg/controller/service/v1/interface.go | 16 + .../pkg/controller/service/v1/servicev1.go | 95 + .../service/v1/servicev1_integration_test.go | 70 + .../controller/service/v1/servicev1_test.go | 131 + .../pkg/controller/v1alpha2/apistatus.go | 44 + .../pkg/controller/v1alpha2/controller.go | 487 ++ .../pkg/controller/v1alpha2/crd.go | 108 + .../v1alpha2/crd_integration_test.go | 45 + components/application-connector/.gitignore | 8 + components/application-connector/Dockerfile | 19 + components/application-connector/Gopkg.lock | 452 + components/application-connector/Gopkg.toml | 62 + components/application-connector/Jenkinsfile | 100 + components/application-connector/README.md | 167 + .../cmd/metadata/metadata.go | 189 + .../cmd/metadata/options.go | 45 + .../docs/api/metadata.yaml | 339 + .../docs/instalation/Installation.md | 168 + .../docs/instalation/remote-environments.zip | Bin 0 -> 6141 bytes .../hack/custom-boilerplate.go.txt | 0 .../hack/generate-groups.sh | 89 + .../hack/update-codegen.sh | 14 + .../internal/apperrors/apperrors.go | 53 + .../internal/apperrors/apperrors_test.go | 34 + .../internal/externalapi/errorhandler.go | 26 + .../internal/externalapi/errorhandler_test.go | 43 + .../internal/externalapi/externalapi.go | 33 + .../externalapi/healthcheckhandler.go | 12 + .../externalapi/healthcheckhandler_test.go | 27 + .../externalapi/invalidstatehandler.go | 65 + .../externalapi/invalidstatehandler_test.go | 51 + .../internal/externalapi/metadatahandler.go | 188 + .../externalapi/metadatahandler_test.go | 758 ++ .../internal/externalapi/model.go | 157 + .../externalapi/servicedetailsvalidation.go | 71 + .../servicedetailsvalidation_test.go | 347 + .../internal/httpconsts/httpconsts.go | 14 + .../internal/httperrors/httperrors.go | 34 + .../internal/httptools/logging.go | 66 + .../internal/k8sconsts/k8sconsts.go | 7 + .../internal/k8sconsts/mocks/NameResolver.go | 51 + .../internal/k8sconsts/nameresolver.go | 70 + .../internal/k8sconsts/nameresolver_test.go | 69 + .../accessservice/accessservicemanager.go | 93 + .../accessservicemanager_test.go | 191 + .../mocks/AccessServiceManager.go | 58 + .../accessservice/mocks/ServiceInterface.go | 48 + .../istio/mocks/ChecknothingInterface.go | 48 + .../metadata/istio/mocks/DenierInterface.go | 48 + .../metadata/istio/mocks/Repository.go | 155 + .../metadata/istio/mocks/RuleInterface.go | 48 + .../internal/metadata/istio/mocks/Service.go | 59 + .../internal/metadata/istio/repository.go | 227 + .../metadata/istio/repository_test.go | 484 ++ .../internal/metadata/istio/service.go | 87 + .../internal/metadata/istio/service_test.go | 233 + .../internal/metadata/minio/mocks/Client.go | 106 + .../metadata/minio/mocks/Repository.go | 68 + .../internal/metadata/minio/mocks/Service.go | 86 + .../internal/metadata/minio/repository.go | 111 + .../metadata/minio/repository_test.go | 188 + .../internal/metadata/minio/service.go | 107 + .../internal/metadata/minio/service_test.go | 301 + .../mocks/ServiceDefinitionService.go | 140 + .../internal/metadata/model.go | 27 + .../mocks/RemoteEnvironmentManager.go | 58 + .../remoteenv/mocks/ServiceRepository.go | 107 + .../internal/metadata/remoteenv/repository.go | 182 + .../metadata/remoteenv/repository_test.go | 507 ++ .../internal/metadata/remoteenv/util.go | 124 + .../metadata/secrets/mocks/Manager.go | 95 + .../metadata/secrets/mocks/Repository.go | 88 + .../internal/metadata/secrets/repository.go | 109 + .../metadata/secrets/repository_test.go | 270 + .../metadata/serviceapi/mocks/Service.go | 103 + .../metadata/serviceapi/serviceapiservice.go | 187 + .../serviceapi/serviceapiservice_test.go | 679 ++ .../internal/metadata/servicedefservice.go | 261 + .../metadata/servicedefservice_test.go | 1113 +++ .../internal/metadata/uuid/generator.go | 15 + .../internal/metadata/uuid/mocks/Generator.go | 23 + .../pkg/apis/istio/v1alpha2/doc.go | 2 + .../pkg/apis/istio/v1alpha2/register.go | 45 + .../pkg/apis/istio/v1alpha2/types.go | 84 + .../istio/v1alpha2/zz_generated.deepcopy.go | 289 + .../client/clientset/versioned/clientset.go | 84 + .../pkg/client/clientset/versioned/doc.go | 4 + .../versioned/fake/clientset_generated.go | 65 + .../client/clientset/versioned/fake/doc.go | 4 + .../clientset/versioned/fake/register.go | 38 + .../client/clientset/versioned/scheme/doc.go | 4 + .../clientset/versioned/scheme/register.go | 38 + .../typed/istio/v1alpha2/checknothing.go | 141 + .../versioned/typed/istio/v1alpha2/denier.go | 141 + .../versioned/typed/istio/v1alpha2/doc.go | 4 + .../typed/istio/v1alpha2/fake/doc.go | 4 + .../istio/v1alpha2/fake/fake_checknothing.go | 112 + .../typed/istio/v1alpha2/fake/fake_denier.go | 112 + .../istio/v1alpha2/fake/fake_istio_client.go | 32 + .../typed/istio/v1alpha2/fake/fake_rule.go | 112 + .../istio/v1alpha2/generated_expansion.go | 9 + .../typed/istio/v1alpha2/istio_client.go | 84 + .../versioned/typed/istio/v1alpha2/rule.go | 141 + .../informers/externalversions/factory.go | 115 + .../informers/externalversions/generic.go | 50 + .../internalinterfaces/factory_interfaces.go | 22 + .../externalversions/istio/interface.go | 30 + .../istio/v1alpha2/checknothing.go | 73 + .../externalversions/istio/v1alpha2/denier.go | 73 + .../istio/v1alpha2/interface.go | 43 + .../externalversions/istio/v1alpha2/rule.go | 73 + .../listers/istio/v1alpha2/checknothing.go | 78 + .../client/listers/istio/v1alpha2/denier.go | 78 + .../istio/v1alpha2/expansion_generated.go | 27 + .../pkg/client/listers/istio/v1alpha2/rule.go | 78 + .../scripts/can-i-commit.sh | 51 + .../scripts/delete-all.sh | 13 + .../scripts/log-oauth-request.js | 25 + .../scripts/log-request.js | 19 + .../test/echo-service/README.md | 145 + .../binding-usage-controller/.gitignore | 104 + .../binding-usage-controller/Gopkg.lock | 643 ++ .../binding-usage-controller/Gopkg.toml | 89 + .../binding-usage-controller/Jenkinsfile | 87 + components/binding-usage-controller/README.md | 38 + .../binding-usage-controller/before-commit.sh | 120 + .../cmd/controller/main.go | 143 + .../deploy/controller/Dockerfile | 10 + .../binding-usage-controller/docs/README.md | 9 + .../docs/architecture.md | 42 + .../docs/assets/architecture.html | 11 + .../docs/assets/architecture.png | Bin 0 -> 90598 bytes .../binding-usage-controller/docs/example.md | 57 + .../binding-usage-controller/docs/status.md | 10 + .../examples/README.md | 117 + .../examples/deploy/redis-client.yaml | 17 + .../deploy/service-binding-usage.yaml | 10 + .../examples/function/redis-client.yaml | 51 + .../function/service-binding-usage.yaml | 13 + .../redis-instance-binding.yaml | 7 + .../servicecatalog/redis-instance.yaml | 7 + .../hack/boilerplate.go.txt | 0 .../hack/generate-groups.sh | 89 + .../hack/update-codegen.sh | 14 + .../automock/applied_spec_storage.go | 69 + .../automock/binding_labels_fetcher.go | 33 + .../automock/binding_usage_checker.go | 24 + .../controller/automock/deployment_finder.go | 33 + .../internal/controller/automock/extends.go | 54 + .../controller/automock/kinds_supervisors.go | 47 + .../automock/kubeless_function_finder.go | 33 + .../kubernetes_resource_supervisor.go | 74 + .../automock/pod_preset_modifier.go | 38 + .../usage_binding_annotation_tracer.go | 61 + .../internal/controller/controller.go | 562 ++ .../controller/controller_export_test.go | 11 + .../internal/controller/controller_test.go | 281 + .../controller/deployment_supervisor.go | 187 + .../deployment_supervisor_export_test.go | 6 + .../controller/deployment_supervisor_test.go | 224 + .../internal/controller/env_prefix.go | 15 + .../internal/controller/env_prefix_test.go | 76 + .../internal/controller/errors.go | 55 + .../internal/controller/helpers_test.go | 165 + .../kubeless_function_supervisor.go | 169 + ...ubeless_function_supervisor_export_test.go | 6 + .../kubeless_function_supervisor_test.go | 225 + .../internal/controller/label_checker.go | 20 + .../internal/controller/label_checker_test.go | 52 + .../internal/controller/labels_fetcher.go | 91 + .../controller/labels_fetcher_test.go | 176 + .../internal/controller/map_helpers.go | 29 + .../internal/controller/map_helpers_test.go | 59 + .../internal/controller/podpreset_modifer.go | 51 + .../controller/podpreset_modifer_test.go | 112 + .../internal/controller/pretty/pretty.go | 47 + .../internal/controller/sbu_spec_storage.go | 109 + .../controller/sbu_spec_storage_test.go | 348 + .../internal/controller/sbu_tracer.go | 115 + .../controller/sbu_tracer_export_test.go | 9 + .../internal/controller/sbu_tracer_test.go | 69 + .../internal/controller/status/usage.go | 87 + .../internal/controller/status/usage_test.go | 109 + .../controller/supervisor_aggregator.go | 67 + .../controller/supervisor_aggregator_test.go | 121 + .../internal/platform/logger/config.go | 37 + .../internal/platform/logger/doc.go | 2 + .../internal/platform/logger/logger.go | 95 + .../internal/platform/logger/logger_mock.go | 34 + .../internal/platform/logger/spy/formatter.go | 56 + .../internal/platform/logger/spy/logger.go | 133 + .../pkg/apis/servicecatalog/v1alpha1/doc.go | 4 + .../apis/servicecatalog/v1alpha1/register.go | 39 + .../pkg/apis/servicecatalog/v1alpha1/types.go | 114 + .../v1alpha1/zz_generated.deepcopy.go | 211 + .../client/clientset/versioned/clientset.go | 84 + .../pkg/client/clientset/versioned/doc.go | 4 + .../versioned/fake/clientset_generated.go | 65 + .../client/clientset/versioned/fake/doc.go | 4 + .../clientset/versioned/fake/register.go | 38 + .../client/clientset/versioned/scheme/doc.go | 4 + .../clientset/versioned/scheme/register.go | 38 + .../typed/servicecatalog/v1alpha1/doc.go | 4 + .../typed/servicecatalog/v1alpha1/fake/doc.go | 4 + .../v1alpha1/fake/fake_servicebindingusage.go | 112 + .../fake/fake_servicecatalog_client.go | 24 + .../v1alpha1/generated_expansion.go | 5 + .../v1alpha1/servicebindingusage.go | 141 + .../v1alpha1/servicecatalog_client.go | 74 + .../informers/externalversions/factory.go | 115 + .../informers/externalversions/generic.go | 46 + .../internalinterfaces/factory_interfaces.go | 22 + .../servicecatalog/interface.go | 30 + .../servicecatalog/v1alpha1/interface.go | 29 + .../v1alpha1/servicebindingusage.go | 73 + .../v1alpha1/expansion_generated.go | 11 + .../v1alpha1/servicebindingusage.go | 78 + .../pkg/signal/signal.go | 24 + .../configurations-generator/Dockerfile | 6 + .../configurations-generator/Gopkg.lock | 42 + .../configurations-generator/Gopkg.toml | 38 + .../configurations-generator/Jenkinsfile | 98 + components/configurations-generator/README.md | 73 + .../cmd/generator/main.go | 77 + components/configurations-generator/doc.go | 4 + .../pkg/kube_config/endpoints.go | 40 + .../pkg/kube_config/endpoints_test.go | 112 + .../pkg/kube_config/kube_config.go | 78 + components/connector-service/.gitignore | 4 + components/connector-service/Dockerfile | 19 + components/connector-service/Gopkg.lock | 328 + components/connector-service/Gopkg.toml | 50 + components/connector-service/Jenkinsfile | 94 + components/connector-service/README.md | 37 + .../cmd/connectorservice/connectorservice.go | 105 + .../cmd/connectorservice/options.go | 71 + .../connector-service/docs/api/swagger.yaml | 182 + .../internal/apperrors/apperrors.go | 52 + .../internal/apperrors/apperrors_test.go | 34 + .../internal/certificates/certificates.go | 185 + .../certificates/certificates_test.go | 510 ++ .../certificates/mocks/CertificateUtility.go | 127 + .../internal/errorhandler/errorhandler.go | 27 + .../errorhandler/errorhandler_test.go | 43 + .../internal/externalapi/externalapi.go | 30 + .../internal/externalapi/infohandler.go | 85 + .../internal/externalapi/infohandler_test.go | 240 + .../internal/externalapi/model.go | 35 + .../internal/externalapi/signaturehandler.go | 164 + .../externalapi/signaturehandler_test.go | 462 ++ .../internal/httpconsts/httpconsts.go | 9 + .../internal/httperrors/httperrors.go | 34 + .../internal/internalapi/internalapi.go | 25 + .../internal/internalapi/tokenhandler.go | 60 + .../internal/internalapi/tokenhandler_test.go | 92 + .../internal/secrets/mocks/Manager.go | 35 + .../internal/secrets/mocks/Repository.go | 44 + .../internal/secrets/repository.go | 36 + .../internal/secrets/repository_test.go | 83 + .../internal/tokens/mocks/TokenGenerator.go | 33 + .../tokens/tokencache/mocks/TokenCache.go | 40 + .../internal/tokens/tokencache/tokencache.go | 40 + .../internal/tokens/tokens.go | 47 + .../internal/tokens/tokens_test.go | 39 + .../connector-service/scripts/can-i-commit.sh | 51 + components/environments/.gitignore | 9 + components/environments/Gopkg.lock | 358 + components/environments/Gopkg.toml | 19 + components/environments/Jenkinsfile | 97 + components/environments/README.md | 69 + components/environments/before-commit.sh | 105 + .../environments/cmd/controller/main.go | 53 + .../environments/deploy/controller/Dockerfile | 10 + .../environments/examples/namespace-test.yaml | 7 + .../internal/controller/config.go | 34 + .../internal/controller/controller.go | 331 + .../internal/controller/controller_test.go | 365 + .../controller/environment_watcher.go | 20 + components/environments/internal/errors.go | 27 + .../internal/limit_range/client.go | 33 + .../internal/namespaces/client.go | 24 + .../internal/resource-quota/client.go | 33 + .../environments/internal/roles/client.go | 34 + components/event-bus/.gitignore | 12 + components/event-bus/Gopkg.lock | 508 ++ components/event-bus/Gopkg.toml | 45 + components/event-bus/Jenkinsfile | 136 + components/event-bus/README.md | 36 + components/event-bus/api/publish/api.go | 60 + components/event-bus/api/publish/error.go | 196 + .../event-bus/api/publish/validators.go | 73 + .../event-bus/api/publish/validators_test.go | 233 + .../api/push/eventing.kyma.cx/register.go | 3 + .../api/push/eventing.kyma.cx/v1alpha1/doc.go | 5 + .../push/eventing.kyma.cx/v1alpha1/helpers.go | 15 + .../eventing.kyma.cx/v1alpha1/register.go | 39 + .../push/eventing.kyma.cx/v1alpha1/types.go | 68 + .../v1alpha1/zz_generated.deepcopy.go | 143 + .../cmd/event-bus-publish/Dockerfile | 28 + .../event-bus/cmd/event-bus-publish/Makefile | 31 + .../application/application.go | 52 + .../cmd/event-bus-publish/controllers/nats.go | 71 + .../cmd/event-bus-publish/dockerBuild.sh | 18 + .../event-bus-publish/handlers/handlers.go | 180 + .../event-bus/cmd/event-bus-publish/main.go | 93 + .../cmd/event-bus-publish/publish_test.go | 148 + .../cmd/event-bus-publish/test_helpers.go | 240 + .../event-bus/cmd/event-bus-push/Dockerfile | 27 + .../event-bus/cmd/event-bus-push/Makefile | 32 + .../event-bus-push/application/application.go | 85 + .../cmd/event-bus-push/dockerBuild.sh | 19 + .../event-bus/cmd/event-bus-push/main.go | 20 + .../event-bus/cmd/event-bus-sv/Dockerfile | 27 + .../event-bus/cmd/event-bus-sv/Makefile | 37 + .../event-bus-sv/application/application.go | 68 + .../event-bus/cmd/event-bus-sv/dockerBuild.sh | 19 + components/event-bus/cmd/event-bus-sv/main.go | 20 + components/event-bus/docs/api.yaml | 198 + .../ea/clientset/versioned/clientset.go | 84 + .../generated/ea/clientset/versioned/doc.go | 4 + .../versioned/fake/clientset_generated.go | 65 + .../ea/clientset/versioned/fake/doc.go | 4 + .../ea/clientset/versioned/fake/register.go | 38 + .../ea/clientset/versioned/scheme/doc.go | 4 + .../ea/clientset/versioned/scheme/register.go | 38 + .../remoteenvironment.kyma.cx/v1alpha1/doc.go | 4 + .../v1alpha1/eventactivation.go | 141 + .../v1alpha1/fake/doc.go | 4 + .../v1alpha1/fake/fake_eventactivation.go | 112 + .../fake_remoteenvironment.kyma.cx_client.go | 24 + .../v1alpha1/generated_expansion.go | 5 + .../remoteenvironment.kyma.cx_client.go | 74 + .../remoteenvironment.kyma.io/v1alpha1/doc.go | 2 + .../v1alpha1/eventactivation.go | 139 + .../v1alpha1/fake/doc.go | 2 + .../v1alpha1/fake/fake_eventactivation.go | 110 + .../fake_remoteenvironment.kyma.io_client.go | 22 + .../v1alpha1/generated_expansion.go | 3 + .../remoteenvironment.kyma.io_client.go | 72 + .../remoteenvironment.ysf.io/v1alpha1/doc.go | 2 + .../v1alpha1/eventactivation.go | 139 + .../v1alpha1/fake/doc.go | 2 + .../v1alpha1/fake/fake_eventactivation.go | 110 + .../fake_remoteenvironment.ysf.io_client.go | 22 + .../v1alpha1/generated_expansion.go | 3 + .../remoteenvironment.ysf.io_client.go | 72 + .../ea/informers/externalversions/factory.go | 115 + .../ea/informers/externalversions/generic.go | 46 + .../internalinterfaces/factory_interfaces.go | 22 + .../remoteenvironment.kyma.cx/interface.go | 30 + .../v1alpha1/eventactivation.go | 73 + .../v1alpha1/interface.go | 29 + .../remoteenvironment.kyma.io/interface.go | 30 + .../v1alpha1/eventactivation.go | 73 + .../v1alpha1/interface.go | 29 + .../remoteenvironment.ysf.io/interface.go | 30 + .../v1alpha1/eventactivation.go | 73 + .../v1alpha1/interface.go | 29 + .../v1alpha1/eventactivation.go | 78 + .../v1alpha1/expansion_generated.go | 11 + .../v1alpha1/eventactivation.go | 78 + .../v1alpha1/expansion_generated.go | 11 + .../v1alpha1/eventactivation.go | 78 + .../v1alpha1/expansion_generated.go | 11 + .../push/clientset/versioned/clientset.go | 84 + .../generated/push/clientset/versioned/doc.go | 4 + .../versioned/fake/clientset_generated.go | 65 + .../push/clientset/versioned/fake/doc.go | 4 + .../push/clientset/versioned/fake/register.go | 38 + .../push/clientset/versioned/scheme/doc.go | 4 + .../clientset/versioned/scheme/register.go | 38 + .../typed/eventing.kyma.cx/v1alpha1/doc.go | 4 + .../v1alpha1/eventing.kyma.cx_client.go | 74 + .../eventing.kyma.cx/v1alpha1/fake/doc.go | 4 + .../fake/fake_eventing.kyma.cx_client.go | 24 + .../v1alpha1/fake/fake_subscription.go | 124 + .../v1alpha1/generated_expansion.go | 5 + .../eventing.kyma.cx/v1alpha1/subscription.go | 158 + .../typed/eventing.kyma.io/v1alpha1/doc.go | 2 + .../v1alpha1/eventing.kyma.io_client.go | 72 + .../eventing.kyma.io/v1alpha1/fake/doc.go | 2 + .../fake/fake_eventing.kyma.io_client.go | 22 + .../v1alpha1/fake/fake_subscription.go | 122 + .../v1alpha1/generated_expansion.go | 3 + .../eventing.kyma.io/v1alpha1/subscription.go | 156 + .../typed/eventing.ysf.io/v1alpha1/doc.go | 2 + .../v1alpha1/eventing.ysf.io_client.go | 72 + .../eventing.ysf.io/v1alpha1/fake/doc.go | 2 + .../fake/fake_eventing.ysf.io_client.go | 22 + .../v1alpha1/fake/fake_subscription.go | 122 + .../v1alpha1/generated_expansion.go | 3 + .../eventing.ysf.io/v1alpha1/subscription.go | 156 + .../eventing.kyma.cx/interface.go | 30 + .../eventing.kyma.cx/v1alpha1/interface.go | 29 + .../eventing.kyma.cx/v1alpha1/subscription.go | 73 + .../eventing.kyma.io/interface.go | 30 + .../eventing.kyma.io/v1alpha1/interface.go | 29 + .../eventing.kyma.io/v1alpha1/subscription.go | 73 + .../eventing.ysf.io/interface.go | 30 + .../eventing.ysf.io/v1alpha1/interface.go | 29 + .../eventing.ysf.io/v1alpha1/subscription.go | 73 + .../informers/externalversions/factory.go | 115 + .../informers/externalversions/generic.go | 46 + .../internalinterfaces/factory_interfaces.go | 22 + .../v1alpha1/expansion_generated.go | 11 + .../eventing.kyma.cx/v1alpha1/subscription.go | 78 + .../v1alpha1/expansion_generated.go | 11 + .../eventing.kyma.io/v1alpha1/subscription.go | 78 + .../v1alpha1/expansion_generated.go | 11 + .../eventing.ysf.io/v1alpha1/subscription.go | 78 + .../hack/boilerplate/boilerplate.go.txt | 0 .../event-bus/hack/update-client-gen.sh | 11 + .../event-bus/hack/update-ea-client-gen.sh | 11 + .../event-bus/internal/common/common.go | 92 + .../event-bus/internal/common/common_test.go | 76 + .../remoteenvironment.kyma.cx/register.go | 5 + .../remoteenvironment.kyma.cx/v1alpha1/doc.go | 5 + .../v1alpha1/register.go | 41 + .../v1alpha1/types.go | 39 + .../v1alpha1/zz_generated.deepcopy.go | 102 + components/event-bus/internal/publish/opts.go | 93 + components/event-bus/internal/publish/util.go | 15 + .../push/actors/subscriptions_supervisor.go | 204 + .../push/controllers/eventactivation.go | 47 + .../push/controllers/eventactivation_test.go | 78 + .../push/controllers/noeventactivation.go | 20 + .../controllers/subscriptions_controller.go | 100 + .../internal/push/handlers/message_handler.go | 211 + .../push/handlers/message_handler_test.go | 49 + .../event-bus/internal/push/opts/opts.go | 192 + .../event-bus/internal/stanutil/stanutil.go | 75 + .../sv/eventactivations_controller.go | 91 + .../internal/sv/eventactivations_utils.go | 29 + components/event-bus/internal/sv/opts/opts.go | 69 + .../internal/sv/subscriptions_controller.go | 89 + .../internal/sv/subscriptions_utils.go | 80 + components/event-bus/internal/trace/tags.go | 25 + components/event-bus/internal/trace/tracer.go | 153 + .../test/acceptance/push/acceptance_test.go | 247 + components/event-bus/test/util/crd.go | 54 + components/event-bus/test/util/logger.go | 22 + components/event-bus/test/util/subscriber.go | 97 + .../event-bus/test/util/subscriber_yaml.go | 74 + components/format.sh | 4 + components/gateway/Dockerfile | 19 + components/gateway/Gopkg.lock | 391 + components/gateway/Gopkg.toml | 60 + components/gateway/Jenkinsfile | 98 + components/gateway/README.md | 182 + components/gateway/cmd/gateway/gateway.go | 136 + components/gateway/cmd/gateway/options.go | 66 + components/gateway/docs/api/externalapi.yaml | 434 + components/gateway/docs/events/README.md | 47 + .../gateway/docs/instalation/Installation.md | 168 + .../docs/instalation/remote-environments.zip | Bin 0 -> 6141 bytes .../gateway/hack/custom-boilerplate.go.txt | 0 components/gateway/hack/generate-groups.sh | 89 + components/gateway/hack/update-codegen.sh | 14 + .../gateway/internal/apperrors/apperrors.go | 53 + .../internal/apperrors/apperrors_test.go | 34 + .../gateway/internal/events/api/types.go | 69 + .../gateway/internal/events/bus/process.go | 54 + .../gateway/internal/events/bus/send.go | 98 + .../internal/events/shared/constants.go | 35 + .../gateway/internal/events/shared/utils.go | 55 + .../internal/externalapi/blackb_data_test.go | 57 + .../internal/externalapi/blackb_test.go | 208 + .../internal/externalapi/errorhandler.go | 26 + .../internal/externalapi/errorhandler_test.go | 43 + .../internal/externalapi/eventshandler.go | 131 + .../externalapi/eventshandler_test.go | 107 + .../internal/externalapi/externalapi.go | 20 + .../externalapi/healthcheckhandler.go | 12 + .../externalapi/healthcheckhandler_test.go | 27 + .../gateway/internal/httpconsts/httpconsts.go | 14 + .../gateway/internal/httperrors/httperrors.go | 34 + components/gateway/internal/httptools/http.go | 21 + .../gateway/internal/httptools/logging.go | 42 + .../gateway/internal/k8sconsts/k8sconsts.go | 7 + .../internal/k8sconsts/mocks/NameResolver.go | 51 + .../internal/k8sconsts/nameresolver.go | 72 + .../internal/k8sconsts/nameresolver_test.go | 69 + .../mocks/ServiceDefinitionService.go | 37 + components/gateway/internal/metadata/model.go | 27 + .../metadata/remoteenv/mocks/Manager.go | 35 + .../remoteenv/mocks/ServiceRepository.go | 34 + .../internal/metadata/remoteenv/repository.go | 100 + .../metadata/remoteenv/repository_test.go | 120 + .../internal/metadata/remoteenv/util.go | 43 + .../metadata/secrets/mocks/Manager.go | 35 + .../metadata/secrets/mocks/Repository.go | 40 + .../internal/metadata/secrets/repository.go | 49 + .../metadata/secrets/repository_test.go | 100 + .../metadata/serviceapi/mocks/Service.go | 37 + .../metadata/serviceapi/serviceapiservice.go | 74 + .../serviceapi/serviceapiservice_test.go | 84 + .../internal/metadata/servicedefservice.go | 51 + .../metadata/servicedefservice_test.go | 115 + .../internal/proxy/mocks/OAuthClient.go | 33 + .../gateway/internal/proxy/oauthclient.go | 81 + .../internal/proxy/oauthclient_test.go | 87 + components/gateway/internal/proxy/proxy.go | 136 + .../gateway/internal/proxy/proxy_test.go | 392 + .../proxy/proxycache/mocks/HTTPProxyCache.go | 50 + .../internal/proxy/proxycache/proxycache.go | 50 + .../proxy/proxycache/proxycache_test.go | 102 + .../gateway/internal/proxy/proxyfactory.go | 63 + components/gateway/scripts/can-i-commit.sh | 51 + components/gateway/scripts/delete-all.sh | 13 + .../gateway/scripts/log-oauth-request.js | 25 + components/gateway/scripts/log-request.js | 19 + components/gateway/scripts/telepresence.log | 846 ++ .../gateway/test/echo-service/README.md | 145 + components/helm-broker/.gitignore | 106 + components/helm-broker/Gopkg.lock | 812 ++ components/helm-broker/Gopkg.toml | 91 + components/helm-broker/Jenkinsfile | 99 + components/helm-broker/README.md | 20 + components/helm-broker/before-commit.sh | 122 + components/helm-broker/cmd/broker/main.go | 83 + components/helm-broker/cmd/checker/main.go | 42 + .../helm-broker/cmd/indexbuilder/index.go | 48 + .../cmd/indexbuilder/index_test.go | 46 + .../helm-broker/cmd/indexbuilder/main.go | 88 + .../helm-broker/cmd/reposerver/README.md | 7 + components/helm-broker/cmd/reposerver/main.go | 54 + .../helm-broker/cmd/reposerver/main_test.go | 77 + components/helm-broker/cmd/targz/README.md | 7 + .../cmd/targz/archiver/archiver.go | 109 + .../helm-broker/cmd/targz/archiver/tar.go | 236 + .../helm-broker/cmd/targz/archiver/targz.go | 100 + components/helm-broker/cmd/targz/main.go | 55 + components/helm-broker/cmd/targz/main_test.go | 43 + .../input/quote-1.0.1/chart/quote/.helmignore | 21 + .../input/quote-1.0.1/chart/quote/Chart.yaml | 7 + .../chart/quote/templates/_helpers.tpl | 16 + .../chart/quote/templates/deployment.yaml | 43 + .../chart/quote/templates/ingress.yaml | 18 + .../chart/quote/templates/service.yaml | 17 + .../input/quote-1.0.1/chart/quote/values.yaml | 17 + .../testdata/input/quote-1.0.1/meta.yaml | 7 + .../plans/default/create-instance-schema.json | 11 + .../input/quote-1.0.1/plans/default/meta.yaml | 4 + .../quote-1.0.1/plans/default/values.yaml | 1 + .../input/redis-0.0.3/chart/redis/.helmignore | 1 + .../input/redis-0.0.3/chart/redis/Chart.yaml | 16 + .../input/redis-0.0.3/chart/redis/README.md | 120 + .../chart/redis/templates/NOTES.txt | 28 + .../chart/redis/templates/_helpers.tpl | 27 + .../chart/redis/templates/deployment.yaml | 96 + .../chart/redis/templates/networkpolicy.yaml | 30 + .../chart/redis/templates/pvc.yaml | 24 + .../chart/redis/templates/secrets.yaml | 18 + .../chart/redis/templates/svc.yaml | 20 + .../tests/test-redis-connection.yaml | 28 + .../input/redis-0.0.3/chart/redis/values.yaml | 86 + .../testdata/input/redis-0.0.3/meta.yaml | 13 + .../redis-0.0.3/plans/enterprise/bind.yaml | 13 + .../enterprise/create-instance-schema.json | 16 + .../redis-0.0.3/plans/enterprise/meta.yaml | 6 + .../redis-0.0.3/plans/enterprise/values.yaml | 7 + .../input/redis-0.0.3/plans/micro/bind.yaml | 15 + .../plans/micro/create-instance-schema.json | 16 + .../input/redis-0.0.3/plans/micro/meta.yaml | 6 + .../input/redis-0.0.3/plans/micro/values.yaml | 7 + .../helm-broker/deploy/broker/Dockerfile | 10 + .../helm-broker/deploy/reposerver/Dockerfile | 14 + .../helm-broker/deploy/tools/Dockerfile | 12 + .../broker/automock/bind_template_renderer.go | 35 + .../broker/automock/bind_template_resolver.go | 36 + .../broker/automock/bundle_storage.go | 55 + .../internal/broker/automock/chart_getter.go | 34 + .../internal/broker/automock/chart_storage.go | 34 + .../internal/broker/automock/converter.go | 31 + .../internal/broker/automock/extended.go | 59 + .../internal/broker/automock/helm_client.go | 49 + .../automock/instance_bind_data_getter.go | 33 + .../automock/instance_bind_data_inserter.go | 24 + .../automock/instance_bind_data_remover.go | 24 + .../broker/automock/instance_state_getter.go | 107 + .../broker/automock/instance_storage.go | 60 + .../broker/automock/operation_storage.go | 111 + .../helm-broker/internal/broker/bind.go | 38 + .../internal/broker/bind_export_test.go | 7 + .../helm-broker/internal/broker/bind_test.go | 117 + .../helm-broker/internal/broker/broker.go | 199 + .../internal/broker/broker_export_test.go | 13 + .../helm-broker/internal/broker/catalog.go | 97 + .../internal/broker/catalog_export_test.go | 10 + .../internal/broker/catalog_test.go | 211 + components/helm-broker/internal/broker/ctx.go | 18 + .../internal/broker/deprovision.go | 144 + .../broker/deprovision_export_test.go | 20 + .../internal/broker/deprovision_test.go | 383 + components/helm-broker/internal/broker/dto.go | 65 + .../helm-broker/internal/broker/error_test.go | 6 + .../internal/broker/export_test.go | 8 + .../internal/broker/fixture_test.go | 119 + .../helm-broker/internal/broker/lastop.go | 45 + .../helm-broker/internal/broker/middleware.go | 36 + .../internal/broker/osbapi_test.go | 527 ++ .../helm-broker/internal/broker/provision.go | 275 + .../internal/broker/provision_export_test.go | 31 + .../internal/broker/provision_test.go | 337 + .../helm-broker/internal/broker/server.go | 462 ++ .../helm-broker/internal/broker/state.go | 118 + .../internal/broker/state_export_test.go | 7 + .../helm-broker/internal/broker/state_test.go | 406 + .../helm-broker/internal/broker/unbind.go | 13 + .../helm-broker/internal/config/config.go | 84 + components/helm-broker/internal/doc.go | 1 + .../helm/automock/helm_delete_installer.go | 73 + .../helm-broker/internal/helm/client.go | 71 + .../helm-broker/internal/helm/client_test.go | 144 + .../helm-broker/internal/helm/config.go | 9 + components/helm-broker/internal/helm/dep.go | 12 + components/helm-broker/internal/model.go | 254 + components/helm-broker/internal/model_test.go | 40 + .../platform/idprovider/id_provider.go | 23 + .../internal/platform/logger/spy/formatter.go | 56 + .../internal/platform/logger/spy/logger.go | 133 + .../internal/storage/config_test.go | 26 + .../internal/storage/driver/etcd/client.go | 22 + .../internal/storage/driver/etcd/driver.go | 38 + .../storage/driver/etcd/entity_bundle.go | 381 + .../storage/driver/etcd/entity_chart.go | 150 + .../storage/driver/etcd/entity_instance.go | 111 + .../driver/etcd/entity_instance_bind_data.go | 131 + .../storage/driver/etcd/entity_operation.go | 242 + .../internal/storage/driver/etcd/error.go | 18 + .../internal/storage/driver/memory/driver.go | 6 + .../storage/driver/memory/entity_bundle.go | 164 + .../storage/driver/memory/entity_chart.go | 112 + .../storage/driver/memory/entity_instance.go | 67 + .../memory/entity_instance_bind_data.go | 68 + .../storage/driver/memory/entity_operation.go | 168 + .../internal/storage/driver/memory/error.go | 18 + .../internal/storage/driver/memory/sync.go | 29 + .../helm-broker/internal/storage/error.go | 18 + .../helm-broker/internal/storage/ext.go | 76 + .../internal/storage/factory_test.go | 47 + .../helm-broker/internal/storage/storage.go | 189 + .../storage/testdata/Config.golden.go | 93 + .../testdata/ConfigAllMemory.input.yaml | 3 + .../internal/storage/testdata/ex.input.yaml | 17 + .../internal/storage/testing/bundle_test.go | 330 + .../internal/storage/testing/chart_test.go | 250 + .../internal/storage/testing/common_test.go | 85 + .../internal/storage/testing/doc.go | 4 + .../testing/instance_bind_data_test.go | 207 + .../internal/storage/testing/instance_test.go | 201 + .../storage/testing/operation_export_test.go | 22 + .../storage/testing/operation_test.go | 460 ++ .../automock/chart_go_template_renderer.go | 34 + .../helm-broker/internal/ybind/renderer.go | 100 + .../internal/ybind/renderer_example_test.go | 66 + .../internal/ybind/renderer_export_test.go | 8 + .../internal/ybind/renderer_test.go | 217 + .../helm-broker/internal/ybind/resolver.go | 196 + .../internal/ybind/resolver_example_test.go | 157 + .../internal/ybind/resolver_test.go | 191 + .../redis-0.0.3/chart/redis/.helmignore | 1 + .../redis-0.0.3/chart/redis/Chart.yaml | 16 + .../redis-0.0.3/chart/redis/README.md | 120 + .../chart/redis/templates/NOTES.txt | 28 + .../chart/redis/templates/_helpers.tpl | 27 + .../chart/redis/templates/deployment.yaml | 96 + .../chart/redis/templates/networkpolicy.yaml | 30 + .../chart/redis/templates/pvc.yaml | 24 + .../chart/redis/templates/secrets.yaml | 18 + .../chart/redis/templates/svc.yaml | 20 + .../tests/test-redis-connection.yaml | 28 + .../redis-0.0.3/chart/redis/values.yaml | 86 + .../testdata/repository/redis-0.0.3/meta.yaml | 13 + .../redis-0.0.3/plans/enterprise/bind.yaml | 13 + .../enterprise/create-instance-schema.json | 16 + .../redis-0.0.3/plans/enterprise/meta.yaml | 6 + .../redis-0.0.3/plans/enterprise/values.yaml | 7 + .../redis-0.0.3/plans/micro/bind.yaml | 15 + .../plans/micro/create-instance-schema.json | 16 + .../redis-0.0.3/plans/micro/meta.yaml | 6 + .../redis-0.0.3/plans/micro/values.yaml | 7 + .../helm-broker/internal/ybind/types.go | 64 + .../ybundle/automock/bundle_loader.go | 43 + .../ybundle/automock/bundle_upserter.go | 30 + .../ybundle/automock/chart_upserter.go | 30 + .../internal/ybundle/automock/extended.go | 54 + .../internal/ybundle/automock/repository.go | 74 + .../internal/ybundle/export_test.go | 5 + .../helm-broker/internal/ybundle/form.go | 221 + .../helm-broker/internal/ybundle/form_test.go | 365 + .../internal/ybundle/helpers_test.go | 154 + .../helm-broker/internal/ybundle/loader.go | 317 + .../internal/ybundle/loader_test.go | 113 + .../internal/ybundle/local_repository.go | 32 + .../helm-broker/internal/ybundle/populator.go | 126 + .../internal/ybundle/populator_dto_test.go | 34 + .../internal/ybundle/populator_test.go | 213 + .../internal/ybundle/repository.go | 75 + .../internal/ybundle/repository_test.go | 66 + .../testdata/bundle-big-schema.input.tgz | Bin 0 -> 11081 bytes .../bundle-incorrect-create-schema.input.tgz | Bin 0 -> 6404 bytes ...-missing-bind-yaml-when-bindable.input.tgz | Bin 0 -> 6435 bytes .../bundle-missing-chart-dir.input.tgz | Bin 0 -> 1410 bytes .../bundle-missing-plan-meta-file.input.tgz | Bin 0 -> 6397 bytes .../testdata/bundle-missing-plans.input.tgz | Bin 0 -> 5558 bytes ...dle-multiple-charts-in-chart-dir.input.tgz | Bin 0 -> 6897 bytes .../bundle-no-chart-in-chart-dir.input.tgz | Bin 0 -> 1200 bytes .../chart/redis/.helmignore | 1 + .../chart/redis/Chart.yaml | 16 + .../chart/redis/README.md | 120 + .../chart/redis/templates/NOTES.txt | 28 + .../chart/redis/templates/_helpers.tpl | 27 + .../chart/redis/templates/deployment.yaml | 92 + .../chart/redis/templates/networkpolicy.yaml | 30 + .../chart/redis/templates/pvc.yaml | 24 + .../chart/redis/templates/secrets.yaml | 18 + .../chart/redis/templates/svc.yaml | 20 + .../chart/redis/values.yaml | 86 + .../bundle-redis-0.0.1.golden/meta.yaml | 13 + .../plans/enterprise/bind.yaml | 15 + .../enterprise/create-instance-schema.json | 24 + .../plans/enterprise/meta.yaml | 6 + .../enterprise/update-instance-schema.json | 24 + .../plans/enterprise/values.yaml | 7 + .../plans/micro/bind.yaml | 15 + .../plans/micro/create-instance-schema.json | 24 + .../plans/micro/meta.yaml | 6 + .../plans/micro/update-instance-schema.json | 24 + .../plans/micro/values.yaml | 7 + .../testdata/bundle-redis-0.0.1.input.tgz | Bin 0 -> 7651 bytes .../ybundle/testdata/index.input.yaml | 30 + components/helm-broker/pkg/doc.go | 1 + .../helm-broker/platform/logger/config.go | 37 + components/helm-broker/platform/logger/doc.go | 2 + .../helm-broker/platform/logger/logger.go | 95 + .../platform/logger/logger_mock.go | 34 + .../platform/logger/spy/formatter.go | 56 + .../helm-broker/platform/logger/spy/logger.go | 133 + components/helm-broker/platform/ptr/ptr.go | 40 + components/helm-broker/platform/time/time.go | 17 + components/helm-broker/tmp/.gitignore | 2 + components/idppreset/.gitignore | 1 + components/idppreset/Gopkg.lock | 391 + components/idppreset/Gopkg.toml | 48 + components/idppreset/README.md | 53 + components/idppreset/before-commit.sh | 88 + components/idppreset/hack/boilerplate.go.txt | 0 components/idppreset/hack/generate-groups.sh | 89 + components/idppreset/hack/update-codegen.sh | 14 + .../idppreset/pkg/apis/ui/v1alpha1/doc.go | 5 + .../pkg/apis/ui/v1alpha1/register.go | 39 + .../idppreset/pkg/apis/ui/v1alpha1/types.go | 36 + .../apis/ui/v1alpha1/zz_generated.deepcopy.go | 85 + .../client/clientset/versioned/clientset.go | 84 + .../pkg/client/clientset/versioned/doc.go | 4 + .../versioned/fake/clientset_generated.go | 65 + .../client/clientset/versioned/fake/doc.go | 4 + .../clientset/versioned/fake/register.go | 38 + .../client/clientset/versioned/scheme/doc.go | 4 + .../clientset/versioned/scheme/register.go | 38 + .../versioned/typed/ui/v1alpha1/doc.go | 4 + .../versioned/typed/ui/v1alpha1/fake/doc.go | 4 + .../typed/ui/v1alpha1/fake/fake_idppreset.go | 104 + .../typed/ui/v1alpha1/fake/fake_ui_client.go | 24 + .../typed/ui/v1alpha1/generated_expansion.go | 5 + .../versioned/typed/ui/v1alpha1/idppreset.go | 131 + .../versioned/typed/ui/v1alpha1/ui_client.go | 74 + .../informers/externalversions/factory.go | 115 + .../informers/externalversions/generic.go | 46 + .../internalinterfaces/factory_interfaces.go | 22 + .../externalversions/ui/interface.go | 30 + .../externalversions/ui/v1alpha1/idppreset.go | 72 + .../externalversions/ui/v1alpha1/interface.go | 29 + .../ui/v1alpha1/expansion_generated.go | 7 + .../client/listers/ui/v1alpha1/idppreset.go | 49 + components/installer/.gitignore | 22 + components/installer/Gopkg.lock | 273 + components/installer/Gopkg.toml | 53 + components/installer/Jenkinsfile | 97 + components/installer/README.md | 117 + components/installer/before-commit.sh | 105 + components/installer/cmd/operator/main.go | 93 + .../installer/hack/custom-boilerplate.go.txt | 0 components/installer/hack/update-codegen.sh | 18 + .../pkg/actionmanager/action-manager.go | 56 + .../pkg/apis/release/v1alpha1/doc.go | 4 + .../pkg/apis/release/v1alpha1/register.go | 46 + .../pkg/apis/release/v1alpha1/types.go | 41 + .../release/v1alpha1/zz_generated.deepcopy.go | 102 + .../installer/pkg/azure-vault/client.go | 82 + .../pkg/azure-vault/secret-provider.go | 61 + .../pkg/azure-vault/secret-provider_test.go | 58 + .../client/clientset/versioned/clientset.go | 106 + .../pkg/client/clientset/versioned/doc.go | 4 + .../versioned/fake/clientset_generated.go | 77 + .../client/clientset/versioned/fake/doc.go | 4 + .../clientset/versioned/fake/register.go | 40 + .../client/clientset/versioned/scheme/doc.go | 4 + .../clientset/versioned/scheme/register.go | 40 + .../versioned/typed/release/v1alpha1/doc.go | 4 + .../typed/release/v1alpha1/fake/doc.go | 4 + .../release/v1alpha1/fake/fake_release.go | 112 + .../v1alpha1/fake/fake_release_client.go | 24 + .../release/v1alpha1/generated_expansion.go | 5 + .../typed/release/v1alpha1/release.go | 141 + .../typed/release/v1alpha1/release_client.go | 74 + .../informers/externalversions/factory.go | 121 + .../informers/externalversions/generic.go | 51 + .../internalinterfaces/factory_interfaces.go | 22 + .../externalversions/release/interface.go | 30 + .../release/v1alpha1/interface.go | 29 + .../release/v1alpha1/release.go | 73 + .../release/v1alpha1/expansion_generated.go | 11 + .../listers/release/v1alpha1/release.go | 78 + .../pkg/conditionmanager/condition-manager.go | 284 + .../installer/pkg/config/environment.go | 47 + .../installer/pkg/config/installation-data.go | 72 + components/installer/pkg/consts/const.go | 8 + components/installer/pkg/errors/errors.go | 31 + components/installer/pkg/finalizer/manager.go | 72 + .../installer/pkg/finalizer/manager_test.go | 88 + .../installer/pkg/installation/controller.go | 293 + .../installer/pkg/kymahelm/ysf-helm-client.go | 122 + .../installer/pkg/overrides/azure-broker.go | 71 + .../pkg/overrides/azure-broker_test.go | 37 + components/installer/pkg/overrides/core.go | 51 + .../installer/pkg/overrides/core_test.go | 80 + components/installer/pkg/overrides/global.go | 42 + .../installer/pkg/overrides/global_test.go | 97 + components/installer/pkg/overrides/istio.go | 38 + .../installer/pkg/overrides/istio_test.go | 34 + .../pkg/overrides/remote-environment.go | 29 + .../installer/pkg/overrides/static-file.go | 80 + .../installer/pkg/release/controller.go | 153 + .../installer/pkg/servicecatalog/client.go | 83 + .../installer/pkg/servicecatalog/wrapper.go | 48 + .../pkg/statusmanager/status-manager.go | 102 + .../pkg/statusmanager/status-manager_test.go | 272 + .../pkg/steps/azure-deprovisioner.go | 297 + .../pkg/steps/azure-deprovisioner_test.go | 152 + .../installer/pkg/steps/cluster-essentials.go | 80 + .../pkg/steps/cluster-essentials_test.go | 39 + .../pkg/steps/cluster-prerequisites.go | 47 + .../pkg/steps/cluster-prerequisites_test.go | 39 + components/installer/pkg/steps/core.go | 120 + components/installer/pkg/steps/core_test.go | 73 + components/installer/pkg/steps/dex.go | 87 + components/installer/pkg/steps/dex_test.go | 71 + components/installer/pkg/steps/doc.go | 2 + .../installer/pkg/steps/download-kyma.go | 59 + .../installer/pkg/steps/download-kyma_test.go | 186 + .../installer/pkg/steps/installation.go | 237 + components/installer/pkg/steps/istio.go | 85 + components/installer/pkg/steps/istio_test.go | 45 + .../installer/pkg/steps/kyma-package.go | 30 + components/installer/pkg/steps/mock.go | 188 + components/installer/pkg/steps/prometheus.go | 78 + .../installer/pkg/steps/prometheus_test.go | 39 + .../installer/pkg/steps/provision-bundles.go | 49 + .../installer/pkg/steps/remote-environment.go | 170 + .../pkg/steps/remove-kyma-components.go | 57 + .../pkg/steps/remove-kyma-sources.go | 24 + components/installer/pkg/steps/tiller.go | 47 + components/installer/pkg/steps/tiller_test.go | 39 + .../pkg/toolkit/command-script-exec.go | 58 + .../pkg/toolkit/installdata-creator.go | 117 + components/installer/scripts/build.sh | 46 + components/installer/scripts/run.sh | 42 + components/istio-webhook/.dockerignore | 1 + components/istio-webhook/Dockerfile | 12 + components/istio-webhook/Jenkinsfile | 73 + components/istio-webhook/README.md | 13 + components/istio-webhook/app.py | 167 + .../remote-environment-broker/.gitignore | 104 + .../remote-environment-broker/Gopkg.lock | 712 ++ .../remote-environment-broker/Gopkg.toml | 132 + .../remote-environment-broker/Jenkinsfile | 86 + .../remote-environment-broker/README.md | 30 + .../before-commit.sh | 120 + .../cmd/broker/main.go | 136 + .../cmd/poc-events/events.go | 84 + .../cmd/poc-events/findings.md | 45 + .../cmd/poc-finalizers/controller.go | 202 + .../cmd/poc-finalizers/finalizers-findings.md | 94 + .../cmd/poc-finalizers/main.go | 103 + .../cmd/poc-finalizers/mapping-prod.yaml | 5 + .../remote-environment-prod.yaml | 55 + .../contrib/examples/binding.yaml | 8 + .../contrib/examples/event-activation.yaml | 13 + .../contrib/examples/mapping.yaml | 8 + .../examples/osb-catalog-response.json | 61 + .../contrib/examples/remote-env.yaml | 52 + .../contrib/examples/service-instance.yaml | 14 + .../deploy/broker/Dockerfile | 11 + .../hack/boilerplate.go.txt | 0 .../hack/generate-groups.sh | 89 + .../hack/update-codegen.sh | 14 + .../internal/access/access.go | 60 + .../access/automock/provision_checker.go | 33 + .../automock/remote_environment_finder.go | 33 + .../internal/access/provision_mapping.go | 93 + .../internal/access/provision_mapping_test.go | 111 + .../internal/access/provision_unique.go | 60 + .../internal/access/provision_unique_test.go | 59 + .../broker/automock/instance_getter.go | 33 + .../broker/automock/instance_state_getter.go | 107 + .../broker/automock/instance_storage.go | 98 + .../broker/automock/operation_storage.go | 111 + .../internal/broker/automock/re_finder.go | 79 + .../automock/service_instance_getter.go | 33 + .../internal/broker/bind.go | 46 + .../internal/broker/bind_export_test.go | 13 + .../internal/broker/bind_test.go | 96 + .../internal/broker/broker.go | 124 + .../internal/broker/catalog.go | 151 + .../internal/broker/catalog_export_test.go | 6 + .../internal/broker/catalog_test.go | 174 + .../internal/broker/ctx.go | 18 + .../internal/broker/deprovision.go | 130 + .../internal/broker/deprovision_test.go | 174 + .../internal/broker/dto.go | 61 + .../internal/broker/error.go | 34 + .../internal/broker/error_test.go | 40 + .../internal/broker/export_test.go | 8 + .../internal/broker/fix_test.go | 152 + .../internal/broker/fixture_test.go | 47 + .../internal/broker/lastop.go | 44 + .../internal/broker/middleware.go | 36 + .../internal/broker/provision.go | 244 + .../internal/broker/provision_test.go | 498 ++ .../internal/broker/server.go | 462 ++ .../internal/broker/service_instance.go | 57 + .../internal/broker/service_instance_test.go | 72 + .../internal/broker/state.go | 110 + .../internal/broker/state_export_test.go | 7 + .../internal/broker/state_test.go | 406 + .../internal/config/config.go | 81 + .../remote-environment-broker/internal/doc.go | 1 + .../internal/labeler/automock/ns_patcher.go | 41 + .../internal/labeler/automock/re_getter.go | 34 + .../internal/labeler/controller.go | 302 + .../labeler/controller_export_test.go | 15 + .../internal/labeler/controller_test.go | 228 + .../internal/model.go | 153 + .../internal/storage/config_test.go | 26 + .../internal/storage/driver/memory/driver.go | 6 + .../storage/driver/memory/entity_instance.go | 110 + .../storage/driver/memory/entity_operation.go | 155 + .../driver/memory/entity_remoteenvironment.go | 129 + .../internal/storage/driver/memory/error.go | 18 + .../internal/storage/driver/memory/sync.go | 29 + .../internal/storage/ext.go | 51 + .../internal/storage/factory_test.go | 34 + .../populator/automock/instance_inserter.go | 24 + .../internal/storage/populator/instance.go | 143 + .../storage/populator/instance_test.go | 203 + .../internal/storage/storage.go | 129 + .../storage/testdata/Config.golden.go | 31 + .../testdata/ConfigAllMemory.input.yaml | 3 + .../internal/storage/testing/common_test.go | 41 + .../internal/storage/testing/doc.go | 4 + .../internal/storage/testing/instance_test.go | 248 + .../storage/testing/remoteenvironment_test.go | 263 + .../internal/syncer/automock/extended.go | 23 + .../automock/remote_environment_cr_mapper.go | 28 + .../remote_environment_cr_validator.go | 25 + .../automock/remote_environment_remover.go | 24 + .../automock/remote_environment_upserter.go | 31 + .../syncer/automock/sc_relist_requester.go | 14 + .../syncer/automock/service_catalog_syncer.go | 23 + .../internal/syncer/controller.go | 240 + .../internal/syncer/controller_export_test.go | 11 + .../internal/syncer/controller_test.go | 101 + .../internal/syncer/mapper.go | 76 + .../internal/syncer/mapper_test.go | 154 + .../internal/syncer/sc_relist_requester.go | 85 + .../syncer/sc_relist_requester_export_test.go | 6 + .../syncer/sc_relist_requester_test.go | 104 + .../syncer/testdata/re-CR-valid.input.yaml | 37 + .../internal/syncer/validator.go | 60 + .../internal/syncer/validator_test.go | 118 + .../apis/remoteenvironment/v1alpha1/doc.go | 4 + .../remoteenvironment/v1alpha1/register.go | 43 + .../apis/remoteenvironment/v1alpha1/types.go | 169 + .../v1alpha1/zz_generated.deepcopy.go | 328 + .../client/clientset/versioned/clientset.go | 84 + .../pkg/client/clientset/versioned/doc.go | 4 + .../versioned/fake/clientset_generated.go | 65 + .../client/clientset/versioned/fake/doc.go | 4 + .../clientset/versioned/fake/register.go | 38 + .../client/clientset/versioned/scheme/doc.go | 4 + .../clientset/versioned/scheme/register.go | 38 + .../typed/remoteenvironment/v1alpha1/doc.go | 4 + .../v1alpha1/environmentmapping.go | 141 + .../v1alpha1/eventactivation.go | 141 + .../remoteenvironment/v1alpha1/fake/doc.go | 4 + .../v1alpha1/fake/fake_environmentmapping.go | 112 + .../v1alpha1/fake/fake_eventactivation.go | 112 + .../v1alpha1/fake/fake_remoteenvironment.go | 115 + .../fake/fake_remoteenvironment_client.go | 32 + .../v1alpha1/generated_expansion.go | 9 + .../v1alpha1/remoteenvironment.go | 147 + .../v1alpha1/remoteenvironment_client.go | 84 + .../informers/externalversions/factory.go | 115 + .../informers/externalversions/generic.go | 50 + .../internalinterfaces/factory_interfaces.go | 22 + .../remoteenvironment/interface.go | 30 + .../v1alpha1/environmentmapping.go | 73 + .../v1alpha1/eventactivation.go | 73 + .../remoteenvironment/v1alpha1/interface.go | 43 + .../v1alpha1/remoteenvironment.go | 72 + .../v1alpha1/environmentmapping.go | 78 + .../v1alpha1/eventactivation.go | 78 + .../v1alpha1/expansion_generated.go | 23 + .../v1alpha1/remoteenvironment.go | 49 + .../platform/idprovider/id_provider.go | 23 + .../platform/logger/config.go | 37 + .../platform/logger/doc.go | 2 + .../platform/logger/logger.go | 95 + .../platform/logger/logger_mock.go | 34 + .../platform/logger/spy/formatter.go | 56 + .../platform/logger/spy/logger.go | 133 + .../platform/time/time.go | 17 + components/ui-api-layer/.gitignore | 8 + components/ui-api-layer/CONTRIBUTING.md | 85 + components/ui-api-layer/Dockerfile | 42 + components/ui-api-layer/Gopkg.lock | 630 ++ components/ui-api-layer/Gopkg.toml | 64 + components/ui-api-layer/Jenkinsfile | 88 + components/ui-api-layer/LICENSE | 202 + components/ui-api-layer/README.md | 99 + components/ui-api-layer/before-commit.sh | 95 + components/ui-api-layer/codegen.sh | 19 + .../examples/service-catalog-test-data.yaml | 195 + components/ui-api-layer/docs/README.md | 9 + components/ui-api-layer/docs/architecture.md | 5 + .../assets/ui-api-layer-architecture.html | 12 + .../docs/assets/ui-api-layer-architecture.png | Bin 0 -> 79140 bytes components/ui-api-layer/docs/configuration.md | 37 + .../ui-api-layer/docs/project-structure.md | 33 + components/ui-api-layer/docs/terminology.md | 7 + .../domain/apicontroller/api_converter.go | 53 + .../apicontroller/api_converter_test.go | 105 + .../domain/apicontroller/api_resolver.go | 32 + .../domain/apicontroller/api_resolver_test.go | 69 + .../domain/apicontroller/api_service.go | 52 + .../domain/apicontroller/api_service_test.go | 122 + .../domain/apicontroller/apicontroller.go | 38 + .../apicontroller/automock/api_lister.go | 33 + .../domain/apicontroller/automock/export.go | 5 + .../domain/apicontroller/interfaces.go | 10 + .../domain/content/apispec_service.go | 27 + .../domain/content/apispec_service_test.go | 61 + .../domain/content/asyncapispec_service.go | 27 + .../content/asyncapispec_service_test.go | 63 + .../domain/content/automock/content_getter.go | 33 + .../domain/content/automock/export.go | 21 + .../content/automock/minio_api_spec_getter.go | 40 + .../automock/minio_async_api_spec_getter.go | 40 + .../content/automock/minio_content_getter.go | 40 + .../automock/topics_converter_interface.go | 50 + .../internal/domain/content/content.go | 103 + .../domain/content/content_converter.go | 21 + .../domain/content/content_converter_test.go | 44 + .../domain/content/content_resolver.go | 35 + .../domain/content/content_resolver_test.go | 65 + .../domain/content/content_service.go | 27 + .../domain/content/content_service_test.go | 61 + .../internal/domain/content/export_test.go | 29 + .../internal/domain/content/interfaces.go | 32 + .../domain/content/storage/automock/cache.go | 74 + .../domain/content/storage/automock/minio.go | 49 + .../internal/domain/content/storage/cache.go | 237 + .../domain/content/storage/cache_test.go | 975 +++ .../domain/content/storage/export_test.go | 33 + .../domain/content/storage/interfaces.go | 28 + .../domain/content/storage/minioclient.go | 75 + .../content/storage/minioclient_test.go | 60 + .../domain/content/storage/mock_client.go | 63 + .../content/storage/mock_store_getter.go | 115 + .../domain/content/storage/storage.go | 14 + .../internal/domain/content/storage/store.go | 146 + .../domain/content/storage/store_test.go | 336 + .../internal/domain/content/storage/types.go | 90 + .../domain/content/topics_converter.go | 79 + .../domain/content/topics_converter_test.go | 156 + .../domain/content/topics_resolver.go | 60 + .../domain/content/topics_resolver_test.go | 178 + .../domain/k8s/automock/deployment_lister.go | 56 + .../domain/k8s/automock/env_lister.go | 57 + .../internal/domain/k8s/automock/expect.go | 13 + .../internal/domain/k8s/automock/export.go | 13 + .../domain/k8s/automock/limit_range_lister.go | 33 + .../k8s/automock/resource_quota_lister.go | 33 + .../k8s/automock/service_binding_getter.go | 33 + .../automock/service_binding_usage_lister.go | 33 + .../domain/k8s/deployment_converter.go | 81 + .../domain/k8s/deployment_converter_test.go | 148 + .../domain/k8s/deployment_resolver.go | 88 + .../domain/k8s/deployment_resolver_test.go | 322 + .../internal/domain/k8s/deployment_service.go | 76 + .../domain/k8s/deployment_service_test.go | 103 + .../domain/k8s/environment_resolver.go | 43 + .../domain/k8s/environment_service.go | 75 + .../internal/domain/k8s/export_test.go | 26 + .../internal/domain/k8s/interfaces.go | 29 + .../ui-api-layer/internal/domain/k8s/k8s.go | 60 + .../domain/k8s/limitrange_converter.go | 62 + .../domain/k8s/limitrange_converter_test.go | 153 + .../domain/k8s/limitrange_resolver.go | 35 + .../domain/k8s/limitrange_resolver_test.go | 29 + .../internal/domain/k8s/limitrange_service.go | 35 + .../domain/k8s/limitrange_service_test.go | 50 + .../domain/k8s/resourcequota_converter.go | 48 + .../k8s/resourcequota_converter_test.go | 95 + .../domain/k8s/resourcequota_resolver.go | 32 + .../domain/k8s/resourcequota_resolver_test.go | 38 + .../domain/k8s/resourcequota_service.go | 36 + .../domain/k8s/resourcequota_service_test.go | 53 + .../internal/domain/k8s/secret_converter.go | 24 + .../domain/k8s/secret_converter_test.go | 44 + .../internal/domain/k8s/secret_resolver.go | 39 + .../domain/k8s/secret_resolver_test.go | 60 + .../domain/kubeless/automock/export.go | 5 + .../kubeless/automock/function_lister.go | 34 + .../internal/domain/kubeless/export_test.go | 11 + .../domain/kubeless/function_converter.go | 39 + .../kubeless/function_converter_test.go | 103 + .../domain/kubeless/function_resolver.go | 37 + .../domain/kubeless/function_resolver_test.go | 71 + .../domain/kubeless/function_service.go | 39 + .../domain/kubeless/function_service_test.go | 67 + .../internal/domain/kubeless/interfaces.go | 11 + .../internal/domain/kubeless/kubeless.go | 36 + .../automock/async_api_spec_getter.go | 34 + .../automock/event_activation_lister.go | 34 + .../remoteenvironment/automock/export.go | 15 + .../remoteenvironment/automock/re_svc.go | 162 + .../automock/status_getter.go | 24 + .../eventactivation_converter.go | 110 + .../eventactivation_converter_test.go | 190 + .../eventactivation_resolver.go | 65 + .../eventactivation_resolver_test.go | 162 + .../eventactivation_service.go | 50 + .../eventactivation_service_test.go | 69 + .../domain/remoteenvironment/export_test.go | 19 + .../gateway/automock/expect.go | 9 + .../gateway/automock/export.go | 5 + .../automock/gateway_service_lister.go | 26 + .../remoteenvironment/gateway/config.go | 9 + .../remoteenvironment/gateway/export_test.go | 15 + .../remoteenvironment/gateway/provider.go | 122 + .../gateway/provider_test.go | 96 + .../remoteenvironment/gateway/service.go | 45 + .../gateway/status_watcher.go | 114 + .../gateway/status_watcher_test.go | 72 + .../domain/remoteenvironment/interfaces.go | 18 + .../internal/domain/remoteenvironment/re.go | 83 + .../domain/remoteenvironment/re_converter.go | 66 + .../remoteenvironment/re_converter_test.go | 87 + .../domain/remoteenvironment/re_resolver.go | 167 + .../remoteenvironment/re_resolver_test.go | 111 + .../domain/remoteenvironment/re_service.go | 228 + .../remoteenvironment/re_service_test.go | 281 + .../internal/domain/root_resolver.go | 293 + .../automock/api_spec_getter.go | 34 + .../automock/async_api_spec_getter.go | 34 + .../servicecatalog/automock/broker_getter.go | 34 + .../automock/broker_list_getter.go | 58 + .../servicecatalog/automock/broker_lister.go | 35 + .../servicecatalog/automock/class_getter.go | 57 + .../automock/class_instance_lister.go | 34 + .../automock/class_list_getter.go | 81 + .../servicecatalog/automock/content_getter.go | 34 + .../domain/servicecatalog/automock/export.go | 77 + .../automock/gql_broker_converter.go | 58 + .../automock/gql_class_converter.go | 58 + .../automock/gql_plan_converter.go | 58 + .../automock/instance_getter.go | 34 + .../automock/instance_lister.go | 59 + .../servicecatalog/automock/plan_getter.go | 57 + .../servicecatalog/automock/plan_lister.go | 34 + .../automock/service_binding_operations.go | 94 + .../service_binding_usage_operations.go | 94 + .../status_binding_usage_extractor.go | 26 + .../servicecatalog/binding_converter.go | 49 + .../servicecatalog/binding_converter_test.go | 155 + .../domain/servicecatalog/binding_resolver.go | 83 + .../servicecatalog/binding_resolver_test.go | 199 + .../domain/servicecatalog/binding_service.go | 88 + .../servicecatalog/binding_service_test.go | 106 + .../servicecatalog/binding_usage_converter.go | 137 + .../binding_usage_converter_test.go | 284 + .../servicecatalog/binding_usage_resolver.go | 107 + .../binding_usage_resolver_test.go | 246 + .../servicecatalog/binding_usage_service.go | 126 + .../binding_usage_service_test.go | 213 + .../domain/servicecatalog/broker_converter.go | 102 + .../servicecatalog/broker_converter_test.go | 158 + .../domain/servicecatalog/broker_resolver.go | 68 + .../domain/servicecatalog/broker_service.go | 51 + .../servicecatalog/broker_service_test.go | 101 + .../domain/servicecatalog/class_converter.go | 59 + .../servicecatalog/class_converter_test.go | 178 + .../domain/servicecatalog/class_resolver.go | 216 + .../servicecatalog/class_resolver_test.go | 473 ++ .../domain/servicecatalog/class_service.go | 87 + .../servicecatalog/class_service_test.go | 160 + .../domain/servicecatalog/export_test.go | 106 + .../servicecatalog/instance_converter.go | 172 + .../servicecatalog/instance_converter_test.go | 336 + .../servicecatalog/instance_listener.go | 59 + .../servicecatalog/instance_listener_test.go | 127 + .../servicecatalog/instance_resolver.go | 248 + .../servicecatalog/instance_resolver_test.go | 483 ++ .../domain/servicecatalog/instance_service.go | 253 + .../servicecatalog/instance_service_test.go | 384 + .../domain/servicecatalog/interfaces.go | 158 + .../mock_gql_instance_converter.go | 120 + .../servicecatalog/mock_instance_svc.go | 143 + .../domain/servicecatalog/plan_converter.go | 126 + .../servicecatalog/plan_converter_test.go | 243 + .../domain/servicecatalog/plan_service.go | 99 + .../servicecatalog/plan_service_test.go | 165 + .../domain/servicecatalog/servicecatalog.go | 82 + .../domain/servicecatalog/status/binding.go | 71 + .../servicecatalog/status/binding_test.go | 69 + .../servicecatalog/status/binding_usage.go | 55 + .../status/binding_usage_test.go | 69 + .../domain/servicecatalog/status/instance.go | 106 + .../servicecatalog/status/instance_test.go | 205 + .../internal/domain/ui/automock/export.go | 9 + .../ui/automock/gql_idp_preset_converter.go | 26 + .../domain/ui/automock/idp_preset_svc.go | 95 + .../internal/domain/ui/export_test.go | 18 + .../internal/domain/ui/helpers_test.go | 36 + .../internal/domain/ui/idppreset_converter.go | 20 + .../domain/ui/idppreset_converter_test.go | 57 + .../internal/domain/ui/idppreset_resolver.go | 105 + .../domain/ui/idppreset_resolver_test.go | 314 + .../internal/domain/ui/idppreset_service.go | 84 + .../domain/ui/idppreset_service_test.go | 173 + .../ui-api-layer/internal/domain/ui/ui.go | 38 + .../internal/gqlschema/Section.go | 6 + .../ui-api-layer/internal/gqlschema/Title.go | 7 + .../internal/gqlschema/TopicEntry.go | 7 + .../ui-api-layer/internal/gqlschema/api.go | 8 + .../internal/gqlschema/deployment.go | 12 + .../internal/gqlschema/deploymentstatus.go | 9 + .../internal/gqlschema/eventactivation.go | 7 + .../ui-api-layer/internal/gqlschema/json.go | 26 + .../internal/gqlschema/limitrange.go | 13 + .../internal/gqlschema/models_gen.go | 457 ++ .../internal/gqlschema/remoteenvironment.go | 9 + .../gqlschema/remoteenvironmentservice.go | 10 + .../internal/gqlschema/resourcequota.go | 8 + .../internal/gqlschema/schema.graphql | 477 ++ .../internal/gqlschema/schema_gen.go | 7272 +++++++++++++++++ .../internal/gqlschema/servicebinding.go | 9 + .../internal/gqlschema/servicebindingusage.go | 23 + .../internal/gqlschema/servicebroker.go | 11 + .../internal/gqlschema/serviceclass.go | 19 + .../internal/gqlschema/serviceinstance.go | 15 + .../gqlschema/serviceinstanceevent.go | 6 + .../internal/gqlschema/timestamp.go | 23 + .../internal/gqlschema/types.json | 25 + .../pager/automock/pageable_indexer.go | 85 + .../internal/pager/automock/pageable_store.go | 71 + .../ui-api-layer/internal/pager/indexpager.go | 41 + .../internal/pager/indexpager_test.go | 149 + .../ui-api-layer/internal/pager/pager.go | 123 + .../ui-api-layer/internal/pager/pager_test.go | 127 + .../internal/resource/extractdata.go | 32 + .../internal/testing/waitforinformer.go | 28 + components/ui-api-layer/main.go | 126 + .../ui-api-layer/pkg/executor/executor.go | 34 + .../pkg/executor/executor_test.go | 32 + .../ui-api-layer/pkg/iosafety/iosafety.go | 16 + .../ui-api-layer/pkg/jsoncopy/jsoncopy.go | 38 + .../pkg/resource/automock/listener.go | 24 + .../ui-api-layer/pkg/resource/notifier.go | 84 + .../pkg/resource/notifier_test.go | 287 + components/ui-api-layer/pkg/signal/signal.go | 26 + docs/Jenkinsfile | 156 + docs/LICENSE | 202 + docs/README.md | 18 + .../assets/001-service-exposure-flow.html | 11 + .../assets/001-service-exposure-flow.png | Bin 0 -> 35691 bytes docs/api-gateway/docs.config.json | 8 + docs/api-gateway/docs/001-overview.md | 6 + docs/api-gateway/docs/005-architecture.md | 23 + docs/api-gateway/docs/008-security.md | 37 + docs/api-gateway/docs/011-CRD.md | 45 + docs/application-connector/docs.config.json | 8 + .../001-overview-application-connector.md | 10 + .../005-architecture-application-connector.md | 15 + .../docs/006-architecture-ingress-gateway.md | 15 + .../docs/010-details-ac-deployment.md | 67 + .../docs/011-details-ac-security.md | 30 + .../012-details-serviceclass-documentation.md | 29 + .../docs/013-details-api.md | 7 + .../docs/014-details-remote-environment.md | 99 + .../015-details-one-click-configuration.md | 94 + ...etails-passing-header-with-access-token.md | 18 + .../docs/030-examples-ac.md | 113 + .../assets/001-application-connector.html | 12 + .../docs/assets/001-application-connector.png | Bin 0 -> 44802 bytes .../assets/002-automatic-configuration.html | 12 + .../assets/002-automatic-configuration.png | Bin 0 -> 78131 bytes .../docs/assets/externalapi.yaml | 434 + .../docs/assets/remote-environment-prod.yaml | 61 + .../docs/assets/remote-environments.zip | Bin 0 -> 4662 bytes docs/assets/crd/mapping-prod.yaml | 6 + docs/assets/rebase.gif | Bin 0 -> 149302 bytes docs/assets/squash.gif | Bin 0 -> 407573 bytes .../docs.config.json | 8 + .../docs/001-overview.md | 23 + .../docs/003-architecture.md | 14 + .../docs/005-details-add-connector.md | 61 + .../docs/assets/001-kyma-authorization.html | 11 + .../docs/assets/001-kyma-authorization.png | Bin 0 -> 55744 bytes docs/event-bus/docs.config.json | 8 + docs/event-bus/docs/001-overview-event-bus.md | 8 + docs/event-bus/docs/010-details-concepts.md | 20 + .../011-details-event-flow-requirements.md | 45 + .../docs/012-details-troubleshooting.md | 13 + .../docs/020-architecture-event-bus.md | 50 + docs/event-bus/docs/030-cli-reference.md | 69 + .../docs/assets/event-activation.html | 12 + .../docs/assets/event-activation.png | Bin 0 -> 62833 bytes .../docs/assets/event-bus-architecture.html | 12 + .../docs/assets/event-bus-architecture.png | Bin 0 -> 76963 bytes .../docs/assets/event-validation.html | 12 + .../docs/assets/event-validation.png | Bin 0 -> 29126 bytes docs/kyma/docs.config.json | 8 + docs/kyma/docs/001-overview.md | 25 + docs/kyma/docs/002-components.md | 42 + docs/kyma/docs/005-environments.md | 44 + docs/kyma/docs/019-prereq-reasoning.md | 13 + .../docs/025-details-local-reinstallation.md | 18 + docs/kyma/docs/026-details-testing.md | 82 + docs/kyma/docs/027-details-charts.md | 152 + .../028-details-deploy-private-registry.md | 78 + docs/kyma/docs/031-gs-local-installation.md | 136 + ...2-gs-sample-service-deployment-to-local.md | 72 + ...gs-sample-service-deployment-to-cluster.md | 83 + .../docs/034-gs-local-develop-no-docker.md | 130 + ...035-gs-publish-service-image-and-deploy.md | 64 + docs/kyma/docs/assets/api-with-auth.yaml | 17 + docs/kyma/docs/assets/api-without-auth.yaml | 13 + docs/kyma/docs/assets/deployment.yaml | 42 + docs/manifest.yaml | 28 + docs/monitoring/docs.config.json | 8 + .../docs/001-overview-monitoring.md | 14 + .../docs/020-architecture-monitoring.md | 34 + docs/monitoring/docs/assets/monitoring.png | Bin 0 -> 53613 bytes docs/serverless/docs.config.json | 8 + docs/serverless/docs/001-Overview.md | 35 + docs/serverless/docs/020-custom-resources.md | 41 + .../docs/021-details-managing-lambdas.md | 17 + docs/serverless/docs/030-architecture.md | 54 + docs/serverless/docs/035-programming-model.md | 76 + docs/serverless/docs/040-cli-reference.md | 91 + .../serverless/docs/assets/api-with-auth.yaml | 18 + .../docs/assets/api-without-auth.yaml | 13 + docs/serverless/docs/assets/deployment.yaml | 18 + docs/serverless/docs/assets/hello.js | 5 + docs/serverless/docs/assets/istio.html | 11 + .../docs/assets/kyma_connected.html | 12 + .../serverless/docs/assets/kyma_connected.png | Bin 0 -> 5332 bytes .../docs/assets/lambda_example.html | 12 + .../serverless/docs/assets/lambda_example.png | Bin 0 -> 23766 bytes docs/serverless/docs/assets/monitoring.html | 11 + docs/serverless/docs/assets/nats.html | 11 + .../docs/assets/serverless_general.html | 11 + .../docs/assets/serverless_general.png | Bin 0 -> 15448 bytes .../azure-mysql/docs.config.json | 8 + .../azure-mysql/docs/overview.md | 15 + .../azure-mysql/docs/plans-details.md | 43 + .../azure-redis-cache/docs.config.json | 8 + .../azure-redis-cache/docs/overview.md | 13 + .../azure-redis-cache/docs/plans-details.md | 37 + .../azure-sql/docs.config.json | 8 + .../azure-sql/docs/overview.md | 15 + .../azure-sql/docs/plans-details.md | 51 + docs/service-brokers/docs.config.json | 8 + .../docs/001-overview-service-brokers.md | 23 + .../docs/002-overview-azure-broker.md | 14 + docs/service-brokers/docs/003-overview-reb.md | 10 + .../docs/004-overview-helm-broker.md | 8 + .../docs/011-configuration-helm-broker.md | 59 + .../012-configuration-helm-broker-bundles.md | 104 + ...nfiguration-helm-broker-bundles-binding.md | 140 + .../014-configuration-enable-azure-broker.md | 19 + .../docs/020-architecture-reb.md | 55 + .../docs/021-architecture-helm-broker.md | 25 + .../docs/030-examples-environment-mapping.md | 65 + .../docs/assets/001-REB-registration.html | 11 + .../docs/assets/001-REB-registration.png | Bin 0 -> 57040 bytes .../docs/assets/002-REB-envmapping.html | 11 + .../docs/assets/002-REB-envmapping.png | Bin 0 -> 36627 bytes .../assets/003-REB-API-service-class.html | 11 + .../docs/assets/003-REB-API-service-class.png | Bin 0 -> 58209 bytes .../assets/004-REB-event-service-class.html | 11 + .../assets/004-REB-event-service-class.png | Bin 0 -> 67432 bytes .../docs/assets/010-helm-registration.html | 11 + .../docs/assets/010-helm-registration.png | Bin 0 -> 48987 bytes .../docs/assets/011-helm-architecture.html | 11 + .../docs/assets/011-helm-architecture.png | Bin 0 -> 52945 bytes docs/service-catalog/docs.config.json | 8 + .../docs/001-overview-service-catalog.md | 15 + .../docs/010-details-resources.md | 22 + .../011-details-add-service-to-the-catalog.md | 15 + .../012-details-provisioning-and-binding.md | 67 + .../docs/013-details-unbinding-corner-case.md | 18 + .../docs/020-architecture-service-catalog.md | 18 + .../service-catalog/docs/030-cli-reference.md | 74 + docs/service-catalog/docs/assets/binding.html | 12 + docs/service-catalog/docs/assets/binding.png | Bin 0 -> 41283 bytes .../docs/assets/deprovisioning.html | 12 + .../docs/assets/deprovisioning.png | Bin 0 -> 20668 bytes .../docs/assets/provisioning-and-binding.html | 12 + .../docs/assets/provisioning-and-binding.png | Bin 0 -> 61528 bytes .../docs/assets/provisioning.html | 12 + .../docs/assets/provisioning.png | Bin 0 -> 20617 bytes .../docs/assets/service-catalog-flow.html | 12 + .../docs/assets/service-catalog-flow.png | Bin 0 -> 96454 bytes .../docs/assets/unbinding-corner-case.html | 12 + .../docs/assets/unbinding-corner-case.png | Bin 0 -> 46129 bytes .../docs/assets/unbinding.html | 12 + .../service-catalog/docs/assets/unbinding.png | Bin 0 -> 43616 bytes docs/service-mesh/docs.config.json | 8 + docs/service-mesh/docs/001-overview.md | 9 + .../docs/005-sidecar-proxy-injection.md | 19 + docs/tracing/docs.config.json | 8 + docs/tracing/docs/001-overview-tracing.md | 32 + docs/tracing/docs/020-architecture-tracing.md | 26 + docs/tracing/docs/assets/request-traces.html | 12 + docs/tracing/docs/assets/request-traces.png | Bin 0 -> 15556 bytes docs/tracing/docs/assets/store-traces.html | 12 + docs/tracing/docs/assets/store-traces.png | Bin 0 -> 20817 bytes .../docs/assets/tracing-architecture.html | 12 + .../docs/assets/tracing-architecture.png | Bin 0 -> 27527 bytes governance.Jenkinsfile | 42 + installation/README.md | 3 + installation/certs/workspace/raw/server.crt | 29 + installation/certs/workspace/raw/server.key | 52 + installation/cmd/azure-ci.run.sh | 93 + installation/cmd/ci.build.ps1 | 23 + installation/cmd/ci.build.sh | 25 + installation/cmd/ci.run.ps1 | 35 + installation/cmd/ci.run.sh | 56 + installation/cmd/run.ps1 | 37 + installation/cmd/run.sh | 47 + .../resources/azure-blobstore-secret.yaml.tpl | 8 + .../resources/azure-broker-secret.yaml.tpl | 11 + .../cluster-certificate-secret.yaml.tpl | 9 + .../resources/installation-config.yaml.tpl | 19 + installation/resources/installer-cr.yaml.tpl | 11 + installation/resources/installer-types.yaml | 27 + installation/resources/installer.yaml | 149 + installation/resources/local-tls-certs.yaml | 2 + installation/resources/release-adder.yaml | 72 + .../remote-env-certificate-secret.yaml.tpl | 9 + .../resources/ui-test-secret.yaml.tpl | 9 + installation/resources/watch-pods.yaml | 53 + installation/scripts/clean-up.ps1 | 35 + installation/scripts/clean-up.sh | 18 + installation/scripts/copy-resource.ps1 | 31 + installation/scripts/copy-resources.sh | 29 + installation/scripts/create-config-map.ps1 | 21 + installation/scripts/create-config-map.sh | 83 + installation/scripts/create-cr.ps1 | 13 + installation/scripts/create-cr.sh | 48 + installation/scripts/create-generic-secret.sh | 37 + installation/scripts/cut.sh | 46 + installation/scripts/docker-start.sh | 5 + .../scripts/generate-cluster-config.sh | 196 + .../scripts/generate-local-config.ps1 | 106 + installation/scripts/generate-local-config.sh | 74 + installation/scripts/installer-ci-local.sh | 12 + installation/scripts/installer.ps1 | 42 + installation/scripts/installer.sh | 69 + installation/scripts/is-installed.ps1 | 44 + installation/scripts/is-installed.sh | 50 + installation/scripts/is-ready.ps1 | 54 + installation/scripts/is-ready.sh | 64 + installation/scripts/minikube.ps1 | 127 + installation/scripts/minikube.sh | 197 + installation/scripts/replace-placeholder.ps1 | 7 + installation/scripts/testing.sh | 210 + installation/scripts/update.sh | 40 + installation/scripts/utils.sh | 97 + installation/scripts/watch-pods.sh | 21 + logo.png | Bin 0 -> 29008 bytes orchestrator.Jenkinsfile | 206 + resources/README.md | 21 + resources/cluster-essentials/.helmignore | 21 + resources/cluster-essentials/Chart.yaml | 5 + resources/cluster-essentials/README.md | 14 + .../templates/environment-mapping.crd.yaml | 14 + .../templates/environments.yaml | 26 + .../templates/event-activation.crd.yaml | 40 + .../templates/eventing-subscription.crd.yaml | 56 + .../templates/nginx-ingress-cert.yaml | 9 + .../templates/remote-env-ca-secret.yaml | 8 + .../templates/remote-environment.crd.yaml | 88 + .../templates/service-binding-usage.crd.yaml | 48 + resources/cluster-prerequisites/README.md | 27 + .../default-sa-rbac-role.yaml | 15 + resources/cluster-prerequisites/install.sh | 21 + .../cluster-prerequisites/limit-range.yaml | 18 + .../remote-environments-minio-secret.yaml | 10 + .../resource-quotas-installer.yaml | 12 + .../resource-quotas.yaml | 38 + resources/core/.helmignore | 21 + resources/core/Chart.yaml | 5 + resources/core/README.md | 40 + .../core/charts/api-controller/Chart.yaml | 5 + .../core/charts/api-controller/README.md | 14 + .../api-controller/templates/deployment.yaml | 21 + .../templates/pre-install-rbac.yaml | 36 + .../tests/test-account-and-role.yaml | 38 + .../templates/tests/test-api-controller.yaml | 24 + .../core/charts/api-controller/values.yaml | 9 + .../core/charts/apiserver-proxy/Chart.yaml | 6 + .../core/charts/apiserver-proxy/README.md | 23 + .../pre-install-proxy-config-map.yaml | 33 + .../templates/proxy-deployment.yaml | 38 + .../templates/proxy-ingress.yaml | 18 + .../templates/proxy-service.yaml | 15 + .../core/charts/apiserver-proxy/values.yaml | 6 + .../charts/application-connector/Chart.yaml | 6 + .../charts/application-connector/README.md | 51 + .../charts/connector-service/.helmignore | 21 + .../charts/connector-service/Chart.yaml | 6 + .../charts/connector-service/README.md | 32 + .../connector-service/templates/_helpers.tpl | 16 + .../templates/deployment.yaml | 53 + .../connector-service/templates/ingress.yaml | 24 + .../templates/role-binding.yaml | 31 + .../connector-service/templates/service.yaml | 37 + .../templates/tests/test-acceptance.yaml | 23 + .../charts/connector-service/values.yaml | 30 + .../charts/metadata-service/Chart.yaml | 4 + .../metadata-service/templates/_helpers.tpl | 16 + .../templates/deployment.yaml | 52 + .../templates/role-binding.yaml | 40 + .../metadata-service/templates/service.yaml | 20 + .../templates/tests/test-acceptance.yaml | 54 + .../charts/metadata-service/values.yaml | 24 + .../application-connector/requirements.yaml | 2 + .../charts/application-connector/values.yaml | 2 + .../core/charts/azure-broker/.helmignore | 21 + resources/core/charts/azure-broker/Chart.yaml | 15 + resources/core/charts/azure-broker/README.md | 23 + .../azure-broker/charts/redis/.helmignore | 1 + .../azure-broker/charts/redis/Chart.yaml | 18 + .../azure-broker/charts/redis/README.md | 129 + .../charts/redis/templates/NOTES.txt | 28 + .../charts/redis/templates/_helpers.tpl | 27 + .../charts/redis/templates/deployment.yaml | 95 + .../charts/redis/templates/networkpolicy.yaml | 30 + .../charts/redis/templates/pvc.yaml | 23 + .../charts/redis/templates/secrets.yaml | 18 + .../charts/redis/templates/svc.yaml | 20 + .../azure-broker/charts/redis/values.yaml | 74 + .../azure-broker/templates/_helpers.tpl | 16 + .../templates/azure-broker-basic-auth.yaml | 13 + .../templates/azure-broker-credentials.yaml | 15 + .../templates/azure-broker-redis.yaml | 12 + .../templates/azure-service-classes-docu.yaml | 24 + .../charts/azure-broker/templates/broker.yaml | 15 + .../azure-broker/templates/deployment.yaml | 107 + .../azure-broker/templates/service.yaml | 23 + .../core/charts/azure-broker/values.yaml | 49 + .../core/charts/cluster-users/Chart.yaml | 7 + resources/core/charts/cluster-users/README.md | 39 + .../cluster-users/templates/rbac-roles.yaml | 112 + .../core/charts/cluster-users/values.yaml | 5 + .../configurations-generator/Chart.yaml | 6 + .../charts/configurations-generator/README.md | 18 + .../templates/deployment.yaml | 30 + .../templates/ingress.yaml | 22 + .../templates/jwt-rule.yaml | 18 + .../templates/route-rule.yaml | 19 + .../templates/service.yaml | 16 + .../configurations-generator/values.yaml | 4 + resources/core/charts/console/.helmignore | 21 + resources/core/charts/console/Chart.yaml | 3 + resources/core/charts/console/README.md | 24 + .../charts/console/templates/_helpers.tpl | 16 + .../charts/console/templates/configmap.yaml | 20 + .../charts/console/templates/deployment.yaml | 34 + .../console/templates/idppreset.crd.yaml | 36 + .../charts/console/templates/ingress.yaml | 24 + .../templates/kubernetes-dashboard-admin.yaml | 14 + .../console/templates/microfrontend.crd.yaml | 48 + .../charts/console/templates/service.yaml | 20 + resources/core/charts/console/values.yaml | 29 + resources/core/charts/docs/.helmignore | 21 + resources/core/charts/docs/Chart.yaml | 3 + resources/core/charts/docs/README.md | 15 + .../charts/docs/charts/content-ui/.helmignore | 21 + .../charts/docs/charts/content-ui/Chart.yaml | 5 + .../charts/content-ui/templates/_helpers.tpl | 16 + .../content-ui/templates/configmap.yaml | 13 + .../content-ui/templates/deployment.yaml | 33 + .../charts/content-ui/templates/ingress.yaml | 28 + .../charts/content-ui/templates/service.yaml | 19 + .../charts/docs/charts/content-ui/values.yaml | 13 + .../docs/charts/documentation/.helmignore | 21 + .../docs/charts/documentation/Chart.yaml | 4 + .../documentation/templates/_helpers.tpl | 16 + .../documentation/templates/docs-job.yaml | 40 + .../docs/charts/documentation/values.yaml | 3 + .../core/charts/environments/.helmignore | 21 + resources/core/charts/environments/Chart.yaml | 4 + resources/core/charts/environments/README.md | 14 + .../templates/0-service-account.yaml | 4 + .../charts/environments/templates/1-role.yaml | 8 + .../templates/2-role-binding.yaml | 12 + .../templates/3-bootstrap-roles.yaml | 22 + .../environments/templates/4-deployment.yaml | 40 + .../environments/templates/_helpers.tpl | 16 + .../templates/tests/test-environments.yaml | 62 + .../core/charts/environments/values.yaml | 20 + resources/core/charts/event-bus/.helmignore | 21 + resources/core/charts/event-bus/Chart.yaml | 4 + resources/core/charts/event-bus/README.md | 42 + .../charts/nats-streaming/.helmignore | 21 + .../charts/nats-streaming/Chart.yaml | 4 + .../nats-streaming/templates/_helpers.tpl | 20 + .../nats-streaming/templates/configmap.yaml | 13 + .../nats-streaming/templates/service.yaml | 14 + .../nats-streaming/templates/statefulset.yaml | 107 + .../charts/nats-streaming/values.yaml | 31 + .../event-bus/charts/publish/Chart.yaml | 4 + .../charts/publish/templates/_helpers.tpl | 28 + .../charts/publish/templates/deployment.yaml | 51 + .../charts/publish/templates/service.yaml | 13 + .../event-bus/charts/publish/values.yaml | 12 + .../charts/event-bus/charts/push/Chart.yaml | 4 + .../charts/push/templates/_helpers.tpl | 27 + .../charts/push/templates/deployment.yaml | 78 + .../charts/event-bus/charts/push/values.yaml | 14 + .../charts/sub-validator/.helmignore | 21 + .../event-bus/charts/sub-validator/Chart.yaml | 4 + .../sub-validator/templates/_helpers.tpl | 27 + .../sub-validator/templates/deployment.yaml | 93 + .../charts/sub-validator/values.yaml | 7 + .../core/charts/event-bus/requirements.yaml | 4 + .../charts/event-bus/templates/_helpers.tpl | 7 + .../templates/tests/test-e2e-tester.yaml | 106 + resources/core/charts/event-bus/values.yaml | 39 + resources/core/charts/helm-broker/.helmignore | 21 + resources/core/charts/helm-broker/Chart.yaml | 4 + resources/core/charts/helm-broker/README.md | 15 + .../helm-broker/charts/etcd/.helmignore | 21 + .../charts/helm-broker/charts/etcd/Chart.yaml | 10 + .../charts/helm-broker/charts/etcd/README.md | 170 + .../charts/etcd/templates/_helpers.tpl | 32 + .../charts/etcd/templates/configmap.yaml | 176 + .../charts/etcd/templates/service.yaml | 22 + .../charts/etcd/templates/statefulset.yaml | 107 + .../helm-broker/charts/etcd/values.yaml | 40 + .../charts/helm-broker/templates/_helpers.tpl | 30 + .../templates/cluster-role-binding.yaml | 17 + .../helm-broker/templates/cluster-role.yaml | 14 + .../helm-broker/templates/configmap.yaml | 14 + .../charts/helm-broker/templates/deploy.yaml | 158 + .../core/charts/helm-broker/templates/sa.yaml | 10 + .../helm-broker/templates/service-broker.yaml | 13 + .../charts/helm-broker/templates/svc.yaml | 47 + resources/core/charts/helm-broker/values.yaml | 56 + resources/core/charts/jaeger/.helmignore | 21 + resources/core/charts/jaeger/Chart.yaml | 8 + resources/core/charts/jaeger/README.md | 18 + .../core/charts/jaeger/templates/_helpers.tpl | 32 + .../charts/jaeger/templates/deployment.yaml | 47 + .../jaeger/templates/jaeger-ingress.yaml | 21 + .../core/charts/jaeger/templates/service.yaml | 106 + resources/core/charts/jaeger/values.yaml | 29 + resources/core/charts/kubeless/Chart.yaml | 10 + resources/core/charts/kubeless/README.md | 93 + .../kubeless/charts/lambdas-ui/.helmignore | 21 + .../kubeless/charts/lambdas-ui/Chart.yaml | 4 + .../charts/lambdas-ui/templates/_helpers.tpl | 16 + .../lambdas-ui/templates/configmap.yaml | 15 + .../lambdas-ui/templates/deployment.yaml | 33 + .../charts/lambdas-ui/templates/ingress.yaml | 24 + .../charts/lambdas-ui/templates/service.yaml | 21 + .../kubeless/charts/lambdas-ui/values.yaml | 14 + .../charts/kubeless/templates/_helpers.tpl | 29 + .../templates/kubeless-clusterroles.yaml | 123 + .../templates/kubeless-configmap.yaml | 66 + .../kubeless-controller-deployment.yaml | 45 + .../kubeless/templates/kubeless-crd.yaml | 19 + .../templates/kubeless-serviceaccount.yaml | 7 + .../templates/tests/test-kubeless.yaml | 73 + resources/core/charts/kubeless/values.yaml | 20 + resources/core/charts/minio/.helmignore | 21 + resources/core/charts/minio/Chart.yaml | 18 + resources/core/charts/minio/README.md | 47 + .../minio/templates/_helper_create_bucket.txt | 75 + .../core/charts/minio/templates/_helpers.tpl | 43 + .../charts/minio/templates/configmap.yaml | 12 + .../charts/minio/templates/deployment.yaml | 106 + .../core/charts/minio/templates/ingress.yaml | 22 + .../minio-content-upload-configmap.yaml | 18 + .../minio-content-upload-podpreset.yaml | 27 + .../charts/minio/templates/networkpolicy.yaml | 25 + .../post-install-create-bucket-job.yaml | 48 + .../core/charts/minio/templates/pvc.yaml | 30 + .../core/charts/minio/templates/secrets.yaml | 16 + .../core/charts/minio/templates/service.yaml | 33 + .../charts/minio/templates/statefulset.yaml | 89 + resources/core/charts/minio/values.yaml | 202 + resources/core/charts/monitoring/.helmignore | 21 + resources/core/charts/monitoring/Chart.yaml | 8 + resources/core/charts/monitoring/README.md | 22 + resources/core/charts/monitoring/_helpers.tpl | 8 + .../monitoring/charts/alert-rules/.helmignore | 21 + .../monitoring/charts/alert-rules/Chart.yaml | 7 + .../monitoring/charts/alert-rules/README.md | 105 + .../charts/alert-rules/templates/_helpers.tpl | 32 + .../templates/unhealthy-pods-configmap.yaml | 22 + .../templates/unhealthy-pods-rules.yaml | 13 + .../monitoring/charts/alert-rules/values.yaml | 4 + .../charts/alertmanager/.helmignore | 21 + .../monitoring/charts/alertmanager/Chart.yaml | 9 + .../monitoring/charts/alertmanager/README.md | 95 + .../alertmanager/templates/_helpers.tpl | 36 + .../templates/alertmanager.rules.yaml | 35 + .../alertmanager/templates/alertmanager.yaml | 56 + .../alertmanager/templates/configmap.yaml | 22 + .../alertmanager/templates/ingress.yaml | 32 + .../charts/alertmanager/templates/secret.yaml | 15 + .../alertmanager/templates/service.yaml | 42 + .../templates/servicemonitor.yaml | 26 + .../charts/alertmanager/values.yaml | 201 + .../.helmignore | 21 + .../Chart.yaml | 7 + .../templates/_helpers.tpl | 36 + .../templates/configmap.yaml | 25 + .../templates/endpoints.yaml | 22 + .../kube-controller-manager.rules.yaml | 5 + .../templates/service.yaml | 23 + .../templates/servicemonitor.yaml | 31 + .../values.yaml | 17 + .../charts/exporter-kube-dns/.helmignore | 21 + .../charts/exporter-kube-dns/Chart.yaml | 7 + .../exporter-kube-dns/templates/_helpers.tpl | 36 + .../exporter-kube-dns/templates/service.yaml | 25 + .../templates/servicemonitor.yaml | 29 + .../charts/exporter-kube-dns/values.yaml | 5 + .../charts/exporter-kube-etcd/.helmignore | 21 + .../charts/exporter-kube-etcd/Chart.yaml | 7 + .../exporter-kube-etcd/templates/_helpers.tpl | 36 + .../templates/configmap.yaml | 25 + .../templates/endpoints.yaml | 22 + .../templates/etcd3.rules.yaml | 125 + .../exporter-kube-etcd/templates/service.yaml | 23 + .../templates/servicemonitor.yaml | 39 + .../charts/exporter-kube-etcd/values.yaml | 20 + .../exporter-kube-scheduler/.helmignore | 21 + .../charts/exporter-kube-scheduler/Chart.yaml | 7 + .../templates/_helpers.tpl | 36 + .../templates/configmap.yaml | 25 + .../templates/endpoints.yaml | 22 + .../templates/kube-scheduler.rules.yaml | 50 + .../templates/service.yaml | 23 + .../templates/servicemonitor.yaml | 26 + .../exporter-kube-scheduler/values.yaml | 16 + .../charts/exporter-kube-state/.helmignore | 21 + .../charts/exporter-kube-state/Chart.yaml | 7 + .../templates/_helpers.tpl | 36 + .../templates/clusterrole.yaml | 48 + .../templates/clusterrolebinding.yaml | 23 + .../templates/configmap.yaml | 25 + .../templates/deployment.yaml | 77 + .../templates/kube-state-metrics.rules.yaml | 61 + .../exporter-kube-state/templates/role.yaml | 23 + .../templates/rolebinding.yaml | 22 + .../templates/service.yaml | 21 + .../templates/serviceaccount.yaml | 11 + .../templates/servicemonitor.yaml | 26 + .../charts/exporter-kube-state/values.yaml | 51 + .../charts/exporter-kubelets/.helmignore | 21 + .../charts/exporter-kubelets/Chart.yaml | 7 + .../exporter-kubelets/templates/_helpers.tpl | 36 + .../templates/configmap.yaml | 25 + .../templates/kubelet.rules.yaml | 50 + .../templates/servicemonitor.yaml | 50 + .../charts/exporter-kubelets/values.yaml | 14 + .../charts/exporter-kubernetes/.helmignore | 21 + .../charts/exporter-kubernetes/Chart.yaml | 7 + .../templates/_helpers.tpl | 36 + .../templates/configmap.yaml | 25 + .../templates/kubernetes.rules.yaml | 108 + .../templates/servicemonitor.yaml | 32 + .../charts/exporter-kubernetes/values.yaml | 9 + .../charts/exporter-node/.helmignore | 21 + .../charts/exporter-node/Chart.yaml | 7 + .../exporter-node/templates/_helpers.tpl | 36 + .../exporter-node/templates/configmap.yaml | 25 + .../exporter-node/templates/daemonset.yaml | 51 + .../exporter-node/templates/node.rules.yaml | 49 + .../exporter-node/templates/service.yaml | 21 + .../templates/servicemonitor.yaml | 25 + .../charts/exporter-node/values.yaml | 54 + .../monitoring/charts/grafana/Chart.yaml | 8 + .../monitoring/charts/grafana/README.md | 56 + .../dashboards/deployment-dashboard.json | 727 ++ .../grafana/dashboards/istio-dashboard.json | 1545 ++++ ...ubernetes-capacity-planning-dashboard.json | 974 +++ .../kubernetes-cluster-health-dashboard.json | 694 ++ .../kubernetes-cluster-status-dashboard.json | 807 ++ ...rnetes-control-plane-status-dashboard.json | 626 ++ ...ubernetes-resource-requests-dashboard.json | 403 + .../grafana/dashboards/lambda-dashboard.json | 1006 +++ .../grafana/dashboards/mixer-dashboard.json | 2106 +++++ .../grafana/dashboards/nodes-dashboard.json | 822 ++ .../grafana/dashboards/pilot-dashboard.json | 1724 ++++ .../grafana/dashboards/pods-dashboard.json | 418 + .../dashboards/statefulset-dashboard.json | 706 ++ .../charts/grafana/templates/_helpers.tpl | 52 + .../dasboards-provisioner-configmap.yaml | 22 + .../templates/dashboards-configmap.yaml | 21 + .../templates/datasource-configmap.yaml | 37 + .../grafana/templates/grafana-deployment.yaml | 125 + .../charts/grafana/templates/ingress.yaml | 29 + .../charts/grafana/templates/pvc.yaml | 24 + .../grafana/templates/servicemonitors.yaml | 29 + .../charts/grafana/templates/svc.yaml | 38 + .../monitoring/charts/grafana/values.yaml | 121 + .../charts/kube-prometheus/.helmignore | 21 + .../charts/kube-prometheus/Chart.yaml | 8 + .../kube-prometheus/templates/_helpers.tpl | 36 + .../kube-prometheus/templates/configmap.yaml | 22 + .../templates/general.rules.yaml | 41 + .../charts/kube-prometheus/values.yaml | 393 + .../monitoring/charts/prometheus/.helmignore | 21 + .../monitoring/charts/prometheus/Chart.yaml | 8 + .../prometheus/templates/_configmaps.json.tpl | 8 + .../charts/prometheus/templates/_helpers.tpl | 47 + .../prometheus/templates/clusterrole.yaml | 33 + .../templates/clusterrolebinding.yaml | 23 + .../prometheus/templates/configmap.yaml | 25 + .../charts/prometheus/templates/ingress.yaml | 32 + .../templates/prometheus.rules.yaml | 103 + .../prometheus/templates/prometheus.yaml | 113 + .../charts/prometheus/templates/rules.yaml | 17 + .../charts/prometheus/templates/secret.yaml | 15 + .../charts/prometheus/templates/service.yaml | 43 + .../prometheus/templates/serviceaccount.yaml | 11 + .../templates/servicemonitor-prometheus.yaml | 28 + .../prometheus/templates/servicemonitors.yaml | 37 + .../monitoring/charts/prometheus/values.yaml | 342 + .../core/charts/monitoring/requirements.yaml | 14 + .../templates/tests/test-kube-prometheus.yaml | 51 + resources/core/charts/monitoring/values.yaml | 6 + .../core/charts/nginx-ingress/.helmignore | 21 + .../core/charts/nginx-ingress/Chart.yaml | 17 + resources/core/charts/nginx-ingress/README.md | 186 + .../charts/nginx-ingress/templates/NOTES.txt | 64 + .../nginx-ingress/templates/_helpers.tpl | 61 + .../nginx-ingress/templates/clusterrole.yaml | 69 + .../templates/clusterrolebinding.yaml | 19 + .../templates/controller-configmap.yaml | 23 + .../templates/controller-daemonset.yaml | 174 + .../templates/controller-deployment.yaml | 167 + .../templates/controller-hpa.yaml | 22 + .../templates/controller-metrics-service.yaml | 38 + .../controller-poddisruptionbudget.yaml | 17 + .../templates/controller-service.yaml | 70 + .../templates/controller-stats-service.yaml | 38 + .../templates/default-backend-deployment.yaml | 66 + .../default-backend-poddisruptionbudget.yaml | 17 + .../templates/default-backend-service.yaml | 37 + .../templates/headers-configmap.yaml | 14 + .../charts/nginx-ingress/templates/role.yaml | 44 + .../nginx-ingress/templates/rolebinding.yaml | 19 + .../templates/serviceaccount.yaml | 11 + .../templates/tcp-configmap.yaml | 14 + .../templates/udp-configmap.yaml | 14 + .../templates/zipkin-configmap.yml | 8 + .../core/charts/nginx-ingress/values.yaml | 305 + .../remote-environment-broker/.helmignore | 21 + .../remote-environment-broker/Chart.yaml | 4 + .../remote-environment-broker/README.md | 27 + .../templates/_helpers.tpl | 16 + .../templates/cluster-role-binding.yaml | 17 + .../templates/cluster-role.yaml | 26 + .../templates/configmap.yaml | 13 + .../templates/deployment.yaml | 71 + .../templates/service-account.yaml | 10 + .../templates/service-broker.yaml | 13 + .../templates/service.yaml | 22 + .../remote-environment-broker/values.yaml | 26 + .../core/charts/service-catalog/Chart.yaml | 5 + .../core/charts/service-catalog/README.md | 27 + .../binding-usage-controller/.helmignore | 21 + .../binding-usage-controller/Chart.yaml | 5 + .../charts/binding-usage-controller/README.md | 17 + .../templates/_helpers.tpl | 16 + .../templates/cluster-role-binding.yaml | 17 + .../templates/cluster-role.yaml | 33 + .../templates/configmap.yaml | 7 + .../templates/deployment.yaml | 42 + .../templates/service-account.yaml | 10 + .../binding-usage-controller/values.yaml | 8 + .../charts/catalog-ui/.helmignore | 21 + .../charts/catalog-ui/Chart.yaml | 5 + .../charts/catalog-ui/templates/_helpers.tpl | 16 + .../catalog-ui/templates/configmap.yaml | 13 + .../catalog-ui/templates/deployment.yaml | 33 + .../charts/catalog-ui/templates/ingress.yaml | 28 + .../charts/catalog-ui/templates/service.yaml | 19 + .../charts/catalog-ui/values.yaml | 13 + .../service-catalog/charts/catalog/Chart.yaml | 4 + .../charts/catalog/templates/_helpers.tpl | 21 + .../catalog/templates/apiregistration.yaml | 23 + .../templates/apiserver-deployment.yaml | 159 + .../catalog/templates/apiserver-secret.yaml | 16 + .../catalog/templates/apiserver-service.yaml | 24 + .../controller-manager-deployment.yaml | 123 + .../charts/catalog/templates/rbac.yaml | 146 + .../catalog/templates/serviceaccounts.yaml | 13 + .../charts/catalog/values.yaml | 111 + .../service-catalog/charts/etcd/.helmignore | 21 + .../service-catalog/charts/etcd/Chart.yaml | 12 + .../charts/etcd/templates/_helpers.tpl | 32 + .../charts/etcd/templates/configmap.yaml | 176 + .../charts/etcd/templates/service.yaml | 22 + .../charts/etcd/templates/statefulset.yaml | 107 + .../service-catalog/charts/etcd/values.yaml | 40 + .../charts/instances-ui/.helmignore | 21 + .../charts/instances-ui/Chart.yaml | 5 + .../instances-ui/templates/_helpers.tpl | 16 + .../instances-ui/templates/configmap.yaml | 14 + .../instances-ui/templates/deployment.yaml | 33 + .../instances-ui/templates/ingress.yaml | 28 + .../instances-ui/templates/service.yaml | 19 + .../charts/instances-ui/values.yaml | 13 + .../core/charts/service-catalog/values.yaml | 0 resources/core/charts/ui-api/.helmignore | 21 + resources/core/charts/ui-api/Chart.yaml | 4 + .../core/charts/ui-api/templates/_helpers.tpl | 16 + .../templates/cluster-role-binding.yaml | 14 + .../charts/ui-api/templates/cluster-role.yaml | 31 + .../charts/ui-api/templates/deployment.yaml | 94 + .../core/charts/ui-api/templates/ingress.yaml | 29 + .../charts/ui-api/templates/jwt-rules.yaml | 11 + .../charts/ui-api/templates/route-role.yml | 8 + .../ui-api/templates/service-account.yaml | 6 + .../core/charts/ui-api/templates/service.yaml | 20 + resources/core/charts/ui-api/values.yaml | 19 + resources/core/cluster.yaml | 34 + resources/core/requirements.yaml | 31 + resources/core/templates/NOTES.txt | 10 + resources/core/templates/_helpers.tpl | 15 + .../templates/tests/rbac-core-acceptance.yaml | 63 + .../tests/rbac-ui-api-acceptance.yaml | 39 + .../templates/tests/test-core-acceptance.yaml | 33 + .../tests/test-ui-api-acceptance.yaml | 19 + resources/core/values.yaml | 43 + resources/dex/Chart.yaml | 8 + resources/dex/README.md | 23 + resources/dex/github.md | 67 + resources/dex/templates/NOTES.txt | 10 + resources/dex/templates/_helpers.tpl | 16 + resources/dex/templates/dex-config-map.yaml | 64 + resources/dex/templates/dex-deployment.yaml | 43 + resources/dex/templates/dex-ingress.yaml | 22 + resources/dex/templates/dex-rbac-role.yaml | 36 + resources/dex/templates/dex-route-rule.yaml | 11 + .../dex/templates/dex-service-account.yaml | 9 + resources/dex/templates/dex-service.yaml | 18 + .../templates/tests/test-dex-connection.yaml | 22 + resources/dex/values.yaml | 10 + resources/helm-broker-repo/README.md | 63 + .../bundles/redis-0.0.3/README.md | 22 + .../redis-0.0.3/chart/redis/.helmignore | 1 + .../redis-0.0.3/chart/redis/Chart.yaml | 16 + .../bundles/redis-0.0.3/chart/redis/README.md | 128 + .../chart/redis/templates/_helpers.tpl | 27 + .../chart/redis/templates/deployment.yaml | 96 + .../chart/redis/templates/networkpolicy.yaml | 30 + .../chart/redis/templates/pvc.yaml | 24 + .../chart/redis/templates/secrets.yaml | 22 + .../chart/redis/templates/svc.yaml | 20 + .../tests/test-redis-connection.yaml | 28 + .../redis-0.0.3/chart/redis/values.yaml | 85 + .../bundles/redis-0.0.3/meta.yaml | 13 + .../redis-0.0.3/plans/enterprise/bind.yaml | 13 + .../enterprise/create-instance-schema.json | 19 + .../redis-0.0.3/plans/enterprise/meta.yaml | 6 + .../redis-0.0.3/plans/enterprise/values.yaml | 6 + .../bundles/redis-0.0.3/plans/micro/bind.yaml | 15 + .../plans/micro/create-instance-schema.json | 19 + .../bundles/redis-0.0.3/plans/micro/meta.yaml | 6 + .../redis-0.0.3/plans/micro/values.yaml | 6 + .../helm-broker-repo/development/check.sh | 121 + .../development/sync-bundles.sh | 11 + resources/helm-broker-repo/install.sh | 5 + .../helm-broker-repo/provisioning/po.yaml | 18 + .../provisioning/provision-bundles.sh | 36 + .../helm-broker-repo/provisioning/pvc.yaml | 10 + resources/helm-broker-repo/update.sh | 5 + resources/istio/istio/Chart.yaml | 15 + .../istio/charts/egressgateway/Chart.yaml | 12 + .../egressgateway/templates/autoscale.yaml | 19 + .../egressgateway/templates/deployment.yaml | 97 + .../egressgateway/templates/service.yaml | 25 + .../templates/serviceaccount.yaml | 14 + .../istio/istio/charts/galley/Chart.yaml | 10 + .../charts/galley/templates/_helpers.tpl | 16 + .../charts/galley/templates/clusterrole.yaml | 18 + .../galley/templates/clusterrolebinding.yaml | 19 + .../charts/galley/templates/deployment.yaml | 70 + .../charts/galley/templates/service.yaml | 12 + .../galley/templates/serviceaccount.yaml | 12 + .../galley/templates/validatingwebhook.yaml | 96 + .../istio/istio/charts/ingress/Chart.yaml | 11 + .../charts/ingress/templates/autoscale.yaml | 19 + .../charts/ingress/templates/clusterrole.yaml | 18 + .../ingress/templates/clusterrolebinding.yaml | 14 + .../charts/ingress/templates/deployment.yaml | 104 + .../templates/istio-ingress-certs.yaml | 9 + .../charts/ingress/templates/service.yaml | 25 + .../ingress/templates/serviceaccount.yaml | 16 + .../istio/charts/ingressgateway/Chart.yaml | 12 + .../ingressgateway/templates/autoscale.yaml | 19 + .../ingressgateway/templates/deployment.yaml | 109 + .../ingressgateway/templates/service.yaml | 28 + .../templates/serviceaccount.yaml | 14 + resources/istio/istio/charts/mixer/Chart.yaml | 11 + .../istio/charts/mixer/templates/_helpers.tpl | 27 + .../charts/mixer/templates/clusterrole.yaml | 22 + .../mixer/templates/clusterrolebinding.yaml | 19 + .../istio/charts/mixer/templates/config.yaml | 502 ++ .../charts/mixer/templates/configmap.yaml | 28 + .../istio/charts/mixer/templates/crds.yaml | 539 ++ .../create-custom-resources-job.yaml | 94 + .../charts/mixer/templates/deployment.yaml | 173 + .../istio/charts/mixer/templates/service.yaml | 28 + .../mixer/templates/serviceaccount.yaml | 16 + .../charts/mixer/templates/statsdtoprom.yaml | 65 + resources/istio/istio/charts/pilot/Chart.yaml | 11 + .../charts/pilot/templates/clusterrole.yaml | 37 + .../pilot/templates/clusterrolebinding.yaml | 19 + .../istio/charts/pilot/templates/crds.yaml | 176 + .../charts/pilot/templates/deployment.yaml | 130 + .../istio/charts/pilot/templates/service.yaml | 28 + .../pilot/templates/serviceaccount.yaml | 16 + .../istio/istio/charts/security/Chart.yaml | 11 + .../charts/security/templates/_helpers.tpl | 27 + .../security/templates/cleanup-old-ca.yaml | 39 + .../security/templates/clusterrole.yaml | 43 + .../templates/clusterrolebinding.yaml | 41 + .../charts/security/templates/deployment.yaml | 38 + .../charts/security/templates/service.yaml | 19 + .../security/templates/serviceaccount.yaml | 34 + .../istio/charts/servicegraph/Chart.yaml | 4 + .../servicegraph/templates/_helpers.tpl | 16 + .../servicegraph/templates/deployment.yaml | 39 + .../servicegraph/templates/ingress.yaml | 33 + .../servicegraph/templates/service.yaml | 19 + .../charts/sidecarInjectorWebhook/Chart.yaml | 11 + .../templates/_helpers.tpl | 16 + .../templates/clusterrole.yaml | 18 + .../templates/clusterrolebinding.yaml | 19 + .../templates/configmap.yaml | 162 + .../templates/deployment.yaml | 78 + .../templates/mutatingwebhook.yaml | 27 + .../templates/service.yaml | 12 + .../templates/serviceaccount.yaml | 14 + .../istio/istio/charts/webhook/Chart.yaml | 8 + .../istio/charts/webhook/scripts/plugin.lua | 38 + .../charts/webhook/templates/configmap.yaml | 7 + .../charts/webhook/templates/deployment.yaml | 29 + .../charts/webhook/templates/service.yaml | 14 + resources/istio/istio/requirements.yaml | 45 + resources/istio/istio/templates/_affinity.tpl | 36 + resources/istio/istio/templates/_helpers.tpl | 41 + .../istio/istio/templates/configmap.yaml | 104 + resources/istio/istio/values.yaml | 433 + resources/prometheus-operator/.helmignore | 21 + resources/prometheus-operator/Chart.yaml | 9 + resources/prometheus-operator/README.md | 18 + .../templates/_helpers.tpl | 32 + .../templates/clusterrole.yaml | 66 + .../templates/clusterrolebinding.yaml | 24 + .../templates/create-servicemonitor-job.yaml | 40 + .../templates/deployment.yaml | 52 + .../templates/get-crd-job.yaml | 51 + .../templates/serviceaccount.yaml | 11 + .../templates/servicemonitor-configmap.yaml | 29 + resources/prometheus-operator/values.yaml | 55 + resources/remote-environments/.helmignore | 21 + resources/remote-environments/Chart.yaml | 4 + resources/remote-environments/README.md | 70 + .../remote-environments/templates/NOTES.txt | 10 + .../templates/_helpers.tpl | 16 + .../templates/deployment.yaml | 52 + .../templates/ingress.yaml | 40 + .../templates/remote-env.yaml | 15 + .../templates/role-binding.yaml | 32 + .../templates/service.yaml | 38 + .../templates/tests/test-acceptance.yaml | 26 + resources/remote-environments/values.yaml | 41 + resources/tiller/install.sh | 11 + resources/tiller/tiller.yaml | 77 + tests/acceptance/.gitignore | 3 + tests/acceptance/Dockerfile | 20 + tests/acceptance/Gopkg.lock | 392 + tests/acceptance/Gopkg.toml | 55 + tests/acceptance/Jenkinsfile | 91 + tests/acceptance/README.md | 18 + tests/acceptance/build.sh | 46 + tests/acceptance/dex/dex_test.go | 136 + tests/acceptance/entrypoint.sh | 18 + tests/acceptance/remote-environment/README.md | 62 + .../cmd/fake-gateway/main.go | 70 + .../cmd/gateway-client/main.go | 186 + .../remote-environment/contrib/Dockerfile | 11 + .../remote-environment/contrib/build.sh | 30 + .../remote-environment/contrib/pod.yaml | 24 + .../remote-environment/contrib/rbac.yaml | 81 + .../acceptance/remote-environment/re_test.go | 67 + .../remote-environment/suite/binding_usage.go | 45 + .../remote-environment/suite/istio.go | 97 + .../remote-environment/suite/kubernetes.go | 384 + .../suite/remote_environment.go | 83 + .../remote-environment/suite/testsuite.go | 244 + tests/acceptance/servicecatalog/README.md | 82 + .../servicecatalog/binding_usage_test.go | 521 ++ .../servicecatalog/cmd/env-tester/README.md | 4 + .../servicecatalog/cmd/env-tester/main.go | 44 + .../acceptance/servicecatalog/helpers_test.go | 112 + .../servicecatalog/servicecatalog_test.go | 112 + tests/acceptance/servicecatalog/wait/wait.go | 24 + .../Dockerfile | 17 + .../Gopkg.lock | 343 + .../Gopkg.toml | 45 + .../Jenkinsfile | 91 + .../api-controller-acceptance-tests/README.md | 13 + .../apicontroller/apicontroller_test.go | 359 + .../apicontroller/fixture.go | 189 + tests/application-connector-tests/.gitignore | 4 + tests/application-connector-tests/Dockerfile | 13 + tests/application-connector-tests/Gopkg.lock | 347 + tests/application-connector-tests/Gopkg.toml | 42 + tests/application-connector-tests/Jenkinsfile | 96 + tests/application-connector-tests/README.md | 18 + .../scripts/entrypoint.sh | 14 + .../test/metadata/apitests/health_test.go | 35 + .../test/metadata/apitests/metadata_test.go | 332 + .../metadata/k8stests/k8sresource_test.go | 567 ++ .../test/metadata/testkit/config.go | 45 + .../metadata/testkit/k8sresources_check.go | 135 + .../metadata/testkit/k8sresources_client.go | 106 + .../metadata/testkit/metadata_api_client.go | 166 + .../test/metadata/testkit/model.go | 85 + tests/connector-service-tests/.gitignore | 19 + tests/connector-service-tests/Dockerfile | 13 + tests/connector-service-tests/Gopkg.lock | 30 + tests/connector-service-tests/Gopkg.toml | 30 + tests/connector-service-tests/Jenkinsfile | 89 + tests/connector-service-tests/README.md | 55 + .../scripts/entrypoint.sh | 7 + .../test/apitests/connector_test.go | 284 + .../test/testkit/certs.go | 105 + .../test/testkit/config.go | 47 + .../test/testkit/connectorclient.go | 116 + .../test/testkit/model.go | 42 + tests/event-bus/.gitignore | 2 + tests/event-bus/Gopkg.lock | 339 + tests/event-bus/Gopkg.toml | 17 + tests/event-bus/Jenkinsfile | 119 + tests/event-bus/README.md | 10 + tests/event-bus/e2e-subscriber/Dockerfile | 23 + tests/event-bus/e2e-subscriber/Makefile | 28 + tests/event-bus/e2e-subscriber/dockerBuild.sh | 17 + .../e2e-subscriber/e2e-subscriber.go | 24 + tests/event-bus/e2e-tester/Dockerfile | 21 + tests/event-bus/e2e-tester/Makefile | 28 + tests/event-bus/e2e-tester/dockerBuild.sh | 17 + tests/event-bus/e2e-tester/e2e-tester.go | 400 + tests/format.sh | 4 + tests/gateway-tests/Dockerfile | 13 + tests/gateway-tests/Gopkg.lock | 30 + tests/gateway-tests/Gopkg.toml | 34 + tests/gateway-tests/Jenkinsfile | 90 + tests/gateway-tests/README.md | 61 + tests/gateway-tests/can-i-commit.sh | 23 + tests/gateway-tests/entrypoint.sh | 8 + .../test/apitests/gateway_events_test.go | 69 + .../test/apitests/gateway_health_test.go | 35 + tests/gateway-tests/test/testkit/config.go | 47 + tests/kubeless-test-client/Dockerfile | 15 + tests/kubeless-test-client/Jenkinsfile | 89 + tests/kubeless-test-client/README.md | 9 + tests/kubeless-test-client/dependecies.json | 20 + tests/kubeless-test-client/hello.js | 5 + tests/kubeless-test-client/ingress.yaml | 15 + tests/kubeless-test-client/route.yaml | 21 + tests/kubeless-test-client/test-kubeless.go | 213 + tests/test-environments/.gitignore | 14 + tests/test-environments/Dockerfile | 33 + tests/test-environments/Gopkg.lock | 27 + tests/test-environments/Gopkg.toml | 34 + tests/test-environments/Jenkinsfile | 88 + tests/test-environments/README.md | 3 + tests/test-environments/cmd/quantity/main.go | 23 + tests/test-environments/sample-namespace.yaml | 6 + tests/test-environments/test-environments.sh | 220 + tests/test-logging-monitoring/.gitignore | 2 + tests/test-logging-monitoring/Dockerfile | 12 + tests/test-logging-monitoring/Jenkinsfile | 89 + tests/test-logging-monitoring/README.md | 4 + .../test-logging-monitoring.go | 267 + .../ui-api-layer-acceptance-tests/.gitignore | 8 + .../ui-api-layer-acceptance-tests/Dockerfile | 57 + .../ui-api-layer-acceptance-tests/Gopkg.lock | 407 + .../ui-api-layer-acceptance-tests/Gopkg.toml | 27 + .../ui-api-layer-acceptance-tests/Jenkinsfile | 88 + tests/ui-api-layer-acceptance-tests/README.md | 37 + .../before-commit.sh | 95 + .../brokerinstaller/brokerinstaller.go | 69 + .../domain/k8s/limitrange_test.go | 158 + .../domain/k8s/resourcequota_test.go | 126 + .../domain/servicecatalog/binding_test.go | 309 + .../servicecatalog/binding_usage_test.go | 366 + .../domain/servicecatalog/broker_test.go | 118 + .../domain/servicecatalog/class_test.go | 164 + .../domain/servicecatalog/instance_test.go | 347 + .../servicecatalog/servicecatalog_test.go | 117 + .../testdata/charts/ups-broker/Chart.yaml | 3 + .../testdata/charts/ups-broker/README.md | 49 + .../charts/ups-broker/templates/_helpers.tpl | 9 + .../templates/broker-deployment.yaml | 58 + .../ups-broker/templates/broker-register.yaml | 6 + .../ups-broker/templates/broker-service.yaml | 16 + .../testdata/charts/ups-broker/values.yaml | 13 + .../graphql/client.go | 50 + .../graphql/request.go | 21 + .../k8s/client.go | 36 + .../k8s/config.go | 28 + tests/ui-api-layer-acceptance-tests/tester.go | 3 + .../waiter/waiter.go | 28 + tools/alpine-net/Dockerfile | 10 + tools/alpine-net/Jenkinsfile | 67 + tools/alpine-net/README.md | 21 + tools/stability-checker/.gitignore | 105 + tools/stability-checker/Gopkg.lock | 339 + tools/stability-checker/Gopkg.toml | 58 + tools/stability-checker/Jenkinsfile | 86 + tools/stability-checker/README.md | 28 + tools/stability-checker/before-commit.sh | 123 + .../cmd/logs-printer/internal/printer/dto.go | 11 + .../logs-printer/internal/printer/printer.go | 78 + .../cmd/logs-printer/main.go | 88 + .../cmd/stability-checker/main.go | 107 + .../chart/stability-checker/.helmignore | 21 + .../deploy/chart/stability-checker/Chart.yaml | 5 + .../stability-checker/templates/_helpers.tpl | 32 + .../templates/configmap.yaml | 7 + .../stability-checker/templates/deploy.yaml | 83 + .../templates/role-binding.yaml | 14 + .../stability-checker/templates/role.yaml | 22 + .../templates/service-account.yaml | 10 + .../chart/stability-checker/values.yaml | 27 + .../deploy/stability-checker/Dockerfile | 21 + tools/stability-checker/internal/dto.go | 7 + .../internal/notifier/config.go | 8 + .../internal/notifier/dep.go | 10 + .../internal/notifier/notifier.go | 166 + .../internal/notifier/renderer.go | 75 + .../internal/notifier/slack_client.go | 73 + .../internal/notifier/templates.go | 30 + .../stability-checker/internal/runner/dep.go | 12 + .../internal/runner/runner.go | 162 + .../local/helpers/isready.sh | 64 + .../local/input/testing-kyma.sh | 215 + tools/stability-checker/local/input/utils.sh | 99 + .../local/provision_volume.sh | 11 + .../stability-checker/local/provisioning.yaml | 37 + .../platform/logger/config.go | 37 + .../stability-checker/platform/logger/doc.go | 2 + .../platform/logger/logger.go | 95 + .../platform/logger/logger_mock.go | 34 + tools/watch-pods/.gitignore | 2 + tools/watch-pods/Dockerfile | 12 + tools/watch-pods/Gopkg.lock | 331 + tools/watch-pods/Gopkg.toml | 49 + tools/watch-pods/Jenkinsfile | 96 + tools/watch-pods/README.md | 31 + tools/watch-pods/build.sh | 39 + tools/watch-pods/internal/tester/tester.go | 186 + .../watch-pods/internal/tester/tester_test.go | 221 + tools/watch-pods/internal/tester/watcher.go | 79 + .../internal/tester/watcher_test.go | 89 + tools/watch-pods/main.go | 110 + 2398 files changed, 176085 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug-report.md create mode 100644 .github/ISSUE_TEMPLATE/feature-request.md create mode 100644 .github/issue-template.md create mode 100644 .github/pull-request-template.md create mode 100644 .gitignore create mode 100644 CODEOWNERS create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Jenkinsfile create mode 100644 LICENSE create mode 100644 NOTICE.md create mode 100644 README.md create mode 100644 azure.Jenkinsfile create mode 100644 ci.Dockerfile create mode 100644 components/api-controller/Dockerfile create mode 100644 components/api-controller/Gopkg.lock create mode 100644 components/api-controller/Gopkg.toml create mode 100755 components/api-controller/Jenkinsfile create mode 100644 components/api-controller/README.md create mode 100644 components/api-controller/cmd/controller/main.go create mode 100644 components/api-controller/doc.go create mode 100644 components/api-controller/docs/.file create mode 100644 components/api-controller/examples/cr-v1alpha1.yaml create mode 100644 components/api-controller/examples/cr-v1alpha2.yaml create mode 100644 components/api-controller/hack/custom-boilerplate.go.txt create mode 100755 components/api-controller/hack/update-codegen.sh create mode 100644 components/api-controller/pkg/apis/authentication.istio.io/v1alpha1/doc.go create mode 100644 components/api-controller/pkg/apis/authentication.istio.io/v1alpha1/register.go create mode 100644 components/api-controller/pkg/apis/authentication.istio.io/v1alpha1/types.go create mode 100644 components/api-controller/pkg/apis/authentication.istio.io/v1alpha1/zz_generated.deepcopy.go create mode 100644 components/api-controller/pkg/apis/gateway.kyma.cx/meta/v1/types.go create mode 100644 components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2/doc.go create mode 100644 components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2/register.go create mode 100644 components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2/types.go create mode 100644 components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2/zz_generated.deepcopy.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/clientset.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/doc.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/fake/clientset_generated.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/fake/doc.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/fake/register.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/scheme/doc.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/scheme/register.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/authentication.istio.io_client.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/doc.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/fake/doc.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/fake/fake_authentication.istio.io_client.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/fake/fake_policy.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/generated_expansion.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/policy.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/authentication.istio.io/interface.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/authentication.istio.io/v1alpha1/interface.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/authentication.istio.io/v1alpha1/policy.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/factory.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/generic.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/internalinterfaces/factory_interfaces.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/listers/authentication.istio.io/v1alpha1/expansion_generated.go create mode 100644 components/api-controller/pkg/clients/authentication.istio.io/listers/authentication.istio.io/v1alpha1/policy.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/clientset.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/doc.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/fake/clientset_generated.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/fake/doc.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/fake/register.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/scheme/doc.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/scheme/register.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/api.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/doc.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/fake/doc.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/fake/fake_api.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/fake/fake_gateway.kyma.cx_client.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/gateway.kyma.cx_client.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/generated_expansion.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/factory.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/gateway.kyma.cx/interface.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/gateway.kyma.cx/v1alpha2/api.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/gateway.kyma.cx/v1alpha2/interface.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/generic.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/internalinterfaces/factory_interfaces.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/listers/gateway.kyma.cx/v1alpha2/api.go create mode 100644 components/api-controller/pkg/clients/gateway.kyma.cx/listers/gateway.kyma.cx/v1alpha2/expansion_generated.go create mode 100644 components/api-controller/pkg/controller/authentication/v2/authenticationv2.go create mode 100644 components/api-controller/pkg/controller/authentication/v2/authenticationv2_integration_test.go create mode 100644 components/api-controller/pkg/controller/authentication/v2/authenticationv2_test.go create mode 100644 components/api-controller/pkg/controller/authentication/v2/doc.go create mode 100644 components/api-controller/pkg/controller/authentication/v2/interface.go create mode 100644 components/api-controller/pkg/controller/commons/errors.go create mode 100644 components/api-controller/pkg/controller/crd/crd.go create mode 100644 components/api-controller/pkg/controller/ingress/v1/doc.go create mode 100644 components/api-controller/pkg/controller/ingress/v1/ingressv1.go create mode 100644 components/api-controller/pkg/controller/ingress/v1/ingressv1_integration_test.go create mode 100644 components/api-controller/pkg/controller/ingress/v1/ingressv1_test.go create mode 100644 components/api-controller/pkg/controller/ingress/v1/interface.go create mode 100644 components/api-controller/pkg/controller/meta/dto.go create mode 100644 components/api-controller/pkg/controller/service/v1/interface.go create mode 100644 components/api-controller/pkg/controller/service/v1/servicev1.go create mode 100644 components/api-controller/pkg/controller/service/v1/servicev1_integration_test.go create mode 100644 components/api-controller/pkg/controller/service/v1/servicev1_test.go create mode 100644 components/api-controller/pkg/controller/v1alpha2/apistatus.go create mode 100644 components/api-controller/pkg/controller/v1alpha2/controller.go create mode 100644 components/api-controller/pkg/controller/v1alpha2/crd.go create mode 100644 components/api-controller/pkg/controller/v1alpha2/crd_integration_test.go create mode 100644 components/application-connector/.gitignore create mode 100644 components/application-connector/Dockerfile create mode 100644 components/application-connector/Gopkg.lock create mode 100644 components/application-connector/Gopkg.toml create mode 100755 components/application-connector/Jenkinsfile create mode 100644 components/application-connector/README.md create mode 100644 components/application-connector/cmd/metadata/metadata.go create mode 100644 components/application-connector/cmd/metadata/options.go create mode 100644 components/application-connector/docs/api/metadata.yaml create mode 100644 components/application-connector/docs/instalation/Installation.md create mode 100644 components/application-connector/docs/instalation/remote-environments.zip create mode 100644 components/application-connector/hack/custom-boilerplate.go.txt create mode 100755 components/application-connector/hack/generate-groups.sh create mode 100755 components/application-connector/hack/update-codegen.sh create mode 100644 components/application-connector/internal/apperrors/apperrors.go create mode 100644 components/application-connector/internal/apperrors/apperrors_test.go create mode 100644 components/application-connector/internal/externalapi/errorhandler.go create mode 100644 components/application-connector/internal/externalapi/errorhandler_test.go create mode 100644 components/application-connector/internal/externalapi/externalapi.go create mode 100644 components/application-connector/internal/externalapi/healthcheckhandler.go create mode 100644 components/application-connector/internal/externalapi/healthcheckhandler_test.go create mode 100644 components/application-connector/internal/externalapi/invalidstatehandler.go create mode 100644 components/application-connector/internal/externalapi/invalidstatehandler_test.go create mode 100644 components/application-connector/internal/externalapi/metadatahandler.go create mode 100644 components/application-connector/internal/externalapi/metadatahandler_test.go create mode 100644 components/application-connector/internal/externalapi/model.go create mode 100644 components/application-connector/internal/externalapi/servicedetailsvalidation.go create mode 100644 components/application-connector/internal/externalapi/servicedetailsvalidation_test.go create mode 100644 components/application-connector/internal/httpconsts/httpconsts.go create mode 100644 components/application-connector/internal/httperrors/httperrors.go create mode 100644 components/application-connector/internal/httptools/logging.go create mode 100644 components/application-connector/internal/k8sconsts/k8sconsts.go create mode 100644 components/application-connector/internal/k8sconsts/mocks/NameResolver.go create mode 100644 components/application-connector/internal/k8sconsts/nameresolver.go create mode 100644 components/application-connector/internal/k8sconsts/nameresolver_test.go create mode 100644 components/application-connector/internal/metadata/accessservice/accessservicemanager.go create mode 100644 components/application-connector/internal/metadata/accessservice/accessservicemanager_test.go create mode 100644 components/application-connector/internal/metadata/accessservice/mocks/AccessServiceManager.go create mode 100644 components/application-connector/internal/metadata/accessservice/mocks/ServiceInterface.go create mode 100644 components/application-connector/internal/metadata/istio/mocks/ChecknothingInterface.go create mode 100644 components/application-connector/internal/metadata/istio/mocks/DenierInterface.go create mode 100644 components/application-connector/internal/metadata/istio/mocks/Repository.go create mode 100644 components/application-connector/internal/metadata/istio/mocks/RuleInterface.go create mode 100644 components/application-connector/internal/metadata/istio/mocks/Service.go create mode 100644 components/application-connector/internal/metadata/istio/repository.go create mode 100644 components/application-connector/internal/metadata/istio/repository_test.go create mode 100644 components/application-connector/internal/metadata/istio/service.go create mode 100644 components/application-connector/internal/metadata/istio/service_test.go create mode 100644 components/application-connector/internal/metadata/minio/mocks/Client.go create mode 100644 components/application-connector/internal/metadata/minio/mocks/Repository.go create mode 100644 components/application-connector/internal/metadata/minio/mocks/Service.go create mode 100644 components/application-connector/internal/metadata/minio/repository.go create mode 100644 components/application-connector/internal/metadata/minio/repository_test.go create mode 100644 components/application-connector/internal/metadata/minio/service.go create mode 100644 components/application-connector/internal/metadata/minio/service_test.go create mode 100644 components/application-connector/internal/metadata/mocks/ServiceDefinitionService.go create mode 100644 components/application-connector/internal/metadata/model.go create mode 100644 components/application-connector/internal/metadata/remoteenv/mocks/RemoteEnvironmentManager.go create mode 100644 components/application-connector/internal/metadata/remoteenv/mocks/ServiceRepository.go create mode 100644 components/application-connector/internal/metadata/remoteenv/repository.go create mode 100644 components/application-connector/internal/metadata/remoteenv/repository_test.go create mode 100644 components/application-connector/internal/metadata/remoteenv/util.go create mode 100644 components/application-connector/internal/metadata/secrets/mocks/Manager.go create mode 100644 components/application-connector/internal/metadata/secrets/mocks/Repository.go create mode 100644 components/application-connector/internal/metadata/secrets/repository.go create mode 100644 components/application-connector/internal/metadata/secrets/repository_test.go create mode 100644 components/application-connector/internal/metadata/serviceapi/mocks/Service.go create mode 100644 components/application-connector/internal/metadata/serviceapi/serviceapiservice.go create mode 100644 components/application-connector/internal/metadata/serviceapi/serviceapiservice_test.go create mode 100644 components/application-connector/internal/metadata/servicedefservice.go create mode 100644 components/application-connector/internal/metadata/servicedefservice_test.go create mode 100644 components/application-connector/internal/metadata/uuid/generator.go create mode 100644 components/application-connector/internal/metadata/uuid/mocks/Generator.go create mode 100644 components/application-connector/pkg/apis/istio/v1alpha2/doc.go create mode 100644 components/application-connector/pkg/apis/istio/v1alpha2/register.go create mode 100644 components/application-connector/pkg/apis/istio/v1alpha2/types.go create mode 100644 components/application-connector/pkg/apis/istio/v1alpha2/zz_generated.deepcopy.go create mode 100644 components/application-connector/pkg/client/clientset/versioned/clientset.go create mode 100644 components/application-connector/pkg/client/clientset/versioned/doc.go create mode 100644 components/application-connector/pkg/client/clientset/versioned/fake/clientset_generated.go create mode 100644 components/application-connector/pkg/client/clientset/versioned/fake/doc.go create mode 100644 components/application-connector/pkg/client/clientset/versioned/fake/register.go create mode 100644 components/application-connector/pkg/client/clientset/versioned/scheme/doc.go create mode 100644 components/application-connector/pkg/client/clientset/versioned/scheme/register.go create mode 100644 components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/checknothing.go create mode 100644 components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/denier.go create mode 100644 components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/doc.go create mode 100644 components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/fake/doc.go create mode 100644 components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/fake/fake_checknothing.go create mode 100644 components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/fake/fake_denier.go create mode 100644 components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/fake/fake_istio_client.go create mode 100644 components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/fake/fake_rule.go create mode 100644 components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/generated_expansion.go create mode 100644 components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/istio_client.go create mode 100644 components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/rule.go create mode 100644 components/application-connector/pkg/client/informers/externalversions/factory.go create mode 100644 components/application-connector/pkg/client/informers/externalversions/generic.go create mode 100644 components/application-connector/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go create mode 100644 components/application-connector/pkg/client/informers/externalversions/istio/interface.go create mode 100644 components/application-connector/pkg/client/informers/externalversions/istio/v1alpha2/checknothing.go create mode 100644 components/application-connector/pkg/client/informers/externalversions/istio/v1alpha2/denier.go create mode 100644 components/application-connector/pkg/client/informers/externalversions/istio/v1alpha2/interface.go create mode 100644 components/application-connector/pkg/client/informers/externalversions/istio/v1alpha2/rule.go create mode 100644 components/application-connector/pkg/client/listers/istio/v1alpha2/checknothing.go create mode 100644 components/application-connector/pkg/client/listers/istio/v1alpha2/denier.go create mode 100644 components/application-connector/pkg/client/listers/istio/v1alpha2/expansion_generated.go create mode 100644 components/application-connector/pkg/client/listers/istio/v1alpha2/rule.go create mode 100755 components/application-connector/scripts/can-i-commit.sh create mode 100755 components/application-connector/scripts/delete-all.sh create mode 100644 components/application-connector/scripts/log-oauth-request.js create mode 100644 components/application-connector/scripts/log-request.js create mode 100644 components/application-connector/test/echo-service/README.md create mode 100644 components/binding-usage-controller/.gitignore create mode 100644 components/binding-usage-controller/Gopkg.lock create mode 100644 components/binding-usage-controller/Gopkg.toml create mode 100644 components/binding-usage-controller/Jenkinsfile create mode 100644 components/binding-usage-controller/README.md create mode 100755 components/binding-usage-controller/before-commit.sh create mode 100644 components/binding-usage-controller/cmd/controller/main.go create mode 100644 components/binding-usage-controller/deploy/controller/Dockerfile create mode 100644 components/binding-usage-controller/docs/README.md create mode 100644 components/binding-usage-controller/docs/architecture.md create mode 100644 components/binding-usage-controller/docs/assets/architecture.html create mode 100644 components/binding-usage-controller/docs/assets/architecture.png create mode 100644 components/binding-usage-controller/docs/example.md create mode 100644 components/binding-usage-controller/docs/status.md create mode 100644 components/binding-usage-controller/examples/README.md create mode 100644 components/binding-usage-controller/examples/deploy/redis-client.yaml create mode 100644 components/binding-usage-controller/examples/deploy/service-binding-usage.yaml create mode 100644 components/binding-usage-controller/examples/function/redis-client.yaml create mode 100644 components/binding-usage-controller/examples/function/service-binding-usage.yaml create mode 100644 components/binding-usage-controller/examples/servicecatalog/redis-instance-binding.yaml create mode 100644 components/binding-usage-controller/examples/servicecatalog/redis-instance.yaml create mode 100644 components/binding-usage-controller/hack/boilerplate.go.txt create mode 100755 components/binding-usage-controller/hack/generate-groups.sh create mode 100755 components/binding-usage-controller/hack/update-codegen.sh create mode 100644 components/binding-usage-controller/internal/controller/automock/applied_spec_storage.go create mode 100644 components/binding-usage-controller/internal/controller/automock/binding_labels_fetcher.go create mode 100644 components/binding-usage-controller/internal/controller/automock/binding_usage_checker.go create mode 100644 components/binding-usage-controller/internal/controller/automock/deployment_finder.go create mode 100644 components/binding-usage-controller/internal/controller/automock/extends.go create mode 100644 components/binding-usage-controller/internal/controller/automock/kinds_supervisors.go create mode 100644 components/binding-usage-controller/internal/controller/automock/kubeless_function_finder.go create mode 100644 components/binding-usage-controller/internal/controller/automock/kubernetes_resource_supervisor.go create mode 100644 components/binding-usage-controller/internal/controller/automock/pod_preset_modifier.go create mode 100644 components/binding-usage-controller/internal/controller/automock/usage_binding_annotation_tracer.go create mode 100644 components/binding-usage-controller/internal/controller/controller.go create mode 100644 components/binding-usage-controller/internal/controller/controller_export_test.go create mode 100644 components/binding-usage-controller/internal/controller/controller_test.go create mode 100644 components/binding-usage-controller/internal/controller/deployment_supervisor.go create mode 100644 components/binding-usage-controller/internal/controller/deployment_supervisor_export_test.go create mode 100644 components/binding-usage-controller/internal/controller/deployment_supervisor_test.go create mode 100644 components/binding-usage-controller/internal/controller/env_prefix.go create mode 100644 components/binding-usage-controller/internal/controller/env_prefix_test.go create mode 100644 components/binding-usage-controller/internal/controller/errors.go create mode 100644 components/binding-usage-controller/internal/controller/helpers_test.go create mode 100644 components/binding-usage-controller/internal/controller/kubeless_function_supervisor.go create mode 100644 components/binding-usage-controller/internal/controller/kubeless_function_supervisor_export_test.go create mode 100644 components/binding-usage-controller/internal/controller/kubeless_function_supervisor_test.go create mode 100644 components/binding-usage-controller/internal/controller/label_checker.go create mode 100644 components/binding-usage-controller/internal/controller/label_checker_test.go create mode 100644 components/binding-usage-controller/internal/controller/labels_fetcher.go create mode 100644 components/binding-usage-controller/internal/controller/labels_fetcher_test.go create mode 100644 components/binding-usage-controller/internal/controller/map_helpers.go create mode 100644 components/binding-usage-controller/internal/controller/map_helpers_test.go create mode 100644 components/binding-usage-controller/internal/controller/podpreset_modifer.go create mode 100644 components/binding-usage-controller/internal/controller/podpreset_modifer_test.go create mode 100644 components/binding-usage-controller/internal/controller/pretty/pretty.go create mode 100644 components/binding-usage-controller/internal/controller/sbu_spec_storage.go create mode 100644 components/binding-usage-controller/internal/controller/sbu_spec_storage_test.go create mode 100644 components/binding-usage-controller/internal/controller/sbu_tracer.go create mode 100644 components/binding-usage-controller/internal/controller/sbu_tracer_export_test.go create mode 100644 components/binding-usage-controller/internal/controller/sbu_tracer_test.go create mode 100644 components/binding-usage-controller/internal/controller/status/usage.go create mode 100644 components/binding-usage-controller/internal/controller/status/usage_test.go create mode 100644 components/binding-usage-controller/internal/controller/supervisor_aggregator.go create mode 100644 components/binding-usage-controller/internal/controller/supervisor_aggregator_test.go create mode 100644 components/binding-usage-controller/internal/platform/logger/config.go create mode 100644 components/binding-usage-controller/internal/platform/logger/doc.go create mode 100644 components/binding-usage-controller/internal/platform/logger/logger.go create mode 100644 components/binding-usage-controller/internal/platform/logger/logger_mock.go create mode 100644 components/binding-usage-controller/internal/platform/logger/spy/formatter.go create mode 100644 components/binding-usage-controller/internal/platform/logger/spy/logger.go create mode 100644 components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1/doc.go create mode 100644 components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1/register.go create mode 100644 components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1/types.go create mode 100644 components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1/zz_generated.deepcopy.go create mode 100644 components/binding-usage-controller/pkg/client/clientset/versioned/clientset.go create mode 100644 components/binding-usage-controller/pkg/client/clientset/versioned/doc.go create mode 100644 components/binding-usage-controller/pkg/client/clientset/versioned/fake/clientset_generated.go create mode 100644 components/binding-usage-controller/pkg/client/clientset/versioned/fake/doc.go create mode 100644 components/binding-usage-controller/pkg/client/clientset/versioned/fake/register.go create mode 100644 components/binding-usage-controller/pkg/client/clientset/versioned/scheme/doc.go create mode 100644 components/binding-usage-controller/pkg/client/clientset/versioned/scheme/register.go create mode 100644 components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/doc.go create mode 100644 components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/fake/doc.go create mode 100644 components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/fake/fake_servicebindingusage.go create mode 100644 components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/fake/fake_servicecatalog_client.go create mode 100644 components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/generated_expansion.go create mode 100644 components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/servicebindingusage.go create mode 100644 components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/servicecatalog_client.go create mode 100644 components/binding-usage-controller/pkg/client/informers/externalversions/factory.go create mode 100644 components/binding-usage-controller/pkg/client/informers/externalversions/generic.go create mode 100644 components/binding-usage-controller/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go create mode 100644 components/binding-usage-controller/pkg/client/informers/externalversions/servicecatalog/interface.go create mode 100644 components/binding-usage-controller/pkg/client/informers/externalversions/servicecatalog/v1alpha1/interface.go create mode 100644 components/binding-usage-controller/pkg/client/informers/externalversions/servicecatalog/v1alpha1/servicebindingusage.go create mode 100644 components/binding-usage-controller/pkg/client/listers/servicecatalog/v1alpha1/expansion_generated.go create mode 100644 components/binding-usage-controller/pkg/client/listers/servicecatalog/v1alpha1/servicebindingusage.go create mode 100644 components/binding-usage-controller/pkg/signal/signal.go create mode 100644 components/configurations-generator/Dockerfile create mode 100644 components/configurations-generator/Gopkg.lock create mode 100644 components/configurations-generator/Gopkg.toml create mode 100644 components/configurations-generator/Jenkinsfile create mode 100644 components/configurations-generator/README.md create mode 100644 components/configurations-generator/cmd/generator/main.go create mode 100644 components/configurations-generator/doc.go create mode 100644 components/configurations-generator/pkg/kube_config/endpoints.go create mode 100644 components/configurations-generator/pkg/kube_config/endpoints_test.go create mode 100644 components/configurations-generator/pkg/kube_config/kube_config.go create mode 100755 components/connector-service/.gitignore create mode 100755 components/connector-service/Dockerfile create mode 100755 components/connector-service/Gopkg.lock create mode 100755 components/connector-service/Gopkg.toml create mode 100755 components/connector-service/Jenkinsfile create mode 100755 components/connector-service/README.md create mode 100755 components/connector-service/cmd/connectorservice/connectorservice.go create mode 100755 components/connector-service/cmd/connectorservice/options.go create mode 100755 components/connector-service/docs/api/swagger.yaml create mode 100755 components/connector-service/internal/apperrors/apperrors.go create mode 100755 components/connector-service/internal/apperrors/apperrors_test.go create mode 100755 components/connector-service/internal/certificates/certificates.go create mode 100755 components/connector-service/internal/certificates/certificates_test.go create mode 100755 components/connector-service/internal/certificates/mocks/CertificateUtility.go create mode 100755 components/connector-service/internal/errorhandler/errorhandler.go create mode 100755 components/connector-service/internal/errorhandler/errorhandler_test.go create mode 100755 components/connector-service/internal/externalapi/externalapi.go create mode 100755 components/connector-service/internal/externalapi/infohandler.go create mode 100755 components/connector-service/internal/externalapi/infohandler_test.go create mode 100755 components/connector-service/internal/externalapi/model.go create mode 100755 components/connector-service/internal/externalapi/signaturehandler.go create mode 100755 components/connector-service/internal/externalapi/signaturehandler_test.go create mode 100755 components/connector-service/internal/httpconsts/httpconsts.go create mode 100755 components/connector-service/internal/httperrors/httperrors.go create mode 100755 components/connector-service/internal/internalapi/internalapi.go create mode 100755 components/connector-service/internal/internalapi/tokenhandler.go create mode 100755 components/connector-service/internal/internalapi/tokenhandler_test.go create mode 100755 components/connector-service/internal/secrets/mocks/Manager.go create mode 100755 components/connector-service/internal/secrets/mocks/Repository.go create mode 100755 components/connector-service/internal/secrets/repository.go create mode 100755 components/connector-service/internal/secrets/repository_test.go create mode 100755 components/connector-service/internal/tokens/mocks/TokenGenerator.go create mode 100755 components/connector-service/internal/tokens/tokencache/mocks/TokenCache.go create mode 100755 components/connector-service/internal/tokens/tokencache/tokencache.go create mode 100755 components/connector-service/internal/tokens/tokens.go create mode 100755 components/connector-service/internal/tokens/tokens_test.go create mode 100755 components/connector-service/scripts/can-i-commit.sh create mode 100644 components/environments/.gitignore create mode 100644 components/environments/Gopkg.lock create mode 100644 components/environments/Gopkg.toml create mode 100644 components/environments/Jenkinsfile create mode 100644 components/environments/README.md create mode 100755 components/environments/before-commit.sh create mode 100644 components/environments/cmd/controller/main.go create mode 100644 components/environments/deploy/controller/Dockerfile create mode 100644 components/environments/examples/namespace-test.yaml create mode 100644 components/environments/internal/controller/config.go create mode 100644 components/environments/internal/controller/controller.go create mode 100644 components/environments/internal/controller/controller_test.go create mode 100644 components/environments/internal/controller/environment_watcher.go create mode 100644 components/environments/internal/errors.go create mode 100644 components/environments/internal/limit_range/client.go create mode 100644 components/environments/internal/namespaces/client.go create mode 100644 components/environments/internal/resource-quota/client.go create mode 100644 components/environments/internal/roles/client.go create mode 100644 components/event-bus/.gitignore create mode 100644 components/event-bus/Gopkg.lock create mode 100644 components/event-bus/Gopkg.toml create mode 100644 components/event-bus/Jenkinsfile create mode 100644 components/event-bus/README.md create mode 100644 components/event-bus/api/publish/api.go create mode 100644 components/event-bus/api/publish/error.go create mode 100644 components/event-bus/api/publish/validators.go create mode 100644 components/event-bus/api/publish/validators_test.go create mode 100644 components/event-bus/api/push/eventing.kyma.cx/register.go create mode 100644 components/event-bus/api/push/eventing.kyma.cx/v1alpha1/doc.go create mode 100644 components/event-bus/api/push/eventing.kyma.cx/v1alpha1/helpers.go create mode 100644 components/event-bus/api/push/eventing.kyma.cx/v1alpha1/register.go create mode 100644 components/event-bus/api/push/eventing.kyma.cx/v1alpha1/types.go create mode 100644 components/event-bus/api/push/eventing.kyma.cx/v1alpha1/zz_generated.deepcopy.go create mode 100644 components/event-bus/cmd/event-bus-publish/Dockerfile create mode 100644 components/event-bus/cmd/event-bus-publish/Makefile create mode 100644 components/event-bus/cmd/event-bus-publish/application/application.go create mode 100644 components/event-bus/cmd/event-bus-publish/controllers/nats.go create mode 100755 components/event-bus/cmd/event-bus-publish/dockerBuild.sh create mode 100644 components/event-bus/cmd/event-bus-publish/handlers/handlers.go create mode 100644 components/event-bus/cmd/event-bus-publish/main.go create mode 100644 components/event-bus/cmd/event-bus-publish/publish_test.go create mode 100644 components/event-bus/cmd/event-bus-publish/test_helpers.go create mode 100644 components/event-bus/cmd/event-bus-push/Dockerfile create mode 100644 components/event-bus/cmd/event-bus-push/Makefile create mode 100644 components/event-bus/cmd/event-bus-push/application/application.go create mode 100755 components/event-bus/cmd/event-bus-push/dockerBuild.sh create mode 100644 components/event-bus/cmd/event-bus-push/main.go create mode 100644 components/event-bus/cmd/event-bus-sv/Dockerfile create mode 100644 components/event-bus/cmd/event-bus-sv/Makefile create mode 100644 components/event-bus/cmd/event-bus-sv/application/application.go create mode 100755 components/event-bus/cmd/event-bus-sv/dockerBuild.sh create mode 100644 components/event-bus/cmd/event-bus-sv/main.go create mode 100644 components/event-bus/docs/api.yaml create mode 100644 components/event-bus/generated/ea/clientset/versioned/clientset.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/doc.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/fake/clientset_generated.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/fake/doc.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/fake/register.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/scheme/doc.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/scheme/register.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/doc.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/eventactivation.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/fake/doc.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/fake/fake_eventactivation.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/fake/fake_remoteenvironment.kyma.cx_client.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/generated_expansion.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/remoteenvironment.kyma.cx_client.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/doc.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/eventactivation.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/fake/doc.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/fake/fake_eventactivation.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/fake/fake_remoteenvironment.kyma.io_client.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/generated_expansion.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/remoteenvironment.kyma.io_client.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/doc.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/eventactivation.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/fake/doc.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/fake/fake_eventactivation.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/fake/fake_remoteenvironment.ysf.io_client.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/generated_expansion.go create mode 100644 components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/remoteenvironment.ysf.io_client.go create mode 100644 components/event-bus/generated/ea/informers/externalversions/factory.go create mode 100644 components/event-bus/generated/ea/informers/externalversions/generic.go create mode 100644 components/event-bus/generated/ea/informers/externalversions/internalinterfaces/factory_interfaces.go create mode 100644 components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.cx/interface.go create mode 100644 components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.cx/v1alpha1/eventactivation.go create mode 100644 components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.cx/v1alpha1/interface.go create mode 100644 components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.io/interface.go create mode 100644 components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.io/v1alpha1/eventactivation.go create mode 100644 components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.io/v1alpha1/interface.go create mode 100644 components/event-bus/generated/ea/informers/externalversions/remoteenvironment.ysf.io/interface.go create mode 100644 components/event-bus/generated/ea/informers/externalversions/remoteenvironment.ysf.io/v1alpha1/eventactivation.go create mode 100644 components/event-bus/generated/ea/informers/externalversions/remoteenvironment.ysf.io/v1alpha1/interface.go create mode 100644 components/event-bus/generated/ea/listers/remoteenvironment.kyma.cx/v1alpha1/eventactivation.go create mode 100644 components/event-bus/generated/ea/listers/remoteenvironment.kyma.cx/v1alpha1/expansion_generated.go create mode 100644 components/event-bus/generated/ea/listers/remoteenvironment.kyma.io/v1alpha1/eventactivation.go create mode 100644 components/event-bus/generated/ea/listers/remoteenvironment.kyma.io/v1alpha1/expansion_generated.go create mode 100644 components/event-bus/generated/ea/listers/remoteenvironment.ysf.io/v1alpha1/eventactivation.go create mode 100644 components/event-bus/generated/ea/listers/remoteenvironment.ysf.io/v1alpha1/expansion_generated.go create mode 100644 components/event-bus/generated/push/clientset/versioned/clientset.go create mode 100644 components/event-bus/generated/push/clientset/versioned/doc.go create mode 100644 components/event-bus/generated/push/clientset/versioned/fake/clientset_generated.go create mode 100644 components/event-bus/generated/push/clientset/versioned/fake/doc.go create mode 100644 components/event-bus/generated/push/clientset/versioned/fake/register.go create mode 100644 components/event-bus/generated/push/clientset/versioned/scheme/doc.go create mode 100644 components/event-bus/generated/push/clientset/versioned/scheme/register.go create mode 100644 components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/doc.go create mode 100644 components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/eventing.kyma.cx_client.go create mode 100644 components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/fake/doc.go create mode 100644 components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/fake/fake_eventing.kyma.cx_client.go create mode 100644 components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/fake/fake_subscription.go create mode 100644 components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/generated_expansion.go create mode 100644 components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/subscription.go create mode 100644 components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/doc.go create mode 100644 components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/eventing.kyma.io_client.go create mode 100644 components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/fake/doc.go create mode 100644 components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/fake/fake_eventing.kyma.io_client.go create mode 100644 components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/fake/fake_subscription.go create mode 100644 components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/generated_expansion.go create mode 100644 components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/subscription.go create mode 100644 components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/doc.go create mode 100644 components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/eventing.ysf.io_client.go create mode 100644 components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/fake/doc.go create mode 100644 components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/fake/fake_eventing.ysf.io_client.go create mode 100644 components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/fake/fake_subscription.go create mode 100644 components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/generated_expansion.go create mode 100644 components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/subscription.go create mode 100644 components/event-bus/generated/push/informers/externalversions/eventing.kyma.cx/interface.go create mode 100644 components/event-bus/generated/push/informers/externalversions/eventing.kyma.cx/v1alpha1/interface.go create mode 100644 components/event-bus/generated/push/informers/externalversions/eventing.kyma.cx/v1alpha1/subscription.go create mode 100644 components/event-bus/generated/push/informers/externalversions/eventing.kyma.io/interface.go create mode 100644 components/event-bus/generated/push/informers/externalversions/eventing.kyma.io/v1alpha1/interface.go create mode 100644 components/event-bus/generated/push/informers/externalversions/eventing.kyma.io/v1alpha1/subscription.go create mode 100644 components/event-bus/generated/push/informers/externalversions/eventing.ysf.io/interface.go create mode 100644 components/event-bus/generated/push/informers/externalversions/eventing.ysf.io/v1alpha1/interface.go create mode 100644 components/event-bus/generated/push/informers/externalversions/eventing.ysf.io/v1alpha1/subscription.go create mode 100644 components/event-bus/generated/push/informers/externalversions/factory.go create mode 100644 components/event-bus/generated/push/informers/externalversions/generic.go create mode 100644 components/event-bus/generated/push/informers/externalversions/internalinterfaces/factory_interfaces.go create mode 100644 components/event-bus/generated/push/listers/eventing.kyma.cx/v1alpha1/expansion_generated.go create mode 100644 components/event-bus/generated/push/listers/eventing.kyma.cx/v1alpha1/subscription.go create mode 100644 components/event-bus/generated/push/listers/eventing.kyma.io/v1alpha1/expansion_generated.go create mode 100644 components/event-bus/generated/push/listers/eventing.kyma.io/v1alpha1/subscription.go create mode 100644 components/event-bus/generated/push/listers/eventing.ysf.io/v1alpha1/expansion_generated.go create mode 100644 components/event-bus/generated/push/listers/eventing.ysf.io/v1alpha1/subscription.go create mode 100644 components/event-bus/hack/boilerplate/boilerplate.go.txt create mode 100755 components/event-bus/hack/update-client-gen.sh create mode 100755 components/event-bus/hack/update-ea-client-gen.sh create mode 100644 components/event-bus/internal/common/common.go create mode 100644 components/event-bus/internal/common/common_test.go create mode 100644 components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/register.go create mode 100644 components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1/doc.go create mode 100644 components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1/register.go create mode 100644 components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1/types.go create mode 100644 components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1/zz_generated.deepcopy.go create mode 100644 components/event-bus/internal/publish/opts.go create mode 100644 components/event-bus/internal/publish/util.go create mode 100644 components/event-bus/internal/push/actors/subscriptions_supervisor.go create mode 100644 components/event-bus/internal/push/controllers/eventactivation.go create mode 100644 components/event-bus/internal/push/controllers/eventactivation_test.go create mode 100644 components/event-bus/internal/push/controllers/noeventactivation.go create mode 100644 components/event-bus/internal/push/controllers/subscriptions_controller.go create mode 100644 components/event-bus/internal/push/handlers/message_handler.go create mode 100644 components/event-bus/internal/push/handlers/message_handler_test.go create mode 100644 components/event-bus/internal/push/opts/opts.go create mode 100644 components/event-bus/internal/stanutil/stanutil.go create mode 100644 components/event-bus/internal/sv/eventactivations_controller.go create mode 100644 components/event-bus/internal/sv/eventactivations_utils.go create mode 100644 components/event-bus/internal/sv/opts/opts.go create mode 100644 components/event-bus/internal/sv/subscriptions_controller.go create mode 100644 components/event-bus/internal/sv/subscriptions_utils.go create mode 100644 components/event-bus/internal/trace/tags.go create mode 100644 components/event-bus/internal/trace/tracer.go create mode 100644 components/event-bus/test/acceptance/push/acceptance_test.go create mode 100644 components/event-bus/test/util/crd.go create mode 100644 components/event-bus/test/util/logger.go create mode 100644 components/event-bus/test/util/subscriber.go create mode 100644 components/event-bus/test/util/subscriber_yaml.go create mode 100755 components/format.sh create mode 100644 components/gateway/Dockerfile create mode 100644 components/gateway/Gopkg.lock create mode 100644 components/gateway/Gopkg.toml create mode 100755 components/gateway/Jenkinsfile create mode 100644 components/gateway/README.md create mode 100644 components/gateway/cmd/gateway/gateway.go create mode 100644 components/gateway/cmd/gateway/options.go create mode 100644 components/gateway/docs/api/externalapi.yaml create mode 100644 components/gateway/docs/events/README.md create mode 100644 components/gateway/docs/instalation/Installation.md create mode 100644 components/gateway/docs/instalation/remote-environments.zip create mode 100644 components/gateway/hack/custom-boilerplate.go.txt create mode 100755 components/gateway/hack/generate-groups.sh create mode 100755 components/gateway/hack/update-codegen.sh create mode 100644 components/gateway/internal/apperrors/apperrors.go create mode 100644 components/gateway/internal/apperrors/apperrors_test.go create mode 100644 components/gateway/internal/events/api/types.go create mode 100644 components/gateway/internal/events/bus/process.go create mode 100644 components/gateway/internal/events/bus/send.go create mode 100644 components/gateway/internal/events/shared/constants.go create mode 100644 components/gateway/internal/events/shared/utils.go create mode 100644 components/gateway/internal/externalapi/blackb_data_test.go create mode 100644 components/gateway/internal/externalapi/blackb_test.go create mode 100644 components/gateway/internal/externalapi/errorhandler.go create mode 100644 components/gateway/internal/externalapi/errorhandler_test.go create mode 100644 components/gateway/internal/externalapi/eventshandler.go create mode 100644 components/gateway/internal/externalapi/eventshandler_test.go create mode 100644 components/gateway/internal/externalapi/externalapi.go create mode 100644 components/gateway/internal/externalapi/healthcheckhandler.go create mode 100644 components/gateway/internal/externalapi/healthcheckhandler_test.go create mode 100644 components/gateway/internal/httpconsts/httpconsts.go create mode 100644 components/gateway/internal/httperrors/httperrors.go create mode 100644 components/gateway/internal/httptools/http.go create mode 100644 components/gateway/internal/httptools/logging.go create mode 100644 components/gateway/internal/k8sconsts/k8sconsts.go create mode 100644 components/gateway/internal/k8sconsts/mocks/NameResolver.go create mode 100644 components/gateway/internal/k8sconsts/nameresolver.go create mode 100644 components/gateway/internal/k8sconsts/nameresolver_test.go create mode 100644 components/gateway/internal/metadata/mocks/ServiceDefinitionService.go create mode 100644 components/gateway/internal/metadata/model.go create mode 100644 components/gateway/internal/metadata/remoteenv/mocks/Manager.go create mode 100644 components/gateway/internal/metadata/remoteenv/mocks/ServiceRepository.go create mode 100644 components/gateway/internal/metadata/remoteenv/repository.go create mode 100644 components/gateway/internal/metadata/remoteenv/repository_test.go create mode 100644 components/gateway/internal/metadata/remoteenv/util.go create mode 100644 components/gateway/internal/metadata/secrets/mocks/Manager.go create mode 100644 components/gateway/internal/metadata/secrets/mocks/Repository.go create mode 100644 components/gateway/internal/metadata/secrets/repository.go create mode 100644 components/gateway/internal/metadata/secrets/repository_test.go create mode 100644 components/gateway/internal/metadata/serviceapi/mocks/Service.go create mode 100644 components/gateway/internal/metadata/serviceapi/serviceapiservice.go create mode 100644 components/gateway/internal/metadata/serviceapi/serviceapiservice_test.go create mode 100644 components/gateway/internal/metadata/servicedefservice.go create mode 100644 components/gateway/internal/metadata/servicedefservice_test.go create mode 100644 components/gateway/internal/proxy/mocks/OAuthClient.go create mode 100644 components/gateway/internal/proxy/oauthclient.go create mode 100644 components/gateway/internal/proxy/oauthclient_test.go create mode 100644 components/gateway/internal/proxy/proxy.go create mode 100644 components/gateway/internal/proxy/proxy_test.go create mode 100644 components/gateway/internal/proxy/proxycache/mocks/HTTPProxyCache.go create mode 100644 components/gateway/internal/proxy/proxycache/proxycache.go create mode 100644 components/gateway/internal/proxy/proxycache/proxycache_test.go create mode 100644 components/gateway/internal/proxy/proxyfactory.go create mode 100755 components/gateway/scripts/can-i-commit.sh create mode 100755 components/gateway/scripts/delete-all.sh create mode 100644 components/gateway/scripts/log-oauth-request.js create mode 100644 components/gateway/scripts/log-request.js create mode 100644 components/gateway/scripts/telepresence.log create mode 100644 components/gateway/test/echo-service/README.md create mode 100644 components/helm-broker/.gitignore create mode 100644 components/helm-broker/Gopkg.lock create mode 100644 components/helm-broker/Gopkg.toml create mode 100644 components/helm-broker/Jenkinsfile create mode 100644 components/helm-broker/README.md create mode 100755 components/helm-broker/before-commit.sh create mode 100644 components/helm-broker/cmd/broker/main.go create mode 100644 components/helm-broker/cmd/checker/main.go create mode 100644 components/helm-broker/cmd/indexbuilder/index.go create mode 100644 components/helm-broker/cmd/indexbuilder/index_test.go create mode 100644 components/helm-broker/cmd/indexbuilder/main.go create mode 100644 components/helm-broker/cmd/reposerver/README.md create mode 100644 components/helm-broker/cmd/reposerver/main.go create mode 100644 components/helm-broker/cmd/reposerver/main_test.go create mode 100644 components/helm-broker/cmd/targz/README.md create mode 100644 components/helm-broker/cmd/targz/archiver/archiver.go create mode 100644 components/helm-broker/cmd/targz/archiver/tar.go create mode 100644 components/helm-broker/cmd/targz/archiver/targz.go create mode 100644 components/helm-broker/cmd/targz/main.go create mode 100644 components/helm-broker/cmd/targz/main_test.go create mode 100644 components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/.helmignore create mode 100644 components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/Chart.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/templates/_helpers.tpl create mode 100644 components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/templates/deployment.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/templates/ingress.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/templates/service.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/values.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/meta.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/plans/default/create-instance-schema.json create mode 100644 components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/plans/default/meta.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/plans/default/values.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/.helmignore create mode 100644 components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/Chart.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/README.md create mode 100644 components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/NOTES.txt create mode 100644 components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/_helpers.tpl create mode 100644 components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/deployment.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/networkpolicy.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/pvc.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/secrets.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/svc.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/tests/test-redis-connection.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/values.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/meta.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/enterprise/bind.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/enterprise/create-instance-schema.json create mode 100644 components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/enterprise/meta.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/enterprise/values.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/micro/bind.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/micro/create-instance-schema.json create mode 100644 components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/micro/meta.yaml create mode 100644 components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/micro/values.yaml create mode 100644 components/helm-broker/deploy/broker/Dockerfile create mode 100644 components/helm-broker/deploy/reposerver/Dockerfile create mode 100644 components/helm-broker/deploy/tools/Dockerfile create mode 100644 components/helm-broker/internal/broker/automock/bind_template_renderer.go create mode 100644 components/helm-broker/internal/broker/automock/bind_template_resolver.go create mode 100644 components/helm-broker/internal/broker/automock/bundle_storage.go create mode 100644 components/helm-broker/internal/broker/automock/chart_getter.go create mode 100644 components/helm-broker/internal/broker/automock/chart_storage.go create mode 100644 components/helm-broker/internal/broker/automock/converter.go create mode 100644 components/helm-broker/internal/broker/automock/extended.go create mode 100644 components/helm-broker/internal/broker/automock/helm_client.go create mode 100644 components/helm-broker/internal/broker/automock/instance_bind_data_getter.go create mode 100644 components/helm-broker/internal/broker/automock/instance_bind_data_inserter.go create mode 100644 components/helm-broker/internal/broker/automock/instance_bind_data_remover.go create mode 100644 components/helm-broker/internal/broker/automock/instance_state_getter.go create mode 100644 components/helm-broker/internal/broker/automock/instance_storage.go create mode 100644 components/helm-broker/internal/broker/automock/operation_storage.go create mode 100644 components/helm-broker/internal/broker/bind.go create mode 100644 components/helm-broker/internal/broker/bind_export_test.go create mode 100644 components/helm-broker/internal/broker/bind_test.go create mode 100644 components/helm-broker/internal/broker/broker.go create mode 100644 components/helm-broker/internal/broker/broker_export_test.go create mode 100644 components/helm-broker/internal/broker/catalog.go create mode 100644 components/helm-broker/internal/broker/catalog_export_test.go create mode 100644 components/helm-broker/internal/broker/catalog_test.go create mode 100644 components/helm-broker/internal/broker/ctx.go create mode 100644 components/helm-broker/internal/broker/deprovision.go create mode 100644 components/helm-broker/internal/broker/deprovision_export_test.go create mode 100644 components/helm-broker/internal/broker/deprovision_test.go create mode 100644 components/helm-broker/internal/broker/dto.go create mode 100644 components/helm-broker/internal/broker/error_test.go create mode 100644 components/helm-broker/internal/broker/export_test.go create mode 100644 components/helm-broker/internal/broker/fixture_test.go create mode 100644 components/helm-broker/internal/broker/lastop.go create mode 100644 components/helm-broker/internal/broker/middleware.go create mode 100644 components/helm-broker/internal/broker/osbapi_test.go create mode 100644 components/helm-broker/internal/broker/provision.go create mode 100644 components/helm-broker/internal/broker/provision_export_test.go create mode 100644 components/helm-broker/internal/broker/provision_test.go create mode 100644 components/helm-broker/internal/broker/server.go create mode 100644 components/helm-broker/internal/broker/state.go create mode 100644 components/helm-broker/internal/broker/state_export_test.go create mode 100644 components/helm-broker/internal/broker/state_test.go create mode 100644 components/helm-broker/internal/broker/unbind.go create mode 100644 components/helm-broker/internal/config/config.go create mode 100644 components/helm-broker/internal/doc.go create mode 100644 components/helm-broker/internal/helm/automock/helm_delete_installer.go create mode 100644 components/helm-broker/internal/helm/client.go create mode 100644 components/helm-broker/internal/helm/client_test.go create mode 100644 components/helm-broker/internal/helm/config.go create mode 100644 components/helm-broker/internal/helm/dep.go create mode 100644 components/helm-broker/internal/model.go create mode 100644 components/helm-broker/internal/model_test.go create mode 100644 components/helm-broker/internal/platform/idprovider/id_provider.go create mode 100644 components/helm-broker/internal/platform/logger/spy/formatter.go create mode 100644 components/helm-broker/internal/platform/logger/spy/logger.go create mode 100644 components/helm-broker/internal/storage/config_test.go create mode 100644 components/helm-broker/internal/storage/driver/etcd/client.go create mode 100644 components/helm-broker/internal/storage/driver/etcd/driver.go create mode 100644 components/helm-broker/internal/storage/driver/etcd/entity_bundle.go create mode 100644 components/helm-broker/internal/storage/driver/etcd/entity_chart.go create mode 100644 components/helm-broker/internal/storage/driver/etcd/entity_instance.go create mode 100644 components/helm-broker/internal/storage/driver/etcd/entity_instance_bind_data.go create mode 100644 components/helm-broker/internal/storage/driver/etcd/entity_operation.go create mode 100644 components/helm-broker/internal/storage/driver/etcd/error.go create mode 100644 components/helm-broker/internal/storage/driver/memory/driver.go create mode 100644 components/helm-broker/internal/storage/driver/memory/entity_bundle.go create mode 100644 components/helm-broker/internal/storage/driver/memory/entity_chart.go create mode 100644 components/helm-broker/internal/storage/driver/memory/entity_instance.go create mode 100644 components/helm-broker/internal/storage/driver/memory/entity_instance_bind_data.go create mode 100644 components/helm-broker/internal/storage/driver/memory/entity_operation.go create mode 100644 components/helm-broker/internal/storage/driver/memory/error.go create mode 100644 components/helm-broker/internal/storage/driver/memory/sync.go create mode 100644 components/helm-broker/internal/storage/error.go create mode 100644 components/helm-broker/internal/storage/ext.go create mode 100644 components/helm-broker/internal/storage/factory_test.go create mode 100644 components/helm-broker/internal/storage/storage.go create mode 100644 components/helm-broker/internal/storage/testdata/Config.golden.go create mode 100644 components/helm-broker/internal/storage/testdata/ConfigAllMemory.input.yaml create mode 100644 components/helm-broker/internal/storage/testdata/ex.input.yaml create mode 100644 components/helm-broker/internal/storage/testing/bundle_test.go create mode 100644 components/helm-broker/internal/storage/testing/chart_test.go create mode 100644 components/helm-broker/internal/storage/testing/common_test.go create mode 100644 components/helm-broker/internal/storage/testing/doc.go create mode 100644 components/helm-broker/internal/storage/testing/instance_bind_data_test.go create mode 100644 components/helm-broker/internal/storage/testing/instance_test.go create mode 100644 components/helm-broker/internal/storage/testing/operation_export_test.go create mode 100644 components/helm-broker/internal/storage/testing/operation_test.go create mode 100644 components/helm-broker/internal/ybind/automock/chart_go_template_renderer.go create mode 100644 components/helm-broker/internal/ybind/renderer.go create mode 100644 components/helm-broker/internal/ybind/renderer_example_test.go create mode 100644 components/helm-broker/internal/ybind/renderer_export_test.go create mode 100644 components/helm-broker/internal/ybind/renderer_test.go create mode 100644 components/helm-broker/internal/ybind/resolver.go create mode 100644 components/helm-broker/internal/ybind/resolver_example_test.go create mode 100644 components/helm-broker/internal/ybind/resolver_test.go create mode 100644 components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/.helmignore create mode 100644 components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/Chart.yaml create mode 100644 components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/README.md create mode 100644 components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/NOTES.txt create mode 100644 components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/_helpers.tpl create mode 100644 components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/deployment.yaml create mode 100644 components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/networkpolicy.yaml create mode 100644 components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/pvc.yaml create mode 100644 components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/secrets.yaml create mode 100644 components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/svc.yaml create mode 100644 components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/tests/test-redis-connection.yaml create mode 100644 components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/values.yaml create mode 100644 components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/meta.yaml create mode 100644 components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/enterprise/bind.yaml create mode 100644 components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/enterprise/create-instance-schema.json create mode 100644 components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/enterprise/meta.yaml create mode 100644 components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/enterprise/values.yaml create mode 100644 components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/micro/bind.yaml create mode 100644 components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/micro/create-instance-schema.json create mode 100644 components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/micro/meta.yaml create mode 100644 components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/micro/values.yaml create mode 100644 components/helm-broker/internal/ybind/types.go create mode 100644 components/helm-broker/internal/ybundle/automock/bundle_loader.go create mode 100644 components/helm-broker/internal/ybundle/automock/bundle_upserter.go create mode 100644 components/helm-broker/internal/ybundle/automock/chart_upserter.go create mode 100644 components/helm-broker/internal/ybundle/automock/extended.go create mode 100644 components/helm-broker/internal/ybundle/automock/repository.go create mode 100644 components/helm-broker/internal/ybundle/export_test.go create mode 100644 components/helm-broker/internal/ybundle/form.go create mode 100644 components/helm-broker/internal/ybundle/form_test.go create mode 100644 components/helm-broker/internal/ybundle/helpers_test.go create mode 100644 components/helm-broker/internal/ybundle/loader.go create mode 100644 components/helm-broker/internal/ybundle/loader_test.go create mode 100644 components/helm-broker/internal/ybundle/local_repository.go create mode 100644 components/helm-broker/internal/ybundle/populator.go create mode 100644 components/helm-broker/internal/ybundle/populator_dto_test.go create mode 100644 components/helm-broker/internal/ybundle/populator_test.go create mode 100644 components/helm-broker/internal/ybundle/repository.go create mode 100644 components/helm-broker/internal/ybundle/repository_test.go create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-big-schema.input.tgz create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-incorrect-create-schema.input.tgz create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-missing-bind-yaml-when-bindable.input.tgz create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-missing-chart-dir.input.tgz create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-missing-plan-meta-file.input.tgz create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-missing-plans.input.tgz create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-multiple-charts-in-chart-dir.input.tgz create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-no-chart-in-chart-dir.input.tgz create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/.helmignore create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/Chart.yaml create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/README.md create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/NOTES.txt create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/_helpers.tpl create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/deployment.yaml create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/networkpolicy.yaml create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/pvc.yaml create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/secrets.yaml create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/svc.yaml create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/values.yaml create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/meta.yaml create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/enterprise/bind.yaml create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/enterprise/create-instance-schema.json create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/enterprise/meta.yaml create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/enterprise/update-instance-schema.json create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/enterprise/values.yaml create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/micro/bind.yaml create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/micro/create-instance-schema.json create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/micro/meta.yaml create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/micro/update-instance-schema.json create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/micro/values.yaml create mode 100644 components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.input.tgz create mode 100644 components/helm-broker/internal/ybundle/testdata/index.input.yaml create mode 100644 components/helm-broker/pkg/doc.go create mode 100644 components/helm-broker/platform/logger/config.go create mode 100644 components/helm-broker/platform/logger/doc.go create mode 100644 components/helm-broker/platform/logger/logger.go create mode 100644 components/helm-broker/platform/logger/logger_mock.go create mode 100644 components/helm-broker/platform/logger/spy/formatter.go create mode 100644 components/helm-broker/platform/logger/spy/logger.go create mode 100644 components/helm-broker/platform/ptr/ptr.go create mode 100644 components/helm-broker/platform/time/time.go create mode 100644 components/helm-broker/tmp/.gitignore create mode 100644 components/idppreset/.gitignore create mode 100644 components/idppreset/Gopkg.lock create mode 100644 components/idppreset/Gopkg.toml create mode 100644 components/idppreset/README.md create mode 100755 components/idppreset/before-commit.sh create mode 100755 components/idppreset/hack/boilerplate.go.txt create mode 100755 components/idppreset/hack/generate-groups.sh create mode 100755 components/idppreset/hack/update-codegen.sh create mode 100644 components/idppreset/pkg/apis/ui/v1alpha1/doc.go create mode 100644 components/idppreset/pkg/apis/ui/v1alpha1/register.go create mode 100644 components/idppreset/pkg/apis/ui/v1alpha1/types.go create mode 100644 components/idppreset/pkg/apis/ui/v1alpha1/zz_generated.deepcopy.go create mode 100644 components/idppreset/pkg/client/clientset/versioned/clientset.go create mode 100644 components/idppreset/pkg/client/clientset/versioned/doc.go create mode 100644 components/idppreset/pkg/client/clientset/versioned/fake/clientset_generated.go create mode 100644 components/idppreset/pkg/client/clientset/versioned/fake/doc.go create mode 100644 components/idppreset/pkg/client/clientset/versioned/fake/register.go create mode 100644 components/idppreset/pkg/client/clientset/versioned/scheme/doc.go create mode 100644 components/idppreset/pkg/client/clientset/versioned/scheme/register.go create mode 100644 components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/doc.go create mode 100644 components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/fake/doc.go create mode 100644 components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/fake/fake_idppreset.go create mode 100644 components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/fake/fake_ui_client.go create mode 100644 components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/generated_expansion.go create mode 100644 components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/idppreset.go create mode 100644 components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/ui_client.go create mode 100644 components/idppreset/pkg/client/informers/externalversions/factory.go create mode 100644 components/idppreset/pkg/client/informers/externalversions/generic.go create mode 100644 components/idppreset/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go create mode 100644 components/idppreset/pkg/client/informers/externalversions/ui/interface.go create mode 100644 components/idppreset/pkg/client/informers/externalversions/ui/v1alpha1/idppreset.go create mode 100644 components/idppreset/pkg/client/informers/externalversions/ui/v1alpha1/interface.go create mode 100644 components/idppreset/pkg/client/listers/ui/v1alpha1/expansion_generated.go create mode 100644 components/idppreset/pkg/client/listers/ui/v1alpha1/idppreset.go create mode 100644 components/installer/.gitignore create mode 100644 components/installer/Gopkg.lock create mode 100644 components/installer/Gopkg.toml create mode 100644 components/installer/Jenkinsfile create mode 100644 components/installer/README.md create mode 100755 components/installer/before-commit.sh create mode 100644 components/installer/cmd/operator/main.go create mode 100644 components/installer/hack/custom-boilerplate.go.txt create mode 100755 components/installer/hack/update-codegen.sh create mode 100644 components/installer/pkg/actionmanager/action-manager.go create mode 100644 components/installer/pkg/apis/release/v1alpha1/doc.go create mode 100644 components/installer/pkg/apis/release/v1alpha1/register.go create mode 100644 components/installer/pkg/apis/release/v1alpha1/types.go create mode 100644 components/installer/pkg/apis/release/v1alpha1/zz_generated.deepcopy.go create mode 100644 components/installer/pkg/azure-vault/client.go create mode 100644 components/installer/pkg/azure-vault/secret-provider.go create mode 100644 components/installer/pkg/azure-vault/secret-provider_test.go create mode 100644 components/installer/pkg/client/clientset/versioned/clientset.go create mode 100644 components/installer/pkg/client/clientset/versioned/doc.go create mode 100644 components/installer/pkg/client/clientset/versioned/fake/clientset_generated.go create mode 100644 components/installer/pkg/client/clientset/versioned/fake/doc.go create mode 100644 components/installer/pkg/client/clientset/versioned/fake/register.go create mode 100644 components/installer/pkg/client/clientset/versioned/scheme/doc.go create mode 100644 components/installer/pkg/client/clientset/versioned/scheme/register.go create mode 100644 components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/doc.go create mode 100644 components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/fake/doc.go create mode 100644 components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/fake/fake_release.go create mode 100644 components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/fake/fake_release_client.go create mode 100644 components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/generated_expansion.go create mode 100644 components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/release.go create mode 100644 components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/release_client.go create mode 100644 components/installer/pkg/client/informers/externalversions/factory.go create mode 100644 components/installer/pkg/client/informers/externalversions/generic.go create mode 100644 components/installer/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go create mode 100644 components/installer/pkg/client/informers/externalversions/release/interface.go create mode 100644 components/installer/pkg/client/informers/externalversions/release/v1alpha1/interface.go create mode 100644 components/installer/pkg/client/informers/externalversions/release/v1alpha1/release.go create mode 100644 components/installer/pkg/client/listers/release/v1alpha1/expansion_generated.go create mode 100644 components/installer/pkg/client/listers/release/v1alpha1/release.go create mode 100644 components/installer/pkg/conditionmanager/condition-manager.go create mode 100644 components/installer/pkg/config/environment.go create mode 100644 components/installer/pkg/config/installation-data.go create mode 100644 components/installer/pkg/consts/const.go create mode 100644 components/installer/pkg/errors/errors.go create mode 100644 components/installer/pkg/finalizer/manager.go create mode 100644 components/installer/pkg/finalizer/manager_test.go create mode 100644 components/installer/pkg/installation/controller.go create mode 100644 components/installer/pkg/kymahelm/ysf-helm-client.go create mode 100644 components/installer/pkg/overrides/azure-broker.go create mode 100644 components/installer/pkg/overrides/azure-broker_test.go create mode 100644 components/installer/pkg/overrides/core.go create mode 100644 components/installer/pkg/overrides/core_test.go create mode 100644 components/installer/pkg/overrides/global.go create mode 100644 components/installer/pkg/overrides/global_test.go create mode 100644 components/installer/pkg/overrides/istio.go create mode 100644 components/installer/pkg/overrides/istio_test.go create mode 100644 components/installer/pkg/overrides/remote-environment.go create mode 100644 components/installer/pkg/overrides/static-file.go create mode 100644 components/installer/pkg/release/controller.go create mode 100644 components/installer/pkg/servicecatalog/client.go create mode 100644 components/installer/pkg/servicecatalog/wrapper.go create mode 100644 components/installer/pkg/statusmanager/status-manager.go create mode 100644 components/installer/pkg/statusmanager/status-manager_test.go create mode 100644 components/installer/pkg/steps/azure-deprovisioner.go create mode 100644 components/installer/pkg/steps/azure-deprovisioner_test.go create mode 100644 components/installer/pkg/steps/cluster-essentials.go create mode 100644 components/installer/pkg/steps/cluster-essentials_test.go create mode 100644 components/installer/pkg/steps/cluster-prerequisites.go create mode 100644 components/installer/pkg/steps/cluster-prerequisites_test.go create mode 100644 components/installer/pkg/steps/core.go create mode 100644 components/installer/pkg/steps/core_test.go create mode 100644 components/installer/pkg/steps/dex.go create mode 100644 components/installer/pkg/steps/dex_test.go create mode 100644 components/installer/pkg/steps/doc.go create mode 100644 components/installer/pkg/steps/download-kyma.go create mode 100644 components/installer/pkg/steps/download-kyma_test.go create mode 100644 components/installer/pkg/steps/installation.go create mode 100644 components/installer/pkg/steps/istio.go create mode 100644 components/installer/pkg/steps/istio_test.go create mode 100644 components/installer/pkg/steps/kyma-package.go create mode 100644 components/installer/pkg/steps/mock.go create mode 100644 components/installer/pkg/steps/prometheus.go create mode 100644 components/installer/pkg/steps/prometheus_test.go create mode 100644 components/installer/pkg/steps/provision-bundles.go create mode 100644 components/installer/pkg/steps/remote-environment.go create mode 100644 components/installer/pkg/steps/remove-kyma-components.go create mode 100644 components/installer/pkg/steps/remove-kyma-sources.go create mode 100644 components/installer/pkg/steps/tiller.go create mode 100644 components/installer/pkg/steps/tiller_test.go create mode 100644 components/installer/pkg/toolkit/command-script-exec.go create mode 100644 components/installer/pkg/toolkit/installdata-creator.go create mode 100755 components/installer/scripts/build.sh create mode 100755 components/installer/scripts/run.sh create mode 100644 components/istio-webhook/.dockerignore create mode 100644 components/istio-webhook/Dockerfile create mode 100644 components/istio-webhook/Jenkinsfile create mode 100644 components/istio-webhook/README.md create mode 100644 components/istio-webhook/app.py create mode 100644 components/remote-environment-broker/.gitignore create mode 100644 components/remote-environment-broker/Gopkg.lock create mode 100644 components/remote-environment-broker/Gopkg.toml create mode 100644 components/remote-environment-broker/Jenkinsfile create mode 100755 components/remote-environment-broker/README.md create mode 100755 components/remote-environment-broker/before-commit.sh create mode 100644 components/remote-environment-broker/cmd/broker/main.go create mode 100644 components/remote-environment-broker/cmd/poc-events/events.go create mode 100644 components/remote-environment-broker/cmd/poc-events/findings.md create mode 100644 components/remote-environment-broker/cmd/poc-finalizers/controller.go create mode 100644 components/remote-environment-broker/cmd/poc-finalizers/finalizers-findings.md create mode 100644 components/remote-environment-broker/cmd/poc-finalizers/main.go create mode 100644 components/remote-environment-broker/cmd/poc-finalizers/mapping-prod.yaml create mode 100644 components/remote-environment-broker/cmd/poc-finalizers/remote-environment-prod.yaml create mode 100644 components/remote-environment-broker/contrib/examples/binding.yaml create mode 100644 components/remote-environment-broker/contrib/examples/event-activation.yaml create mode 100644 components/remote-environment-broker/contrib/examples/mapping.yaml create mode 100644 components/remote-environment-broker/contrib/examples/osb-catalog-response.json create mode 100644 components/remote-environment-broker/contrib/examples/remote-env.yaml create mode 100644 components/remote-environment-broker/contrib/examples/service-instance.yaml create mode 100644 components/remote-environment-broker/deploy/broker/Dockerfile create mode 100644 components/remote-environment-broker/hack/boilerplate.go.txt create mode 100755 components/remote-environment-broker/hack/generate-groups.sh create mode 100755 components/remote-environment-broker/hack/update-codegen.sh create mode 100644 components/remote-environment-broker/internal/access/access.go create mode 100644 components/remote-environment-broker/internal/access/automock/provision_checker.go create mode 100644 components/remote-environment-broker/internal/access/automock/remote_environment_finder.go create mode 100644 components/remote-environment-broker/internal/access/provision_mapping.go create mode 100644 components/remote-environment-broker/internal/access/provision_mapping_test.go create mode 100644 components/remote-environment-broker/internal/access/provision_unique.go create mode 100644 components/remote-environment-broker/internal/access/provision_unique_test.go create mode 100644 components/remote-environment-broker/internal/broker/automock/instance_getter.go create mode 100644 components/remote-environment-broker/internal/broker/automock/instance_state_getter.go create mode 100644 components/remote-environment-broker/internal/broker/automock/instance_storage.go create mode 100644 components/remote-environment-broker/internal/broker/automock/operation_storage.go create mode 100644 components/remote-environment-broker/internal/broker/automock/re_finder.go create mode 100644 components/remote-environment-broker/internal/broker/automock/service_instance_getter.go create mode 100644 components/remote-environment-broker/internal/broker/bind.go create mode 100644 components/remote-environment-broker/internal/broker/bind_export_test.go create mode 100644 components/remote-environment-broker/internal/broker/bind_test.go create mode 100644 components/remote-environment-broker/internal/broker/broker.go create mode 100644 components/remote-environment-broker/internal/broker/catalog.go create mode 100644 components/remote-environment-broker/internal/broker/catalog_export_test.go create mode 100644 components/remote-environment-broker/internal/broker/catalog_test.go create mode 100644 components/remote-environment-broker/internal/broker/ctx.go create mode 100644 components/remote-environment-broker/internal/broker/deprovision.go create mode 100644 components/remote-environment-broker/internal/broker/deprovision_test.go create mode 100644 components/remote-environment-broker/internal/broker/dto.go create mode 100644 components/remote-environment-broker/internal/broker/error.go create mode 100644 components/remote-environment-broker/internal/broker/error_test.go create mode 100644 components/remote-environment-broker/internal/broker/export_test.go create mode 100644 components/remote-environment-broker/internal/broker/fix_test.go create mode 100644 components/remote-environment-broker/internal/broker/fixture_test.go create mode 100644 components/remote-environment-broker/internal/broker/lastop.go create mode 100644 components/remote-environment-broker/internal/broker/middleware.go create mode 100644 components/remote-environment-broker/internal/broker/provision.go create mode 100644 components/remote-environment-broker/internal/broker/provision_test.go create mode 100644 components/remote-environment-broker/internal/broker/server.go create mode 100644 components/remote-environment-broker/internal/broker/service_instance.go create mode 100644 components/remote-environment-broker/internal/broker/service_instance_test.go create mode 100644 components/remote-environment-broker/internal/broker/state.go create mode 100644 components/remote-environment-broker/internal/broker/state_export_test.go create mode 100644 components/remote-environment-broker/internal/broker/state_test.go create mode 100644 components/remote-environment-broker/internal/config/config.go create mode 100644 components/remote-environment-broker/internal/doc.go create mode 100644 components/remote-environment-broker/internal/labeler/automock/ns_patcher.go create mode 100644 components/remote-environment-broker/internal/labeler/automock/re_getter.go create mode 100644 components/remote-environment-broker/internal/labeler/controller.go create mode 100644 components/remote-environment-broker/internal/labeler/controller_export_test.go create mode 100644 components/remote-environment-broker/internal/labeler/controller_test.go create mode 100644 components/remote-environment-broker/internal/model.go create mode 100644 components/remote-environment-broker/internal/storage/config_test.go create mode 100644 components/remote-environment-broker/internal/storage/driver/memory/driver.go create mode 100644 components/remote-environment-broker/internal/storage/driver/memory/entity_instance.go create mode 100644 components/remote-environment-broker/internal/storage/driver/memory/entity_operation.go create mode 100644 components/remote-environment-broker/internal/storage/driver/memory/entity_remoteenvironment.go create mode 100644 components/remote-environment-broker/internal/storage/driver/memory/error.go create mode 100644 components/remote-environment-broker/internal/storage/driver/memory/sync.go create mode 100644 components/remote-environment-broker/internal/storage/ext.go create mode 100644 components/remote-environment-broker/internal/storage/factory_test.go create mode 100644 components/remote-environment-broker/internal/storage/populator/automock/instance_inserter.go create mode 100644 components/remote-environment-broker/internal/storage/populator/instance.go create mode 100644 components/remote-environment-broker/internal/storage/populator/instance_test.go create mode 100644 components/remote-environment-broker/internal/storage/storage.go create mode 100644 components/remote-environment-broker/internal/storage/testdata/Config.golden.go create mode 100644 components/remote-environment-broker/internal/storage/testdata/ConfigAllMemory.input.yaml create mode 100644 components/remote-environment-broker/internal/storage/testing/common_test.go create mode 100644 components/remote-environment-broker/internal/storage/testing/doc.go create mode 100644 components/remote-environment-broker/internal/storage/testing/instance_test.go create mode 100644 components/remote-environment-broker/internal/storage/testing/remoteenvironment_test.go create mode 100644 components/remote-environment-broker/internal/syncer/automock/extended.go create mode 100644 components/remote-environment-broker/internal/syncer/automock/remote_environment_cr_mapper.go create mode 100644 components/remote-environment-broker/internal/syncer/automock/remote_environment_cr_validator.go create mode 100644 components/remote-environment-broker/internal/syncer/automock/remote_environment_remover.go create mode 100644 components/remote-environment-broker/internal/syncer/automock/remote_environment_upserter.go create mode 100644 components/remote-environment-broker/internal/syncer/automock/sc_relist_requester.go create mode 100644 components/remote-environment-broker/internal/syncer/automock/service_catalog_syncer.go create mode 100644 components/remote-environment-broker/internal/syncer/controller.go create mode 100644 components/remote-environment-broker/internal/syncer/controller_export_test.go create mode 100644 components/remote-environment-broker/internal/syncer/controller_test.go create mode 100644 components/remote-environment-broker/internal/syncer/mapper.go create mode 100644 components/remote-environment-broker/internal/syncer/mapper_test.go create mode 100644 components/remote-environment-broker/internal/syncer/sc_relist_requester.go create mode 100644 components/remote-environment-broker/internal/syncer/sc_relist_requester_export_test.go create mode 100644 components/remote-environment-broker/internal/syncer/sc_relist_requester_test.go create mode 100644 components/remote-environment-broker/internal/syncer/testdata/re-CR-valid.input.yaml create mode 100644 components/remote-environment-broker/internal/syncer/validator.go create mode 100644 components/remote-environment-broker/internal/syncer/validator_test.go create mode 100644 components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1/doc.go create mode 100644 components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1/register.go create mode 100644 components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1/types.go create mode 100644 components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1/zz_generated.deepcopy.go create mode 100644 components/remote-environment-broker/pkg/client/clientset/versioned/clientset.go create mode 100644 components/remote-environment-broker/pkg/client/clientset/versioned/doc.go create mode 100644 components/remote-environment-broker/pkg/client/clientset/versioned/fake/clientset_generated.go create mode 100644 components/remote-environment-broker/pkg/client/clientset/versioned/fake/doc.go create mode 100644 components/remote-environment-broker/pkg/client/clientset/versioned/fake/register.go create mode 100644 components/remote-environment-broker/pkg/client/clientset/versioned/scheme/doc.go create mode 100644 components/remote-environment-broker/pkg/client/clientset/versioned/scheme/register.go create mode 100644 components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/doc.go create mode 100644 components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/environmentmapping.go create mode 100644 components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/eventactivation.go create mode 100644 components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake/doc.go create mode 100644 components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake/fake_environmentmapping.go create mode 100644 components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake/fake_eventactivation.go create mode 100644 components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake/fake_remoteenvironment.go create mode 100644 components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake/fake_remoteenvironment_client.go create mode 100644 components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/generated_expansion.go create mode 100644 components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/remoteenvironment.go create mode 100644 components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/remoteenvironment_client.go create mode 100644 components/remote-environment-broker/pkg/client/informers/externalversions/factory.go create mode 100644 components/remote-environment-broker/pkg/client/informers/externalversions/generic.go create mode 100644 components/remote-environment-broker/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go create mode 100644 components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/interface.go create mode 100644 components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/v1alpha1/environmentmapping.go create mode 100644 components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/v1alpha1/eventactivation.go create mode 100644 components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/v1alpha1/interface.go create mode 100644 components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/v1alpha1/remoteenvironment.go create mode 100644 components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1/environmentmapping.go create mode 100644 components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1/eventactivation.go create mode 100644 components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1/expansion_generated.go create mode 100644 components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1/remoteenvironment.go create mode 100644 components/remote-environment-broker/platform/idprovider/id_provider.go create mode 100644 components/remote-environment-broker/platform/logger/config.go create mode 100644 components/remote-environment-broker/platform/logger/doc.go create mode 100644 components/remote-environment-broker/platform/logger/logger.go create mode 100644 components/remote-environment-broker/platform/logger/logger_mock.go create mode 100644 components/remote-environment-broker/platform/logger/spy/formatter.go create mode 100644 components/remote-environment-broker/platform/logger/spy/logger.go create mode 100644 components/remote-environment-broker/platform/time/time.go create mode 100644 components/ui-api-layer/.gitignore create mode 100644 components/ui-api-layer/CONTRIBUTING.md create mode 100644 components/ui-api-layer/Dockerfile create mode 100644 components/ui-api-layer/Gopkg.lock create mode 100644 components/ui-api-layer/Gopkg.toml create mode 100644 components/ui-api-layer/Jenkinsfile create mode 100644 components/ui-api-layer/LICENSE create mode 100644 components/ui-api-layer/README.md create mode 100755 components/ui-api-layer/before-commit.sh create mode 100755 components/ui-api-layer/codegen.sh create mode 100644 components/ui-api-layer/contrib/examples/service-catalog-test-data.yaml create mode 100644 components/ui-api-layer/docs/README.md create mode 100644 components/ui-api-layer/docs/architecture.md create mode 100644 components/ui-api-layer/docs/assets/ui-api-layer-architecture.html create mode 100644 components/ui-api-layer/docs/assets/ui-api-layer-architecture.png create mode 100644 components/ui-api-layer/docs/configuration.md create mode 100644 components/ui-api-layer/docs/project-structure.md create mode 100644 components/ui-api-layer/docs/terminology.md create mode 100644 components/ui-api-layer/internal/domain/apicontroller/api_converter.go create mode 100644 components/ui-api-layer/internal/domain/apicontroller/api_converter_test.go create mode 100644 components/ui-api-layer/internal/domain/apicontroller/api_resolver.go create mode 100644 components/ui-api-layer/internal/domain/apicontroller/api_resolver_test.go create mode 100644 components/ui-api-layer/internal/domain/apicontroller/api_service.go create mode 100644 components/ui-api-layer/internal/domain/apicontroller/api_service_test.go create mode 100644 components/ui-api-layer/internal/domain/apicontroller/apicontroller.go create mode 100644 components/ui-api-layer/internal/domain/apicontroller/automock/api_lister.go create mode 100644 components/ui-api-layer/internal/domain/apicontroller/automock/export.go create mode 100644 components/ui-api-layer/internal/domain/apicontroller/interfaces.go create mode 100644 components/ui-api-layer/internal/domain/content/apispec_service.go create mode 100644 components/ui-api-layer/internal/domain/content/apispec_service_test.go create mode 100644 components/ui-api-layer/internal/domain/content/asyncapispec_service.go create mode 100644 components/ui-api-layer/internal/domain/content/asyncapispec_service_test.go create mode 100644 components/ui-api-layer/internal/domain/content/automock/content_getter.go create mode 100644 components/ui-api-layer/internal/domain/content/automock/export.go create mode 100644 components/ui-api-layer/internal/domain/content/automock/minio_api_spec_getter.go create mode 100644 components/ui-api-layer/internal/domain/content/automock/minio_async_api_spec_getter.go create mode 100644 components/ui-api-layer/internal/domain/content/automock/minio_content_getter.go create mode 100644 components/ui-api-layer/internal/domain/content/automock/topics_converter_interface.go create mode 100644 components/ui-api-layer/internal/domain/content/content.go create mode 100644 components/ui-api-layer/internal/domain/content/content_converter.go create mode 100644 components/ui-api-layer/internal/domain/content/content_converter_test.go create mode 100644 components/ui-api-layer/internal/domain/content/content_resolver.go create mode 100644 components/ui-api-layer/internal/domain/content/content_resolver_test.go create mode 100644 components/ui-api-layer/internal/domain/content/content_service.go create mode 100644 components/ui-api-layer/internal/domain/content/content_service_test.go create mode 100644 components/ui-api-layer/internal/domain/content/export_test.go create mode 100644 components/ui-api-layer/internal/domain/content/interfaces.go create mode 100644 components/ui-api-layer/internal/domain/content/storage/automock/cache.go create mode 100644 components/ui-api-layer/internal/domain/content/storage/automock/minio.go create mode 100644 components/ui-api-layer/internal/domain/content/storage/cache.go create mode 100644 components/ui-api-layer/internal/domain/content/storage/cache_test.go create mode 100644 components/ui-api-layer/internal/domain/content/storage/export_test.go create mode 100644 components/ui-api-layer/internal/domain/content/storage/interfaces.go create mode 100644 components/ui-api-layer/internal/domain/content/storage/minioclient.go create mode 100644 components/ui-api-layer/internal/domain/content/storage/minioclient_test.go create mode 100644 components/ui-api-layer/internal/domain/content/storage/mock_client.go create mode 100644 components/ui-api-layer/internal/domain/content/storage/mock_store_getter.go create mode 100644 components/ui-api-layer/internal/domain/content/storage/storage.go create mode 100644 components/ui-api-layer/internal/domain/content/storage/store.go create mode 100644 components/ui-api-layer/internal/domain/content/storage/store_test.go create mode 100644 components/ui-api-layer/internal/domain/content/storage/types.go create mode 100644 components/ui-api-layer/internal/domain/content/topics_converter.go create mode 100644 components/ui-api-layer/internal/domain/content/topics_converter_test.go create mode 100644 components/ui-api-layer/internal/domain/content/topics_resolver.go create mode 100644 components/ui-api-layer/internal/domain/content/topics_resolver_test.go create mode 100644 components/ui-api-layer/internal/domain/k8s/automock/deployment_lister.go create mode 100644 components/ui-api-layer/internal/domain/k8s/automock/env_lister.go create mode 100644 components/ui-api-layer/internal/domain/k8s/automock/expect.go create mode 100644 components/ui-api-layer/internal/domain/k8s/automock/export.go create mode 100644 components/ui-api-layer/internal/domain/k8s/automock/limit_range_lister.go create mode 100644 components/ui-api-layer/internal/domain/k8s/automock/resource_quota_lister.go create mode 100644 components/ui-api-layer/internal/domain/k8s/automock/service_binding_getter.go create mode 100644 components/ui-api-layer/internal/domain/k8s/automock/service_binding_usage_lister.go create mode 100644 components/ui-api-layer/internal/domain/k8s/deployment_converter.go create mode 100644 components/ui-api-layer/internal/domain/k8s/deployment_converter_test.go create mode 100644 components/ui-api-layer/internal/domain/k8s/deployment_resolver.go create mode 100644 components/ui-api-layer/internal/domain/k8s/deployment_resolver_test.go create mode 100644 components/ui-api-layer/internal/domain/k8s/deployment_service.go create mode 100644 components/ui-api-layer/internal/domain/k8s/deployment_service_test.go create mode 100644 components/ui-api-layer/internal/domain/k8s/environment_resolver.go create mode 100644 components/ui-api-layer/internal/domain/k8s/environment_service.go create mode 100644 components/ui-api-layer/internal/domain/k8s/export_test.go create mode 100644 components/ui-api-layer/internal/domain/k8s/interfaces.go create mode 100644 components/ui-api-layer/internal/domain/k8s/k8s.go create mode 100644 components/ui-api-layer/internal/domain/k8s/limitrange_converter.go create mode 100644 components/ui-api-layer/internal/domain/k8s/limitrange_converter_test.go create mode 100644 components/ui-api-layer/internal/domain/k8s/limitrange_resolver.go create mode 100644 components/ui-api-layer/internal/domain/k8s/limitrange_resolver_test.go create mode 100644 components/ui-api-layer/internal/domain/k8s/limitrange_service.go create mode 100644 components/ui-api-layer/internal/domain/k8s/limitrange_service_test.go create mode 100644 components/ui-api-layer/internal/domain/k8s/resourcequota_converter.go create mode 100644 components/ui-api-layer/internal/domain/k8s/resourcequota_converter_test.go create mode 100644 components/ui-api-layer/internal/domain/k8s/resourcequota_resolver.go create mode 100644 components/ui-api-layer/internal/domain/k8s/resourcequota_resolver_test.go create mode 100644 components/ui-api-layer/internal/domain/k8s/resourcequota_service.go create mode 100644 components/ui-api-layer/internal/domain/k8s/resourcequota_service_test.go create mode 100644 components/ui-api-layer/internal/domain/k8s/secret_converter.go create mode 100644 components/ui-api-layer/internal/domain/k8s/secret_converter_test.go create mode 100644 components/ui-api-layer/internal/domain/k8s/secret_resolver.go create mode 100644 components/ui-api-layer/internal/domain/k8s/secret_resolver_test.go create mode 100644 components/ui-api-layer/internal/domain/kubeless/automock/export.go create mode 100644 components/ui-api-layer/internal/domain/kubeless/automock/function_lister.go create mode 100644 components/ui-api-layer/internal/domain/kubeless/export_test.go create mode 100644 components/ui-api-layer/internal/domain/kubeless/function_converter.go create mode 100644 components/ui-api-layer/internal/domain/kubeless/function_converter_test.go create mode 100644 components/ui-api-layer/internal/domain/kubeless/function_resolver.go create mode 100644 components/ui-api-layer/internal/domain/kubeless/function_resolver_test.go create mode 100644 components/ui-api-layer/internal/domain/kubeless/function_service.go create mode 100644 components/ui-api-layer/internal/domain/kubeless/function_service_test.go create mode 100644 components/ui-api-layer/internal/domain/kubeless/interfaces.go create mode 100644 components/ui-api-layer/internal/domain/kubeless/kubeless.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/automock/async_api_spec_getter.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/automock/event_activation_lister.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/automock/export.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/automock/re_svc.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/automock/status_getter.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_converter.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_converter_test.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_resolver.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_resolver_test.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_service.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_service_test.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/export_test.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/gateway/automock/expect.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/gateway/automock/export.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/gateway/automock/gateway_service_lister.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/gateway/config.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/gateway/export_test.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/gateway/provider.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/gateway/provider_test.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/gateway/service.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/gateway/status_watcher.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/gateway/status_watcher_test.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/interfaces.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/re.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/re_converter.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/re_converter_test.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/re_resolver.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/re_resolver_test.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/re_service.go create mode 100644 components/ui-api-layer/internal/domain/remoteenvironment/re_service_test.go create mode 100644 components/ui-api-layer/internal/domain/root_resolver.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/automock/api_spec_getter.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/automock/async_api_spec_getter.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/automock/broker_getter.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/automock/broker_list_getter.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/automock/broker_lister.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/automock/class_getter.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/automock/class_instance_lister.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/automock/class_list_getter.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/automock/content_getter.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/automock/export.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/automock/gql_broker_converter.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/automock/gql_class_converter.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/automock/gql_plan_converter.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/automock/instance_getter.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/automock/instance_lister.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/automock/plan_getter.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/automock/plan_lister.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/automock/service_binding_operations.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/automock/service_binding_usage_operations.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/automock/status_binding_usage_extractor.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/binding_converter.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/binding_converter_test.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/binding_resolver.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/binding_resolver_test.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/binding_service.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/binding_service_test.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/binding_usage_converter.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/binding_usage_converter_test.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/binding_usage_resolver.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/binding_usage_resolver_test.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/binding_usage_service.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/binding_usage_service_test.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/broker_converter.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/broker_converter_test.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/broker_resolver.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/broker_service.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/broker_service_test.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/class_converter.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/class_converter_test.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/class_resolver.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/class_resolver_test.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/class_service.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/class_service_test.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/export_test.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/instance_converter.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/instance_converter_test.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/instance_listener.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/instance_listener_test.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/instance_resolver.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/instance_resolver_test.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/instance_service.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/instance_service_test.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/interfaces.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/mock_gql_instance_converter.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/mock_instance_svc.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/plan_converter.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/plan_converter_test.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/plan_service.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/plan_service_test.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/servicecatalog.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/status/binding.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/status/binding_test.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/status/binding_usage.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/status/binding_usage_test.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/status/instance.go create mode 100644 components/ui-api-layer/internal/domain/servicecatalog/status/instance_test.go create mode 100644 components/ui-api-layer/internal/domain/ui/automock/export.go create mode 100644 components/ui-api-layer/internal/domain/ui/automock/gql_idp_preset_converter.go create mode 100644 components/ui-api-layer/internal/domain/ui/automock/idp_preset_svc.go create mode 100644 components/ui-api-layer/internal/domain/ui/export_test.go create mode 100644 components/ui-api-layer/internal/domain/ui/helpers_test.go create mode 100644 components/ui-api-layer/internal/domain/ui/idppreset_converter.go create mode 100644 components/ui-api-layer/internal/domain/ui/idppreset_converter_test.go create mode 100644 components/ui-api-layer/internal/domain/ui/idppreset_resolver.go create mode 100644 components/ui-api-layer/internal/domain/ui/idppreset_resolver_test.go create mode 100644 components/ui-api-layer/internal/domain/ui/idppreset_service.go create mode 100644 components/ui-api-layer/internal/domain/ui/idppreset_service_test.go create mode 100644 components/ui-api-layer/internal/domain/ui/ui.go create mode 100644 components/ui-api-layer/internal/gqlschema/Section.go create mode 100644 components/ui-api-layer/internal/gqlschema/Title.go create mode 100644 components/ui-api-layer/internal/gqlschema/TopicEntry.go create mode 100644 components/ui-api-layer/internal/gqlschema/api.go create mode 100644 components/ui-api-layer/internal/gqlschema/deployment.go create mode 100644 components/ui-api-layer/internal/gqlschema/deploymentstatus.go create mode 100644 components/ui-api-layer/internal/gqlschema/eventactivation.go create mode 100644 components/ui-api-layer/internal/gqlschema/json.go create mode 100644 components/ui-api-layer/internal/gqlschema/limitrange.go create mode 100644 components/ui-api-layer/internal/gqlschema/models_gen.go create mode 100644 components/ui-api-layer/internal/gqlschema/remoteenvironment.go create mode 100644 components/ui-api-layer/internal/gqlschema/remoteenvironmentservice.go create mode 100644 components/ui-api-layer/internal/gqlschema/resourcequota.go create mode 100644 components/ui-api-layer/internal/gqlschema/schema.graphql create mode 100644 components/ui-api-layer/internal/gqlschema/schema_gen.go create mode 100644 components/ui-api-layer/internal/gqlschema/servicebinding.go create mode 100644 components/ui-api-layer/internal/gqlschema/servicebindingusage.go create mode 100644 components/ui-api-layer/internal/gqlschema/servicebroker.go create mode 100644 components/ui-api-layer/internal/gqlschema/serviceclass.go create mode 100644 components/ui-api-layer/internal/gqlschema/serviceinstance.go create mode 100644 components/ui-api-layer/internal/gqlschema/serviceinstanceevent.go create mode 100644 components/ui-api-layer/internal/gqlschema/timestamp.go create mode 100644 components/ui-api-layer/internal/gqlschema/types.json create mode 100644 components/ui-api-layer/internal/pager/automock/pageable_indexer.go create mode 100644 components/ui-api-layer/internal/pager/automock/pageable_store.go create mode 100644 components/ui-api-layer/internal/pager/indexpager.go create mode 100644 components/ui-api-layer/internal/pager/indexpager_test.go create mode 100644 components/ui-api-layer/internal/pager/pager.go create mode 100644 components/ui-api-layer/internal/pager/pager_test.go create mode 100644 components/ui-api-layer/internal/resource/extractdata.go create mode 100644 components/ui-api-layer/internal/testing/waitforinformer.go create mode 100644 components/ui-api-layer/main.go create mode 100644 components/ui-api-layer/pkg/executor/executor.go create mode 100644 components/ui-api-layer/pkg/executor/executor_test.go create mode 100644 components/ui-api-layer/pkg/iosafety/iosafety.go create mode 100644 components/ui-api-layer/pkg/jsoncopy/jsoncopy.go create mode 100644 components/ui-api-layer/pkg/resource/automock/listener.go create mode 100644 components/ui-api-layer/pkg/resource/notifier.go create mode 100644 components/ui-api-layer/pkg/resource/notifier_test.go create mode 100644 components/ui-api-layer/pkg/signal/signal.go create mode 100644 docs/Jenkinsfile create mode 100644 docs/LICENSE create mode 100644 docs/README.md create mode 100644 docs/api-gateway/assets/001-service-exposure-flow.html create mode 100644 docs/api-gateway/assets/001-service-exposure-flow.png create mode 100644 docs/api-gateway/docs.config.json create mode 100644 docs/api-gateway/docs/001-overview.md create mode 100644 docs/api-gateway/docs/005-architecture.md create mode 100644 docs/api-gateway/docs/008-security.md create mode 100644 docs/api-gateway/docs/011-CRD.md create mode 100644 docs/application-connector/docs.config.json create mode 100644 docs/application-connector/docs/001-overview-application-connector.md create mode 100644 docs/application-connector/docs/005-architecture-application-connector.md create mode 100644 docs/application-connector/docs/006-architecture-ingress-gateway.md create mode 100644 docs/application-connector/docs/010-details-ac-deployment.md create mode 100644 docs/application-connector/docs/011-details-ac-security.md create mode 100644 docs/application-connector/docs/012-details-serviceclass-documentation.md create mode 100644 docs/application-connector/docs/013-details-api.md create mode 100644 docs/application-connector/docs/014-details-remote-environment.md create mode 100644 docs/application-connector/docs/015-details-one-click-configuration.md create mode 100644 docs/application-connector/docs/016-details-passing-header-with-access-token.md create mode 100644 docs/application-connector/docs/030-examples-ac.md create mode 100644 docs/application-connector/docs/assets/001-application-connector.html create mode 100644 docs/application-connector/docs/assets/001-application-connector.png create mode 100644 docs/application-connector/docs/assets/002-automatic-configuration.html create mode 100644 docs/application-connector/docs/assets/002-automatic-configuration.png create mode 100644 docs/application-connector/docs/assets/externalapi.yaml create mode 100644 docs/application-connector/docs/assets/remote-environment-prod.yaml create mode 100644 docs/application-connector/docs/assets/remote-environments.zip create mode 100644 docs/assets/crd/mapping-prod.yaml create mode 100644 docs/assets/rebase.gif create mode 100644 docs/assets/squash.gif create mode 100644 docs/authorization-and-authentication/docs.config.json create mode 100644 docs/authorization-and-authentication/docs/001-overview.md create mode 100644 docs/authorization-and-authentication/docs/003-architecture.md create mode 100644 docs/authorization-and-authentication/docs/005-details-add-connector.md create mode 100644 docs/authorization-and-authentication/docs/assets/001-kyma-authorization.html create mode 100644 docs/authorization-and-authentication/docs/assets/001-kyma-authorization.png create mode 100644 docs/event-bus/docs.config.json create mode 100644 docs/event-bus/docs/001-overview-event-bus.md create mode 100644 docs/event-bus/docs/010-details-concepts.md create mode 100644 docs/event-bus/docs/011-details-event-flow-requirements.md create mode 100644 docs/event-bus/docs/012-details-troubleshooting.md create mode 100644 docs/event-bus/docs/020-architecture-event-bus.md create mode 100644 docs/event-bus/docs/030-cli-reference.md create mode 100644 docs/event-bus/docs/assets/event-activation.html create mode 100644 docs/event-bus/docs/assets/event-activation.png create mode 100644 docs/event-bus/docs/assets/event-bus-architecture.html create mode 100644 docs/event-bus/docs/assets/event-bus-architecture.png create mode 100755 docs/event-bus/docs/assets/event-validation.html create mode 100755 docs/event-bus/docs/assets/event-validation.png create mode 100644 docs/kyma/docs.config.json create mode 100644 docs/kyma/docs/001-overview.md create mode 100644 docs/kyma/docs/002-components.md create mode 100644 docs/kyma/docs/005-environments.md create mode 100644 docs/kyma/docs/019-prereq-reasoning.md create mode 100644 docs/kyma/docs/025-details-local-reinstallation.md create mode 100644 docs/kyma/docs/026-details-testing.md create mode 100644 docs/kyma/docs/027-details-charts.md create mode 100644 docs/kyma/docs/028-details-deploy-private-registry.md create mode 100644 docs/kyma/docs/031-gs-local-installation.md create mode 100644 docs/kyma/docs/032-gs-sample-service-deployment-to-local.md create mode 100644 docs/kyma/docs/033-gs-sample-service-deployment-to-cluster.md create mode 100644 docs/kyma/docs/034-gs-local-develop-no-docker.md create mode 100644 docs/kyma/docs/035-gs-publish-service-image-and-deploy.md create mode 100644 docs/kyma/docs/assets/api-with-auth.yaml create mode 100644 docs/kyma/docs/assets/api-without-auth.yaml create mode 100644 docs/kyma/docs/assets/deployment.yaml create mode 100644 docs/manifest.yaml create mode 100644 docs/monitoring/docs.config.json create mode 100644 docs/monitoring/docs/001-overview-monitoring.md create mode 100644 docs/monitoring/docs/020-architecture-monitoring.md create mode 100644 docs/monitoring/docs/assets/monitoring.png create mode 100644 docs/serverless/docs.config.json create mode 100644 docs/serverless/docs/001-Overview.md create mode 100644 docs/serverless/docs/020-custom-resources.md create mode 100644 docs/serverless/docs/021-details-managing-lambdas.md create mode 100644 docs/serverless/docs/030-architecture.md create mode 100644 docs/serverless/docs/035-programming-model.md create mode 100644 docs/serverless/docs/040-cli-reference.md create mode 100644 docs/serverless/docs/assets/api-with-auth.yaml create mode 100644 docs/serverless/docs/assets/api-without-auth.yaml create mode 100644 docs/serverless/docs/assets/deployment.yaml create mode 100644 docs/serverless/docs/assets/hello.js create mode 100644 docs/serverless/docs/assets/istio.html create mode 100644 docs/serverless/docs/assets/kyma_connected.html create mode 100644 docs/serverless/docs/assets/kyma_connected.png create mode 100644 docs/serverless/docs/assets/lambda_example.html create mode 100644 docs/serverless/docs/assets/lambda_example.png create mode 100644 docs/serverless/docs/assets/monitoring.html create mode 100644 docs/serverless/docs/assets/nats.html create mode 100644 docs/serverless/docs/assets/serverless_general.html create mode 100644 docs/serverless/docs/assets/serverless_general.png create mode 100644 docs/service-brokers/azure-broker-service-classes/azure-mysql/docs.config.json create mode 100644 docs/service-brokers/azure-broker-service-classes/azure-mysql/docs/overview.md create mode 100644 docs/service-brokers/azure-broker-service-classes/azure-mysql/docs/plans-details.md create mode 100644 docs/service-brokers/azure-broker-service-classes/azure-redis-cache/docs.config.json create mode 100644 docs/service-brokers/azure-broker-service-classes/azure-redis-cache/docs/overview.md create mode 100644 docs/service-brokers/azure-broker-service-classes/azure-redis-cache/docs/plans-details.md create mode 100644 docs/service-brokers/azure-broker-service-classes/azure-sql/docs.config.json create mode 100644 docs/service-brokers/azure-broker-service-classes/azure-sql/docs/overview.md create mode 100644 docs/service-brokers/azure-broker-service-classes/azure-sql/docs/plans-details.md create mode 100644 docs/service-brokers/docs.config.json create mode 100644 docs/service-brokers/docs/001-overview-service-brokers.md create mode 100644 docs/service-brokers/docs/002-overview-azure-broker.md create mode 100644 docs/service-brokers/docs/003-overview-reb.md create mode 100644 docs/service-brokers/docs/004-overview-helm-broker.md create mode 100644 docs/service-brokers/docs/011-configuration-helm-broker.md create mode 100644 docs/service-brokers/docs/012-configuration-helm-broker-bundles.md create mode 100644 docs/service-brokers/docs/013-configuration-helm-broker-bundles-binding.md create mode 100644 docs/service-brokers/docs/014-configuration-enable-azure-broker.md create mode 100644 docs/service-brokers/docs/020-architecture-reb.md create mode 100644 docs/service-brokers/docs/021-architecture-helm-broker.md create mode 100644 docs/service-brokers/docs/030-examples-environment-mapping.md create mode 100644 docs/service-brokers/docs/assets/001-REB-registration.html create mode 100644 docs/service-brokers/docs/assets/001-REB-registration.png create mode 100644 docs/service-brokers/docs/assets/002-REB-envmapping.html create mode 100644 docs/service-brokers/docs/assets/002-REB-envmapping.png create mode 100644 docs/service-brokers/docs/assets/003-REB-API-service-class.html create mode 100644 docs/service-brokers/docs/assets/003-REB-API-service-class.png create mode 100644 docs/service-brokers/docs/assets/004-REB-event-service-class.html create mode 100644 docs/service-brokers/docs/assets/004-REB-event-service-class.png create mode 100644 docs/service-brokers/docs/assets/010-helm-registration.html create mode 100644 docs/service-brokers/docs/assets/010-helm-registration.png create mode 100644 docs/service-brokers/docs/assets/011-helm-architecture.html create mode 100644 docs/service-brokers/docs/assets/011-helm-architecture.png create mode 100644 docs/service-catalog/docs.config.json create mode 100644 docs/service-catalog/docs/001-overview-service-catalog.md create mode 100644 docs/service-catalog/docs/010-details-resources.md create mode 100644 docs/service-catalog/docs/011-details-add-service-to-the-catalog.md create mode 100644 docs/service-catalog/docs/012-details-provisioning-and-binding.md create mode 100644 docs/service-catalog/docs/013-details-unbinding-corner-case.md create mode 100644 docs/service-catalog/docs/020-architecture-service-catalog.md create mode 100644 docs/service-catalog/docs/030-cli-reference.md create mode 100755 docs/service-catalog/docs/assets/binding.html create mode 100755 docs/service-catalog/docs/assets/binding.png create mode 100755 docs/service-catalog/docs/assets/deprovisioning.html create mode 100755 docs/service-catalog/docs/assets/deprovisioning.png create mode 100755 docs/service-catalog/docs/assets/provisioning-and-binding.html create mode 100755 docs/service-catalog/docs/assets/provisioning-and-binding.png create mode 100755 docs/service-catalog/docs/assets/provisioning.html create mode 100755 docs/service-catalog/docs/assets/provisioning.png create mode 100755 docs/service-catalog/docs/assets/service-catalog-flow.html create mode 100755 docs/service-catalog/docs/assets/service-catalog-flow.png create mode 100755 docs/service-catalog/docs/assets/unbinding-corner-case.html create mode 100755 docs/service-catalog/docs/assets/unbinding-corner-case.png create mode 100755 docs/service-catalog/docs/assets/unbinding.html create mode 100755 docs/service-catalog/docs/assets/unbinding.png create mode 100644 docs/service-mesh/docs.config.json create mode 100644 docs/service-mesh/docs/001-overview.md create mode 100644 docs/service-mesh/docs/005-sidecar-proxy-injection.md create mode 100644 docs/tracing/docs.config.json create mode 100644 docs/tracing/docs/001-overview-tracing.md create mode 100644 docs/tracing/docs/020-architecture-tracing.md create mode 100755 docs/tracing/docs/assets/request-traces.html create mode 100755 docs/tracing/docs/assets/request-traces.png create mode 100755 docs/tracing/docs/assets/store-traces.html create mode 100755 docs/tracing/docs/assets/store-traces.png create mode 100755 docs/tracing/docs/assets/tracing-architecture.html create mode 100755 docs/tracing/docs/assets/tracing-architecture.png create mode 100644 governance.Jenkinsfile create mode 100644 installation/README.md create mode 100644 installation/certs/workspace/raw/server.crt create mode 100644 installation/certs/workspace/raw/server.key create mode 100755 installation/cmd/azure-ci.run.sh create mode 100644 installation/cmd/ci.build.ps1 create mode 100755 installation/cmd/ci.build.sh create mode 100644 installation/cmd/ci.run.ps1 create mode 100755 installation/cmd/ci.run.sh create mode 100644 installation/cmd/run.ps1 create mode 100755 installation/cmd/run.sh create mode 100644 installation/resources/azure-blobstore-secret.yaml.tpl create mode 100644 installation/resources/azure-broker-secret.yaml.tpl create mode 100644 installation/resources/cluster-certificate-secret.yaml.tpl create mode 100644 installation/resources/installation-config.yaml.tpl create mode 100644 installation/resources/installer-cr.yaml.tpl create mode 100644 installation/resources/installer-types.yaml create mode 100644 installation/resources/installer.yaml create mode 100644 installation/resources/local-tls-certs.yaml create mode 100644 installation/resources/release-adder.yaml create mode 100644 installation/resources/remote-env-certificate-secret.yaml.tpl create mode 100644 installation/resources/ui-test-secret.yaml.tpl create mode 100644 installation/resources/watch-pods.yaml create mode 100644 installation/scripts/clean-up.ps1 create mode 100755 installation/scripts/clean-up.sh create mode 100644 installation/scripts/copy-resource.ps1 create mode 100755 installation/scripts/copy-resources.sh create mode 100644 installation/scripts/create-config-map.ps1 create mode 100755 installation/scripts/create-config-map.sh create mode 100644 installation/scripts/create-cr.ps1 create mode 100755 installation/scripts/create-cr.sh create mode 100755 installation/scripts/create-generic-secret.sh create mode 100755 installation/scripts/cut.sh create mode 100755 installation/scripts/docker-start.sh create mode 100755 installation/scripts/generate-cluster-config.sh create mode 100644 installation/scripts/generate-local-config.ps1 create mode 100644 installation/scripts/generate-local-config.sh create mode 100755 installation/scripts/installer-ci-local.sh create mode 100644 installation/scripts/installer.ps1 create mode 100755 installation/scripts/installer.sh create mode 100644 installation/scripts/is-installed.ps1 create mode 100755 installation/scripts/is-installed.sh create mode 100644 installation/scripts/is-ready.ps1 create mode 100755 installation/scripts/is-ready.sh create mode 100644 installation/scripts/minikube.ps1 create mode 100755 installation/scripts/minikube.sh create mode 100644 installation/scripts/replace-placeholder.ps1 create mode 100755 installation/scripts/testing.sh create mode 100755 installation/scripts/update.sh create mode 100644 installation/scripts/utils.sh create mode 100755 installation/scripts/watch-pods.sh create mode 100755 logo.png create mode 100644 orchestrator.Jenkinsfile create mode 100644 resources/README.md create mode 100644 resources/cluster-essentials/.helmignore create mode 100644 resources/cluster-essentials/Chart.yaml create mode 100644 resources/cluster-essentials/README.md create mode 100644 resources/cluster-essentials/templates/environment-mapping.crd.yaml create mode 100644 resources/cluster-essentials/templates/environments.yaml create mode 100644 resources/cluster-essentials/templates/event-activation.crd.yaml create mode 100644 resources/cluster-essentials/templates/eventing-subscription.crd.yaml create mode 100644 resources/cluster-essentials/templates/nginx-ingress-cert.yaml create mode 100644 resources/cluster-essentials/templates/remote-env-ca-secret.yaml create mode 100644 resources/cluster-essentials/templates/remote-environment.crd.yaml create mode 100644 resources/cluster-essentials/templates/service-binding-usage.crd.yaml create mode 100644 resources/cluster-prerequisites/README.md create mode 100644 resources/cluster-prerequisites/default-sa-rbac-role.yaml create mode 100644 resources/cluster-prerequisites/install.sh create mode 100644 resources/cluster-prerequisites/limit-range.yaml create mode 100644 resources/cluster-prerequisites/remote-environments-minio-secret.yaml create mode 100644 resources/cluster-prerequisites/resource-quotas-installer.yaml create mode 100644 resources/cluster-prerequisites/resource-quotas.yaml create mode 100644 resources/core/.helmignore create mode 100644 resources/core/Chart.yaml create mode 100644 resources/core/README.md create mode 100644 resources/core/charts/api-controller/Chart.yaml create mode 100644 resources/core/charts/api-controller/README.md create mode 100644 resources/core/charts/api-controller/templates/deployment.yaml create mode 100644 resources/core/charts/api-controller/templates/pre-install-rbac.yaml create mode 100644 resources/core/charts/api-controller/templates/tests/test-account-and-role.yaml create mode 100644 resources/core/charts/api-controller/templates/tests/test-api-controller.yaml create mode 100644 resources/core/charts/api-controller/values.yaml create mode 100644 resources/core/charts/apiserver-proxy/Chart.yaml create mode 100644 resources/core/charts/apiserver-proxy/README.md create mode 100644 resources/core/charts/apiserver-proxy/templates/pre-install-proxy-config-map.yaml create mode 100644 resources/core/charts/apiserver-proxy/templates/proxy-deployment.yaml create mode 100644 resources/core/charts/apiserver-proxy/templates/proxy-ingress.yaml create mode 100644 resources/core/charts/apiserver-proxy/templates/proxy-service.yaml create mode 100644 resources/core/charts/apiserver-proxy/values.yaml create mode 100644 resources/core/charts/application-connector/Chart.yaml create mode 100644 resources/core/charts/application-connector/README.md create mode 100644 resources/core/charts/application-connector/charts/connector-service/.helmignore create mode 100644 resources/core/charts/application-connector/charts/connector-service/Chart.yaml create mode 100644 resources/core/charts/application-connector/charts/connector-service/README.md create mode 100644 resources/core/charts/application-connector/charts/connector-service/templates/_helpers.tpl create mode 100644 resources/core/charts/application-connector/charts/connector-service/templates/deployment.yaml create mode 100644 resources/core/charts/application-connector/charts/connector-service/templates/ingress.yaml create mode 100644 resources/core/charts/application-connector/charts/connector-service/templates/role-binding.yaml create mode 100644 resources/core/charts/application-connector/charts/connector-service/templates/service.yaml create mode 100644 resources/core/charts/application-connector/charts/connector-service/templates/tests/test-acceptance.yaml create mode 100644 resources/core/charts/application-connector/charts/connector-service/values.yaml create mode 100644 resources/core/charts/application-connector/charts/metadata-service/Chart.yaml create mode 100644 resources/core/charts/application-connector/charts/metadata-service/templates/_helpers.tpl create mode 100644 resources/core/charts/application-connector/charts/metadata-service/templates/deployment.yaml create mode 100644 resources/core/charts/application-connector/charts/metadata-service/templates/role-binding.yaml create mode 100644 resources/core/charts/application-connector/charts/metadata-service/templates/service.yaml create mode 100644 resources/core/charts/application-connector/charts/metadata-service/templates/tests/test-acceptance.yaml create mode 100644 resources/core/charts/application-connector/charts/metadata-service/values.yaml create mode 100644 resources/core/charts/application-connector/requirements.yaml create mode 100644 resources/core/charts/application-connector/values.yaml create mode 100755 resources/core/charts/azure-broker/.helmignore create mode 100755 resources/core/charts/azure-broker/Chart.yaml create mode 100644 resources/core/charts/azure-broker/README.md create mode 100755 resources/core/charts/azure-broker/charts/redis/.helmignore create mode 100755 resources/core/charts/azure-broker/charts/redis/Chart.yaml create mode 100755 resources/core/charts/azure-broker/charts/redis/README.md create mode 100755 resources/core/charts/azure-broker/charts/redis/templates/NOTES.txt create mode 100755 resources/core/charts/azure-broker/charts/redis/templates/_helpers.tpl create mode 100755 resources/core/charts/azure-broker/charts/redis/templates/deployment.yaml create mode 100755 resources/core/charts/azure-broker/charts/redis/templates/networkpolicy.yaml create mode 100755 resources/core/charts/azure-broker/charts/redis/templates/pvc.yaml create mode 100755 resources/core/charts/azure-broker/charts/redis/templates/secrets.yaml create mode 100755 resources/core/charts/azure-broker/charts/redis/templates/svc.yaml create mode 100755 resources/core/charts/azure-broker/charts/redis/values.yaml create mode 100644 resources/core/charts/azure-broker/templates/_helpers.tpl create mode 100644 resources/core/charts/azure-broker/templates/azure-broker-basic-auth.yaml create mode 100644 resources/core/charts/azure-broker/templates/azure-broker-credentials.yaml create mode 100644 resources/core/charts/azure-broker/templates/azure-broker-redis.yaml create mode 100644 resources/core/charts/azure-broker/templates/azure-service-classes-docu.yaml create mode 100644 resources/core/charts/azure-broker/templates/broker.yaml create mode 100644 resources/core/charts/azure-broker/templates/deployment.yaml create mode 100644 resources/core/charts/azure-broker/templates/service.yaml create mode 100755 resources/core/charts/azure-broker/values.yaml create mode 100644 resources/core/charts/cluster-users/Chart.yaml create mode 100644 resources/core/charts/cluster-users/README.md create mode 100644 resources/core/charts/cluster-users/templates/rbac-roles.yaml create mode 100644 resources/core/charts/cluster-users/values.yaml create mode 100644 resources/core/charts/configurations-generator/Chart.yaml create mode 100644 resources/core/charts/configurations-generator/README.md create mode 100644 resources/core/charts/configurations-generator/templates/deployment.yaml create mode 100644 resources/core/charts/configurations-generator/templates/ingress.yaml create mode 100644 resources/core/charts/configurations-generator/templates/jwt-rule.yaml create mode 100644 resources/core/charts/configurations-generator/templates/route-rule.yaml create mode 100644 resources/core/charts/configurations-generator/templates/service.yaml create mode 100644 resources/core/charts/configurations-generator/values.yaml create mode 100644 resources/core/charts/console/.helmignore create mode 100644 resources/core/charts/console/Chart.yaml create mode 100644 resources/core/charts/console/README.md create mode 100644 resources/core/charts/console/templates/_helpers.tpl create mode 100644 resources/core/charts/console/templates/configmap.yaml create mode 100644 resources/core/charts/console/templates/deployment.yaml create mode 100644 resources/core/charts/console/templates/idppreset.crd.yaml create mode 100644 resources/core/charts/console/templates/ingress.yaml create mode 100644 resources/core/charts/console/templates/kubernetes-dashboard-admin.yaml create mode 100644 resources/core/charts/console/templates/microfrontend.crd.yaml create mode 100644 resources/core/charts/console/templates/service.yaml create mode 100644 resources/core/charts/console/values.yaml create mode 100644 resources/core/charts/docs/.helmignore create mode 100644 resources/core/charts/docs/Chart.yaml create mode 100644 resources/core/charts/docs/README.md create mode 100644 resources/core/charts/docs/charts/content-ui/.helmignore create mode 100644 resources/core/charts/docs/charts/content-ui/Chart.yaml create mode 100644 resources/core/charts/docs/charts/content-ui/templates/_helpers.tpl create mode 100644 resources/core/charts/docs/charts/content-ui/templates/configmap.yaml create mode 100644 resources/core/charts/docs/charts/content-ui/templates/deployment.yaml create mode 100644 resources/core/charts/docs/charts/content-ui/templates/ingress.yaml create mode 100644 resources/core/charts/docs/charts/content-ui/templates/service.yaml create mode 100644 resources/core/charts/docs/charts/content-ui/values.yaml create mode 100644 resources/core/charts/docs/charts/documentation/.helmignore create mode 100644 resources/core/charts/docs/charts/documentation/Chart.yaml create mode 100644 resources/core/charts/docs/charts/documentation/templates/_helpers.tpl create mode 100644 resources/core/charts/docs/charts/documentation/templates/docs-job.yaml create mode 100644 resources/core/charts/docs/charts/documentation/values.yaml create mode 100644 resources/core/charts/environments/.helmignore create mode 100644 resources/core/charts/environments/Chart.yaml create mode 100644 resources/core/charts/environments/README.md create mode 100644 resources/core/charts/environments/templates/0-service-account.yaml create mode 100644 resources/core/charts/environments/templates/1-role.yaml create mode 100644 resources/core/charts/environments/templates/2-role-binding.yaml create mode 100644 resources/core/charts/environments/templates/3-bootstrap-roles.yaml create mode 100644 resources/core/charts/environments/templates/4-deployment.yaml create mode 100644 resources/core/charts/environments/templates/_helpers.tpl create mode 100644 resources/core/charts/environments/templates/tests/test-environments.yaml create mode 100644 resources/core/charts/environments/values.yaml create mode 100644 resources/core/charts/event-bus/.helmignore create mode 100644 resources/core/charts/event-bus/Chart.yaml create mode 100644 resources/core/charts/event-bus/README.md create mode 100644 resources/core/charts/event-bus/charts/nats-streaming/.helmignore create mode 100644 resources/core/charts/event-bus/charts/nats-streaming/Chart.yaml create mode 100644 resources/core/charts/event-bus/charts/nats-streaming/templates/_helpers.tpl create mode 100644 resources/core/charts/event-bus/charts/nats-streaming/templates/configmap.yaml create mode 100644 resources/core/charts/event-bus/charts/nats-streaming/templates/service.yaml create mode 100644 resources/core/charts/event-bus/charts/nats-streaming/templates/statefulset.yaml create mode 100644 resources/core/charts/event-bus/charts/nats-streaming/values.yaml create mode 100644 resources/core/charts/event-bus/charts/publish/Chart.yaml create mode 100644 resources/core/charts/event-bus/charts/publish/templates/_helpers.tpl create mode 100644 resources/core/charts/event-bus/charts/publish/templates/deployment.yaml create mode 100644 resources/core/charts/event-bus/charts/publish/templates/service.yaml create mode 100644 resources/core/charts/event-bus/charts/publish/values.yaml create mode 100644 resources/core/charts/event-bus/charts/push/Chart.yaml create mode 100644 resources/core/charts/event-bus/charts/push/templates/_helpers.tpl create mode 100644 resources/core/charts/event-bus/charts/push/templates/deployment.yaml create mode 100644 resources/core/charts/event-bus/charts/push/values.yaml create mode 100644 resources/core/charts/event-bus/charts/sub-validator/.helmignore create mode 100644 resources/core/charts/event-bus/charts/sub-validator/Chart.yaml create mode 100644 resources/core/charts/event-bus/charts/sub-validator/templates/_helpers.tpl create mode 100644 resources/core/charts/event-bus/charts/sub-validator/templates/deployment.yaml create mode 100644 resources/core/charts/event-bus/charts/sub-validator/values.yaml create mode 100644 resources/core/charts/event-bus/requirements.yaml create mode 100644 resources/core/charts/event-bus/templates/_helpers.tpl create mode 100644 resources/core/charts/event-bus/templates/tests/test-e2e-tester.yaml create mode 100644 resources/core/charts/event-bus/values.yaml create mode 100644 resources/core/charts/helm-broker/.helmignore create mode 100644 resources/core/charts/helm-broker/Chart.yaml create mode 100644 resources/core/charts/helm-broker/README.md create mode 100644 resources/core/charts/helm-broker/charts/etcd/.helmignore create mode 100644 resources/core/charts/helm-broker/charts/etcd/Chart.yaml create mode 100644 resources/core/charts/helm-broker/charts/etcd/README.md create mode 100644 resources/core/charts/helm-broker/charts/etcd/templates/_helpers.tpl create mode 100644 resources/core/charts/helm-broker/charts/etcd/templates/configmap.yaml create mode 100644 resources/core/charts/helm-broker/charts/etcd/templates/service.yaml create mode 100644 resources/core/charts/helm-broker/charts/etcd/templates/statefulset.yaml create mode 100644 resources/core/charts/helm-broker/charts/etcd/values.yaml create mode 100644 resources/core/charts/helm-broker/templates/_helpers.tpl create mode 100644 resources/core/charts/helm-broker/templates/cluster-role-binding.yaml create mode 100644 resources/core/charts/helm-broker/templates/cluster-role.yaml create mode 100644 resources/core/charts/helm-broker/templates/configmap.yaml create mode 100644 resources/core/charts/helm-broker/templates/deploy.yaml create mode 100644 resources/core/charts/helm-broker/templates/sa.yaml create mode 100644 resources/core/charts/helm-broker/templates/service-broker.yaml create mode 100644 resources/core/charts/helm-broker/templates/svc.yaml create mode 100644 resources/core/charts/helm-broker/values.yaml create mode 100644 resources/core/charts/jaeger/.helmignore create mode 100644 resources/core/charts/jaeger/Chart.yaml create mode 100644 resources/core/charts/jaeger/README.md create mode 100644 resources/core/charts/jaeger/templates/_helpers.tpl create mode 100644 resources/core/charts/jaeger/templates/deployment.yaml create mode 100644 resources/core/charts/jaeger/templates/jaeger-ingress.yaml create mode 100644 resources/core/charts/jaeger/templates/service.yaml create mode 100644 resources/core/charts/jaeger/values.yaml create mode 100644 resources/core/charts/kubeless/Chart.yaml create mode 100644 resources/core/charts/kubeless/README.md create mode 100644 resources/core/charts/kubeless/charts/lambdas-ui/.helmignore create mode 100644 resources/core/charts/kubeless/charts/lambdas-ui/Chart.yaml create mode 100644 resources/core/charts/kubeless/charts/lambdas-ui/templates/_helpers.tpl create mode 100644 resources/core/charts/kubeless/charts/lambdas-ui/templates/configmap.yaml create mode 100644 resources/core/charts/kubeless/charts/lambdas-ui/templates/deployment.yaml create mode 100644 resources/core/charts/kubeless/charts/lambdas-ui/templates/ingress.yaml create mode 100644 resources/core/charts/kubeless/charts/lambdas-ui/templates/service.yaml create mode 100644 resources/core/charts/kubeless/charts/lambdas-ui/values.yaml create mode 100644 resources/core/charts/kubeless/templates/_helpers.tpl create mode 100644 resources/core/charts/kubeless/templates/kubeless-clusterroles.yaml create mode 100644 resources/core/charts/kubeless/templates/kubeless-configmap.yaml create mode 100755 resources/core/charts/kubeless/templates/kubeless-controller-deployment.yaml create mode 100644 resources/core/charts/kubeless/templates/kubeless-crd.yaml create mode 100644 resources/core/charts/kubeless/templates/kubeless-serviceaccount.yaml create mode 100644 resources/core/charts/kubeless/templates/tests/test-kubeless.yaml create mode 100644 resources/core/charts/kubeless/values.yaml create mode 100644 resources/core/charts/minio/.helmignore create mode 100755 resources/core/charts/minio/Chart.yaml create mode 100644 resources/core/charts/minio/README.md create mode 100644 resources/core/charts/minio/templates/_helper_create_bucket.txt create mode 100644 resources/core/charts/minio/templates/_helpers.tpl create mode 100644 resources/core/charts/minio/templates/configmap.yaml create mode 100644 resources/core/charts/minio/templates/deployment.yaml create mode 100644 resources/core/charts/minio/templates/ingress.yaml create mode 100644 resources/core/charts/minio/templates/minio-content-upload-configmap.yaml create mode 100644 resources/core/charts/minio/templates/minio-content-upload-podpreset.yaml create mode 100644 resources/core/charts/minio/templates/networkpolicy.yaml create mode 100644 resources/core/charts/minio/templates/post-install-create-bucket-job.yaml create mode 100644 resources/core/charts/minio/templates/pvc.yaml create mode 100644 resources/core/charts/minio/templates/secrets.yaml create mode 100644 resources/core/charts/minio/templates/service.yaml create mode 100644 resources/core/charts/minio/templates/statefulset.yaml create mode 100644 resources/core/charts/minio/values.yaml create mode 100644 resources/core/charts/monitoring/.helmignore create mode 100644 resources/core/charts/monitoring/Chart.yaml create mode 100644 resources/core/charts/monitoring/README.md create mode 100644 resources/core/charts/monitoring/_helpers.tpl create mode 100644 resources/core/charts/monitoring/charts/alert-rules/.helmignore create mode 100644 resources/core/charts/monitoring/charts/alert-rules/Chart.yaml create mode 100644 resources/core/charts/monitoring/charts/alert-rules/README.md create mode 100644 resources/core/charts/monitoring/charts/alert-rules/templates/_helpers.tpl create mode 100644 resources/core/charts/monitoring/charts/alert-rules/templates/unhealthy-pods-configmap.yaml create mode 100644 resources/core/charts/monitoring/charts/alert-rules/templates/unhealthy-pods-rules.yaml create mode 100644 resources/core/charts/monitoring/charts/alert-rules/values.yaml create mode 100644 resources/core/charts/monitoring/charts/alertmanager/.helmignore create mode 100644 resources/core/charts/monitoring/charts/alertmanager/Chart.yaml create mode 100644 resources/core/charts/monitoring/charts/alertmanager/README.md create mode 100644 resources/core/charts/monitoring/charts/alertmanager/templates/_helpers.tpl create mode 100644 resources/core/charts/monitoring/charts/alertmanager/templates/alertmanager.rules.yaml create mode 100644 resources/core/charts/monitoring/charts/alertmanager/templates/alertmanager.yaml create mode 100644 resources/core/charts/monitoring/charts/alertmanager/templates/configmap.yaml create mode 100644 resources/core/charts/monitoring/charts/alertmanager/templates/ingress.yaml create mode 100644 resources/core/charts/monitoring/charts/alertmanager/templates/secret.yaml create mode 100644 resources/core/charts/monitoring/charts/alertmanager/templates/service.yaml create mode 100644 resources/core/charts/monitoring/charts/alertmanager/templates/servicemonitor.yaml create mode 100644 resources/core/charts/monitoring/charts/alertmanager/values.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-controller-manager/.helmignore create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-controller-manager/Chart.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/_helpers.tpl create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/configmap.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/endpoints.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/kube-controller-manager.rules.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/service.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/servicemonitor.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-controller-manager/values.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-dns/.helmignore create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-dns/Chart.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-dns/templates/_helpers.tpl create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-dns/templates/service.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-dns/templates/servicemonitor.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-dns/values.yaml create mode 100755 resources/core/charts/monitoring/charts/exporter-kube-etcd/.helmignore create mode 100755 resources/core/charts/monitoring/charts/exporter-kube-etcd/Chart.yaml create mode 100755 resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/_helpers.tpl create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/configmap.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/endpoints.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/etcd3.rules.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/service.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/servicemonitor.yaml create mode 100755 resources/core/charts/monitoring/charts/exporter-kube-etcd/values.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-scheduler/.helmignore create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-scheduler/Chart.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/_helpers.tpl create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/configmap.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/endpoints.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/kube-scheduler.rules.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/service.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/servicemonitor.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-scheduler/values.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-state/.helmignore create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-state/Chart.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-state/templates/_helpers.tpl create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-state/templates/clusterrole.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-state/templates/clusterrolebinding.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-state/templates/configmap.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-state/templates/deployment.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-state/templates/kube-state-metrics.rules.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-state/templates/role.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-state/templates/rolebinding.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-state/templates/service.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-state/templates/serviceaccount.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-state/templates/servicemonitor.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kube-state/values.yaml create mode 100755 resources/core/charts/monitoring/charts/exporter-kubelets/.helmignore create mode 100755 resources/core/charts/monitoring/charts/exporter-kubelets/Chart.yaml create mode 100755 resources/core/charts/monitoring/charts/exporter-kubelets/templates/_helpers.tpl create mode 100644 resources/core/charts/monitoring/charts/exporter-kubelets/templates/configmap.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kubelets/templates/kubelet.rules.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kubelets/templates/servicemonitor.yaml create mode 100755 resources/core/charts/monitoring/charts/exporter-kubelets/values.yaml create mode 100755 resources/core/charts/monitoring/charts/exporter-kubernetes/.helmignore create mode 100755 resources/core/charts/monitoring/charts/exporter-kubernetes/Chart.yaml create mode 100755 resources/core/charts/monitoring/charts/exporter-kubernetes/templates/_helpers.tpl create mode 100644 resources/core/charts/monitoring/charts/exporter-kubernetes/templates/configmap.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kubernetes/templates/kubernetes.rules.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-kubernetes/templates/servicemonitor.yaml create mode 100755 resources/core/charts/monitoring/charts/exporter-kubernetes/values.yaml create mode 100755 resources/core/charts/monitoring/charts/exporter-node/.helmignore create mode 100755 resources/core/charts/monitoring/charts/exporter-node/Chart.yaml create mode 100755 resources/core/charts/monitoring/charts/exporter-node/templates/_helpers.tpl create mode 100644 resources/core/charts/monitoring/charts/exporter-node/templates/configmap.yaml create mode 100755 resources/core/charts/monitoring/charts/exporter-node/templates/daemonset.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-node/templates/node.rules.yaml create mode 100755 resources/core/charts/monitoring/charts/exporter-node/templates/service.yaml create mode 100644 resources/core/charts/monitoring/charts/exporter-node/templates/servicemonitor.yaml create mode 100755 resources/core/charts/monitoring/charts/exporter-node/values.yaml create mode 100755 resources/core/charts/monitoring/charts/grafana/Chart.yaml create mode 100644 resources/core/charts/monitoring/charts/grafana/README.md create mode 100644 resources/core/charts/monitoring/charts/grafana/dashboards/deployment-dashboard.json create mode 100755 resources/core/charts/monitoring/charts/grafana/dashboards/istio-dashboard.json create mode 100644 resources/core/charts/monitoring/charts/grafana/dashboards/kubernetes-capacity-planning-dashboard.json create mode 100644 resources/core/charts/monitoring/charts/grafana/dashboards/kubernetes-cluster-health-dashboard.json create mode 100644 resources/core/charts/monitoring/charts/grafana/dashboards/kubernetes-cluster-status-dashboard.json create mode 100644 resources/core/charts/monitoring/charts/grafana/dashboards/kubernetes-control-plane-status-dashboard.json create mode 100644 resources/core/charts/monitoring/charts/grafana/dashboards/kubernetes-resource-requests-dashboard.json create mode 100644 resources/core/charts/monitoring/charts/grafana/dashboards/lambda-dashboard.json create mode 100755 resources/core/charts/monitoring/charts/grafana/dashboards/mixer-dashboard.json create mode 100644 resources/core/charts/monitoring/charts/grafana/dashboards/nodes-dashboard.json create mode 100755 resources/core/charts/monitoring/charts/grafana/dashboards/pilot-dashboard.json create mode 100644 resources/core/charts/monitoring/charts/grafana/dashboards/pods-dashboard.json create mode 100644 resources/core/charts/monitoring/charts/grafana/dashboards/statefulset-dashboard.json create mode 100644 resources/core/charts/monitoring/charts/grafana/templates/_helpers.tpl create mode 100644 resources/core/charts/monitoring/charts/grafana/templates/dasboards-provisioner-configmap.yaml create mode 100644 resources/core/charts/monitoring/charts/grafana/templates/dashboards-configmap.yaml create mode 100644 resources/core/charts/monitoring/charts/grafana/templates/datasource-configmap.yaml create mode 100644 resources/core/charts/monitoring/charts/grafana/templates/grafana-deployment.yaml create mode 100644 resources/core/charts/monitoring/charts/grafana/templates/ingress.yaml create mode 100644 resources/core/charts/monitoring/charts/grafana/templates/pvc.yaml create mode 100644 resources/core/charts/monitoring/charts/grafana/templates/servicemonitors.yaml create mode 100644 resources/core/charts/monitoring/charts/grafana/templates/svc.yaml create mode 100755 resources/core/charts/monitoring/charts/grafana/values.yaml create mode 100644 resources/core/charts/monitoring/charts/kube-prometheus/.helmignore create mode 100644 resources/core/charts/monitoring/charts/kube-prometheus/Chart.yaml create mode 100644 resources/core/charts/monitoring/charts/kube-prometheus/templates/_helpers.tpl create mode 100644 resources/core/charts/monitoring/charts/kube-prometheus/templates/configmap.yaml create mode 100644 resources/core/charts/monitoring/charts/kube-prometheus/templates/general.rules.yaml create mode 100644 resources/core/charts/monitoring/charts/kube-prometheus/values.yaml create mode 100755 resources/core/charts/monitoring/charts/prometheus/.helmignore create mode 100755 resources/core/charts/monitoring/charts/prometheus/Chart.yaml create mode 100755 resources/core/charts/monitoring/charts/prometheus/templates/_configmaps.json.tpl create mode 100755 resources/core/charts/monitoring/charts/prometheus/templates/_helpers.tpl create mode 100644 resources/core/charts/monitoring/charts/prometheus/templates/clusterrole.yaml create mode 100644 resources/core/charts/monitoring/charts/prometheus/templates/clusterrolebinding.yaml create mode 100644 resources/core/charts/monitoring/charts/prometheus/templates/configmap.yaml create mode 100755 resources/core/charts/monitoring/charts/prometheus/templates/ingress.yaml create mode 100644 resources/core/charts/monitoring/charts/prometheus/templates/prometheus.rules.yaml create mode 100755 resources/core/charts/monitoring/charts/prometheus/templates/prometheus.yaml create mode 100755 resources/core/charts/monitoring/charts/prometheus/templates/rules.yaml create mode 100755 resources/core/charts/monitoring/charts/prometheus/templates/secret.yaml create mode 100755 resources/core/charts/monitoring/charts/prometheus/templates/service.yaml create mode 100644 resources/core/charts/monitoring/charts/prometheus/templates/serviceaccount.yaml create mode 100644 resources/core/charts/monitoring/charts/prometheus/templates/servicemonitor-prometheus.yaml create mode 100755 resources/core/charts/monitoring/charts/prometheus/templates/servicemonitors.yaml create mode 100755 resources/core/charts/monitoring/charts/prometheus/values.yaml create mode 100644 resources/core/charts/monitoring/requirements.yaml create mode 100644 resources/core/charts/monitoring/templates/tests/test-kube-prometheus.yaml create mode 100644 resources/core/charts/monitoring/values.yaml create mode 100755 resources/core/charts/nginx-ingress/.helmignore create mode 100755 resources/core/charts/nginx-ingress/Chart.yaml create mode 100755 resources/core/charts/nginx-ingress/README.md create mode 100755 resources/core/charts/nginx-ingress/templates/NOTES.txt create mode 100755 resources/core/charts/nginx-ingress/templates/_helpers.tpl create mode 100755 resources/core/charts/nginx-ingress/templates/clusterrole.yaml create mode 100755 resources/core/charts/nginx-ingress/templates/clusterrolebinding.yaml create mode 100755 resources/core/charts/nginx-ingress/templates/controller-configmap.yaml create mode 100755 resources/core/charts/nginx-ingress/templates/controller-daemonset.yaml create mode 100755 resources/core/charts/nginx-ingress/templates/controller-deployment.yaml create mode 100755 resources/core/charts/nginx-ingress/templates/controller-hpa.yaml create mode 100755 resources/core/charts/nginx-ingress/templates/controller-metrics-service.yaml create mode 100755 resources/core/charts/nginx-ingress/templates/controller-poddisruptionbudget.yaml create mode 100755 resources/core/charts/nginx-ingress/templates/controller-service.yaml create mode 100755 resources/core/charts/nginx-ingress/templates/controller-stats-service.yaml create mode 100755 resources/core/charts/nginx-ingress/templates/default-backend-deployment.yaml create mode 100755 resources/core/charts/nginx-ingress/templates/default-backend-poddisruptionbudget.yaml create mode 100755 resources/core/charts/nginx-ingress/templates/default-backend-service.yaml create mode 100755 resources/core/charts/nginx-ingress/templates/headers-configmap.yaml create mode 100755 resources/core/charts/nginx-ingress/templates/role.yaml create mode 100755 resources/core/charts/nginx-ingress/templates/rolebinding.yaml create mode 100755 resources/core/charts/nginx-ingress/templates/serviceaccount.yaml create mode 100755 resources/core/charts/nginx-ingress/templates/tcp-configmap.yaml create mode 100755 resources/core/charts/nginx-ingress/templates/udp-configmap.yaml create mode 100644 resources/core/charts/nginx-ingress/templates/zipkin-configmap.yml create mode 100755 resources/core/charts/nginx-ingress/values.yaml create mode 100644 resources/core/charts/remote-environment-broker/.helmignore create mode 100644 resources/core/charts/remote-environment-broker/Chart.yaml create mode 100644 resources/core/charts/remote-environment-broker/README.md create mode 100644 resources/core/charts/remote-environment-broker/templates/_helpers.tpl create mode 100644 resources/core/charts/remote-environment-broker/templates/cluster-role-binding.yaml create mode 100644 resources/core/charts/remote-environment-broker/templates/cluster-role.yaml create mode 100644 resources/core/charts/remote-environment-broker/templates/configmap.yaml create mode 100644 resources/core/charts/remote-environment-broker/templates/deployment.yaml create mode 100644 resources/core/charts/remote-environment-broker/templates/service-account.yaml create mode 100644 resources/core/charts/remote-environment-broker/templates/service-broker.yaml create mode 100644 resources/core/charts/remote-environment-broker/templates/service.yaml create mode 100644 resources/core/charts/remote-environment-broker/values.yaml create mode 100644 resources/core/charts/service-catalog/Chart.yaml create mode 100644 resources/core/charts/service-catalog/README.md create mode 100644 resources/core/charts/service-catalog/charts/binding-usage-controller/.helmignore create mode 100644 resources/core/charts/service-catalog/charts/binding-usage-controller/Chart.yaml create mode 100644 resources/core/charts/service-catalog/charts/binding-usage-controller/README.md create mode 100644 resources/core/charts/service-catalog/charts/binding-usage-controller/templates/_helpers.tpl create mode 100644 resources/core/charts/service-catalog/charts/binding-usage-controller/templates/cluster-role-binding.yaml create mode 100644 resources/core/charts/service-catalog/charts/binding-usage-controller/templates/cluster-role.yaml create mode 100644 resources/core/charts/service-catalog/charts/binding-usage-controller/templates/configmap.yaml create mode 100644 resources/core/charts/service-catalog/charts/binding-usage-controller/templates/deployment.yaml create mode 100644 resources/core/charts/service-catalog/charts/binding-usage-controller/templates/service-account.yaml create mode 100644 resources/core/charts/service-catalog/charts/binding-usage-controller/values.yaml create mode 100644 resources/core/charts/service-catalog/charts/catalog-ui/.helmignore create mode 100644 resources/core/charts/service-catalog/charts/catalog-ui/Chart.yaml create mode 100644 resources/core/charts/service-catalog/charts/catalog-ui/templates/_helpers.tpl create mode 100644 resources/core/charts/service-catalog/charts/catalog-ui/templates/configmap.yaml create mode 100644 resources/core/charts/service-catalog/charts/catalog-ui/templates/deployment.yaml create mode 100644 resources/core/charts/service-catalog/charts/catalog-ui/templates/ingress.yaml create mode 100644 resources/core/charts/service-catalog/charts/catalog-ui/templates/service.yaml create mode 100644 resources/core/charts/service-catalog/charts/catalog-ui/values.yaml create mode 100644 resources/core/charts/service-catalog/charts/catalog/Chart.yaml create mode 100644 resources/core/charts/service-catalog/charts/catalog/templates/_helpers.tpl create mode 100644 resources/core/charts/service-catalog/charts/catalog/templates/apiregistration.yaml create mode 100644 resources/core/charts/service-catalog/charts/catalog/templates/apiserver-deployment.yaml create mode 100644 resources/core/charts/service-catalog/charts/catalog/templates/apiserver-secret.yaml create mode 100644 resources/core/charts/service-catalog/charts/catalog/templates/apiserver-service.yaml create mode 100644 resources/core/charts/service-catalog/charts/catalog/templates/controller-manager-deployment.yaml create mode 100644 resources/core/charts/service-catalog/charts/catalog/templates/rbac.yaml create mode 100644 resources/core/charts/service-catalog/charts/catalog/templates/serviceaccounts.yaml create mode 100644 resources/core/charts/service-catalog/charts/catalog/values.yaml create mode 100644 resources/core/charts/service-catalog/charts/etcd/.helmignore create mode 100644 resources/core/charts/service-catalog/charts/etcd/Chart.yaml create mode 100644 resources/core/charts/service-catalog/charts/etcd/templates/_helpers.tpl create mode 100644 resources/core/charts/service-catalog/charts/etcd/templates/configmap.yaml create mode 100644 resources/core/charts/service-catalog/charts/etcd/templates/service.yaml create mode 100644 resources/core/charts/service-catalog/charts/etcd/templates/statefulset.yaml create mode 100644 resources/core/charts/service-catalog/charts/etcd/values.yaml create mode 100644 resources/core/charts/service-catalog/charts/instances-ui/.helmignore create mode 100644 resources/core/charts/service-catalog/charts/instances-ui/Chart.yaml create mode 100644 resources/core/charts/service-catalog/charts/instances-ui/templates/_helpers.tpl create mode 100644 resources/core/charts/service-catalog/charts/instances-ui/templates/configmap.yaml create mode 100644 resources/core/charts/service-catalog/charts/instances-ui/templates/deployment.yaml create mode 100644 resources/core/charts/service-catalog/charts/instances-ui/templates/ingress.yaml create mode 100644 resources/core/charts/service-catalog/charts/instances-ui/templates/service.yaml create mode 100644 resources/core/charts/service-catalog/charts/instances-ui/values.yaml create mode 100644 resources/core/charts/service-catalog/values.yaml create mode 100644 resources/core/charts/ui-api/.helmignore create mode 100644 resources/core/charts/ui-api/Chart.yaml create mode 100644 resources/core/charts/ui-api/templates/_helpers.tpl create mode 100644 resources/core/charts/ui-api/templates/cluster-role-binding.yaml create mode 100644 resources/core/charts/ui-api/templates/cluster-role.yaml create mode 100644 resources/core/charts/ui-api/templates/deployment.yaml create mode 100644 resources/core/charts/ui-api/templates/ingress.yaml create mode 100644 resources/core/charts/ui-api/templates/jwt-rules.yaml create mode 100644 resources/core/charts/ui-api/templates/route-role.yml create mode 100644 resources/core/charts/ui-api/templates/service-account.yaml create mode 100644 resources/core/charts/ui-api/templates/service.yaml create mode 100644 resources/core/charts/ui-api/values.yaml create mode 100644 resources/core/cluster.yaml create mode 100644 resources/core/requirements.yaml create mode 100644 resources/core/templates/NOTES.txt create mode 100644 resources/core/templates/_helpers.tpl create mode 100644 resources/core/templates/tests/rbac-core-acceptance.yaml create mode 100644 resources/core/templates/tests/rbac-ui-api-acceptance.yaml create mode 100644 resources/core/templates/tests/test-core-acceptance.yaml create mode 100644 resources/core/templates/tests/test-ui-api-acceptance.yaml create mode 100644 resources/core/values.yaml create mode 100644 resources/dex/Chart.yaml create mode 100644 resources/dex/README.md create mode 100644 resources/dex/github.md create mode 100644 resources/dex/templates/NOTES.txt create mode 100644 resources/dex/templates/_helpers.tpl create mode 100644 resources/dex/templates/dex-config-map.yaml create mode 100644 resources/dex/templates/dex-deployment.yaml create mode 100644 resources/dex/templates/dex-ingress.yaml create mode 100644 resources/dex/templates/dex-rbac-role.yaml create mode 100644 resources/dex/templates/dex-route-rule.yaml create mode 100644 resources/dex/templates/dex-service-account.yaml create mode 100644 resources/dex/templates/dex-service.yaml create mode 100644 resources/dex/templates/tests/test-dex-connection.yaml create mode 100644 resources/dex/values.yaml create mode 100644 resources/helm-broker-repo/README.md create mode 100644 resources/helm-broker-repo/bundles/redis-0.0.3/README.md create mode 100644 resources/helm-broker-repo/bundles/redis-0.0.3/chart/redis/.helmignore create mode 100644 resources/helm-broker-repo/bundles/redis-0.0.3/chart/redis/Chart.yaml create mode 100644 resources/helm-broker-repo/bundles/redis-0.0.3/chart/redis/README.md create mode 100644 resources/helm-broker-repo/bundles/redis-0.0.3/chart/redis/templates/_helpers.tpl create mode 100644 resources/helm-broker-repo/bundles/redis-0.0.3/chart/redis/templates/deployment.yaml create mode 100644 resources/helm-broker-repo/bundles/redis-0.0.3/chart/redis/templates/networkpolicy.yaml create mode 100644 resources/helm-broker-repo/bundles/redis-0.0.3/chart/redis/templates/pvc.yaml create mode 100644 resources/helm-broker-repo/bundles/redis-0.0.3/chart/redis/templates/secrets.yaml create mode 100644 resources/helm-broker-repo/bundles/redis-0.0.3/chart/redis/templates/svc.yaml create mode 100644 resources/helm-broker-repo/bundles/redis-0.0.3/chart/redis/templates/tests/test-redis-connection.yaml create mode 100644 resources/helm-broker-repo/bundles/redis-0.0.3/chart/redis/values.yaml create mode 100644 resources/helm-broker-repo/bundles/redis-0.0.3/meta.yaml create mode 100644 resources/helm-broker-repo/bundles/redis-0.0.3/plans/enterprise/bind.yaml create mode 100644 resources/helm-broker-repo/bundles/redis-0.0.3/plans/enterprise/create-instance-schema.json create mode 100644 resources/helm-broker-repo/bundles/redis-0.0.3/plans/enterprise/meta.yaml create mode 100644 resources/helm-broker-repo/bundles/redis-0.0.3/plans/enterprise/values.yaml create mode 100644 resources/helm-broker-repo/bundles/redis-0.0.3/plans/micro/bind.yaml create mode 100644 resources/helm-broker-repo/bundles/redis-0.0.3/plans/micro/create-instance-schema.json create mode 100644 resources/helm-broker-repo/bundles/redis-0.0.3/plans/micro/meta.yaml create mode 100644 resources/helm-broker-repo/bundles/redis-0.0.3/plans/micro/values.yaml create mode 100755 resources/helm-broker-repo/development/check.sh create mode 100755 resources/helm-broker-repo/development/sync-bundles.sh create mode 100644 resources/helm-broker-repo/install.sh create mode 100644 resources/helm-broker-repo/provisioning/po.yaml create mode 100755 resources/helm-broker-repo/provisioning/provision-bundles.sh create mode 100644 resources/helm-broker-repo/provisioning/pvc.yaml create mode 100644 resources/helm-broker-repo/update.sh create mode 100644 resources/istio/istio/Chart.yaml create mode 100644 resources/istio/istio/charts/egressgateway/Chart.yaml create mode 100644 resources/istio/istio/charts/egressgateway/templates/autoscale.yaml create mode 100644 resources/istio/istio/charts/egressgateway/templates/deployment.yaml create mode 100644 resources/istio/istio/charts/egressgateway/templates/service.yaml create mode 100644 resources/istio/istio/charts/egressgateway/templates/serviceaccount.yaml create mode 100644 resources/istio/istio/charts/galley/Chart.yaml create mode 100644 resources/istio/istio/charts/galley/templates/_helpers.tpl create mode 100644 resources/istio/istio/charts/galley/templates/clusterrole.yaml create mode 100644 resources/istio/istio/charts/galley/templates/clusterrolebinding.yaml create mode 100644 resources/istio/istio/charts/galley/templates/deployment.yaml create mode 100644 resources/istio/istio/charts/galley/templates/service.yaml create mode 100644 resources/istio/istio/charts/galley/templates/serviceaccount.yaml create mode 100644 resources/istio/istio/charts/galley/templates/validatingwebhook.yaml create mode 100644 resources/istio/istio/charts/ingress/Chart.yaml create mode 100644 resources/istio/istio/charts/ingress/templates/autoscale.yaml create mode 100644 resources/istio/istio/charts/ingress/templates/clusterrole.yaml create mode 100644 resources/istio/istio/charts/ingress/templates/clusterrolebinding.yaml create mode 100644 resources/istio/istio/charts/ingress/templates/deployment.yaml create mode 100644 resources/istio/istio/charts/ingress/templates/istio-ingress-certs.yaml create mode 100644 resources/istio/istio/charts/ingress/templates/service.yaml create mode 100644 resources/istio/istio/charts/ingress/templates/serviceaccount.yaml create mode 100644 resources/istio/istio/charts/ingressgateway/Chart.yaml create mode 100644 resources/istio/istio/charts/ingressgateway/templates/autoscale.yaml create mode 100644 resources/istio/istio/charts/ingressgateway/templates/deployment.yaml create mode 100644 resources/istio/istio/charts/ingressgateway/templates/service.yaml create mode 100644 resources/istio/istio/charts/ingressgateway/templates/serviceaccount.yaml create mode 100644 resources/istio/istio/charts/mixer/Chart.yaml create mode 100644 resources/istio/istio/charts/mixer/templates/_helpers.tpl create mode 100644 resources/istio/istio/charts/mixer/templates/clusterrole.yaml create mode 100644 resources/istio/istio/charts/mixer/templates/clusterrolebinding.yaml create mode 100644 resources/istio/istio/charts/mixer/templates/config.yaml create mode 100644 resources/istio/istio/charts/mixer/templates/configmap.yaml create mode 100644 resources/istio/istio/charts/mixer/templates/crds.yaml create mode 100644 resources/istio/istio/charts/mixer/templates/create-custom-resources-job.yaml create mode 100644 resources/istio/istio/charts/mixer/templates/deployment.yaml create mode 100644 resources/istio/istio/charts/mixer/templates/service.yaml create mode 100644 resources/istio/istio/charts/mixer/templates/serviceaccount.yaml create mode 100644 resources/istio/istio/charts/mixer/templates/statsdtoprom.yaml create mode 100644 resources/istio/istio/charts/pilot/Chart.yaml create mode 100644 resources/istio/istio/charts/pilot/templates/clusterrole.yaml create mode 100644 resources/istio/istio/charts/pilot/templates/clusterrolebinding.yaml create mode 100644 resources/istio/istio/charts/pilot/templates/crds.yaml create mode 100644 resources/istio/istio/charts/pilot/templates/deployment.yaml create mode 100644 resources/istio/istio/charts/pilot/templates/service.yaml create mode 100644 resources/istio/istio/charts/pilot/templates/serviceaccount.yaml create mode 100644 resources/istio/istio/charts/security/Chart.yaml create mode 100644 resources/istio/istio/charts/security/templates/_helpers.tpl create mode 100644 resources/istio/istio/charts/security/templates/cleanup-old-ca.yaml create mode 100644 resources/istio/istio/charts/security/templates/clusterrole.yaml create mode 100644 resources/istio/istio/charts/security/templates/clusterrolebinding.yaml create mode 100644 resources/istio/istio/charts/security/templates/deployment.yaml create mode 100644 resources/istio/istio/charts/security/templates/service.yaml create mode 100644 resources/istio/istio/charts/security/templates/serviceaccount.yaml create mode 100644 resources/istio/istio/charts/servicegraph/Chart.yaml create mode 100644 resources/istio/istio/charts/servicegraph/templates/_helpers.tpl create mode 100644 resources/istio/istio/charts/servicegraph/templates/deployment.yaml create mode 100644 resources/istio/istio/charts/servicegraph/templates/ingress.yaml create mode 100644 resources/istio/istio/charts/servicegraph/templates/service.yaml create mode 100644 resources/istio/istio/charts/sidecarInjectorWebhook/Chart.yaml create mode 100644 resources/istio/istio/charts/sidecarInjectorWebhook/templates/_helpers.tpl create mode 100644 resources/istio/istio/charts/sidecarInjectorWebhook/templates/clusterrole.yaml create mode 100644 resources/istio/istio/charts/sidecarInjectorWebhook/templates/clusterrolebinding.yaml create mode 100644 resources/istio/istio/charts/sidecarInjectorWebhook/templates/configmap.yaml create mode 100644 resources/istio/istio/charts/sidecarInjectorWebhook/templates/deployment.yaml create mode 100644 resources/istio/istio/charts/sidecarInjectorWebhook/templates/mutatingwebhook.yaml create mode 100644 resources/istio/istio/charts/sidecarInjectorWebhook/templates/service.yaml create mode 100644 resources/istio/istio/charts/sidecarInjectorWebhook/templates/serviceaccount.yaml create mode 100644 resources/istio/istio/charts/webhook/Chart.yaml create mode 100644 resources/istio/istio/charts/webhook/scripts/plugin.lua create mode 100644 resources/istio/istio/charts/webhook/templates/configmap.yaml create mode 100644 resources/istio/istio/charts/webhook/templates/deployment.yaml create mode 100644 resources/istio/istio/charts/webhook/templates/service.yaml create mode 100644 resources/istio/istio/requirements.yaml create mode 100644 resources/istio/istio/templates/_affinity.tpl create mode 100644 resources/istio/istio/templates/_helpers.tpl create mode 100644 resources/istio/istio/templates/configmap.yaml create mode 100644 resources/istio/istio/values.yaml create mode 100644 resources/prometheus-operator/.helmignore create mode 100644 resources/prometheus-operator/Chart.yaml create mode 100644 resources/prometheus-operator/README.md create mode 100644 resources/prometheus-operator/templates/_helpers.tpl create mode 100644 resources/prometheus-operator/templates/clusterrole.yaml create mode 100644 resources/prometheus-operator/templates/clusterrolebinding.yaml create mode 100644 resources/prometheus-operator/templates/create-servicemonitor-job.yaml create mode 100644 resources/prometheus-operator/templates/deployment.yaml create mode 100644 resources/prometheus-operator/templates/get-crd-job.yaml create mode 100644 resources/prometheus-operator/templates/serviceaccount.yaml create mode 100644 resources/prometheus-operator/templates/servicemonitor-configmap.yaml create mode 100644 resources/prometheus-operator/values.yaml create mode 100644 resources/remote-environments/.helmignore create mode 100644 resources/remote-environments/Chart.yaml create mode 100644 resources/remote-environments/README.md create mode 100644 resources/remote-environments/templates/NOTES.txt create mode 100644 resources/remote-environments/templates/_helpers.tpl create mode 100644 resources/remote-environments/templates/deployment.yaml create mode 100644 resources/remote-environments/templates/ingress.yaml create mode 100644 resources/remote-environments/templates/remote-env.yaml create mode 100644 resources/remote-environments/templates/role-binding.yaml create mode 100644 resources/remote-environments/templates/service.yaml create mode 100644 resources/remote-environments/templates/tests/test-acceptance.yaml create mode 100644 resources/remote-environments/values.yaml create mode 100644 resources/tiller/install.sh create mode 100644 resources/tiller/tiller.yaml create mode 100644 tests/acceptance/.gitignore create mode 100644 tests/acceptance/Dockerfile create mode 100644 tests/acceptance/Gopkg.lock create mode 100644 tests/acceptance/Gopkg.toml create mode 100644 tests/acceptance/Jenkinsfile create mode 100644 tests/acceptance/README.md create mode 100755 tests/acceptance/build.sh create mode 100644 tests/acceptance/dex/dex_test.go create mode 100755 tests/acceptance/entrypoint.sh create mode 100644 tests/acceptance/remote-environment/README.md create mode 100644 tests/acceptance/remote-environment/cmd/fake-gateway/main.go create mode 100644 tests/acceptance/remote-environment/cmd/gateway-client/main.go create mode 100644 tests/acceptance/remote-environment/contrib/Dockerfile create mode 100755 tests/acceptance/remote-environment/contrib/build.sh create mode 100644 tests/acceptance/remote-environment/contrib/pod.yaml create mode 100644 tests/acceptance/remote-environment/contrib/rbac.yaml create mode 100644 tests/acceptance/remote-environment/re_test.go create mode 100644 tests/acceptance/remote-environment/suite/binding_usage.go create mode 100644 tests/acceptance/remote-environment/suite/istio.go create mode 100644 tests/acceptance/remote-environment/suite/kubernetes.go create mode 100644 tests/acceptance/remote-environment/suite/remote_environment.go create mode 100644 tests/acceptance/remote-environment/suite/testsuite.go create mode 100644 tests/acceptance/servicecatalog/README.md create mode 100644 tests/acceptance/servicecatalog/binding_usage_test.go create mode 100644 tests/acceptance/servicecatalog/cmd/env-tester/README.md create mode 100644 tests/acceptance/servicecatalog/cmd/env-tester/main.go create mode 100644 tests/acceptance/servicecatalog/helpers_test.go create mode 100644 tests/acceptance/servicecatalog/servicecatalog_test.go create mode 100644 tests/acceptance/servicecatalog/wait/wait.go create mode 100644 tests/api-controller-acceptance-tests/Dockerfile create mode 100644 tests/api-controller-acceptance-tests/Gopkg.lock create mode 100644 tests/api-controller-acceptance-tests/Gopkg.toml create mode 100755 tests/api-controller-acceptance-tests/Jenkinsfile create mode 100644 tests/api-controller-acceptance-tests/README.md create mode 100644 tests/api-controller-acceptance-tests/apicontroller/apicontroller_test.go create mode 100644 tests/api-controller-acceptance-tests/apicontroller/fixture.go create mode 100644 tests/application-connector-tests/.gitignore create mode 100644 tests/application-connector-tests/Dockerfile create mode 100644 tests/application-connector-tests/Gopkg.lock create mode 100644 tests/application-connector-tests/Gopkg.toml create mode 100644 tests/application-connector-tests/Jenkinsfile create mode 100644 tests/application-connector-tests/README.md create mode 100755 tests/application-connector-tests/scripts/entrypoint.sh create mode 100644 tests/application-connector-tests/test/metadata/apitests/health_test.go create mode 100644 tests/application-connector-tests/test/metadata/apitests/metadata_test.go create mode 100644 tests/application-connector-tests/test/metadata/k8stests/k8sresource_test.go create mode 100644 tests/application-connector-tests/test/metadata/testkit/config.go create mode 100644 tests/application-connector-tests/test/metadata/testkit/k8sresources_check.go create mode 100644 tests/application-connector-tests/test/metadata/testkit/k8sresources_client.go create mode 100644 tests/application-connector-tests/test/metadata/testkit/metadata_api_client.go create mode 100644 tests/application-connector-tests/test/metadata/testkit/model.go create mode 100755 tests/connector-service-tests/.gitignore create mode 100755 tests/connector-service-tests/Dockerfile create mode 100755 tests/connector-service-tests/Gopkg.lock create mode 100755 tests/connector-service-tests/Gopkg.toml create mode 100755 tests/connector-service-tests/Jenkinsfile create mode 100755 tests/connector-service-tests/README.md create mode 100755 tests/connector-service-tests/scripts/entrypoint.sh create mode 100755 tests/connector-service-tests/test/apitests/connector_test.go create mode 100755 tests/connector-service-tests/test/testkit/certs.go create mode 100755 tests/connector-service-tests/test/testkit/config.go create mode 100755 tests/connector-service-tests/test/testkit/connectorclient.go create mode 100755 tests/connector-service-tests/test/testkit/model.go create mode 100644 tests/event-bus/.gitignore create mode 100644 tests/event-bus/Gopkg.lock create mode 100644 tests/event-bus/Gopkg.toml create mode 100644 tests/event-bus/Jenkinsfile create mode 100644 tests/event-bus/README.md create mode 100644 tests/event-bus/e2e-subscriber/Dockerfile create mode 100644 tests/event-bus/e2e-subscriber/Makefile create mode 100755 tests/event-bus/e2e-subscriber/dockerBuild.sh create mode 100644 tests/event-bus/e2e-subscriber/e2e-subscriber.go create mode 100644 tests/event-bus/e2e-tester/Dockerfile create mode 100644 tests/event-bus/e2e-tester/Makefile create mode 100755 tests/event-bus/e2e-tester/dockerBuild.sh create mode 100644 tests/event-bus/e2e-tester/e2e-tester.go create mode 100755 tests/format.sh create mode 100644 tests/gateway-tests/Dockerfile create mode 100644 tests/gateway-tests/Gopkg.lock create mode 100644 tests/gateway-tests/Gopkg.toml create mode 100755 tests/gateway-tests/Jenkinsfile create mode 100644 tests/gateway-tests/README.md create mode 100755 tests/gateway-tests/can-i-commit.sh create mode 100755 tests/gateway-tests/entrypoint.sh create mode 100644 tests/gateway-tests/test/apitests/gateway_events_test.go create mode 100644 tests/gateway-tests/test/apitests/gateway_health_test.go create mode 100644 tests/gateway-tests/test/testkit/config.go create mode 100644 tests/kubeless-test-client/Dockerfile create mode 100644 tests/kubeless-test-client/Jenkinsfile create mode 100644 tests/kubeless-test-client/README.md create mode 100644 tests/kubeless-test-client/dependecies.json create mode 100644 tests/kubeless-test-client/hello.js create mode 100644 tests/kubeless-test-client/ingress.yaml create mode 100644 tests/kubeless-test-client/route.yaml create mode 100644 tests/kubeless-test-client/test-kubeless.go create mode 100644 tests/test-environments/.gitignore create mode 100644 tests/test-environments/Dockerfile create mode 100644 tests/test-environments/Gopkg.lock create mode 100644 tests/test-environments/Gopkg.toml create mode 100644 tests/test-environments/Jenkinsfile create mode 100644 tests/test-environments/README.md create mode 100644 tests/test-environments/cmd/quantity/main.go create mode 100644 tests/test-environments/sample-namespace.yaml create mode 100755 tests/test-environments/test-environments.sh create mode 100644 tests/test-logging-monitoring/.gitignore create mode 100644 tests/test-logging-monitoring/Dockerfile create mode 100644 tests/test-logging-monitoring/Jenkinsfile create mode 100644 tests/test-logging-monitoring/README.md create mode 100644 tests/test-logging-monitoring/test-logging-monitoring.go create mode 100644 tests/ui-api-layer-acceptance-tests/.gitignore create mode 100644 tests/ui-api-layer-acceptance-tests/Dockerfile create mode 100644 tests/ui-api-layer-acceptance-tests/Gopkg.lock create mode 100644 tests/ui-api-layer-acceptance-tests/Gopkg.toml create mode 100644 tests/ui-api-layer-acceptance-tests/Jenkinsfile create mode 100644 tests/ui-api-layer-acceptance-tests/README.md create mode 100755 tests/ui-api-layer-acceptance-tests/before-commit.sh create mode 100644 tests/ui-api-layer-acceptance-tests/brokerinstaller/brokerinstaller.go create mode 100644 tests/ui-api-layer-acceptance-tests/domain/k8s/limitrange_test.go create mode 100644 tests/ui-api-layer-acceptance-tests/domain/k8s/resourcequota_test.go create mode 100644 tests/ui-api-layer-acceptance-tests/domain/servicecatalog/binding_test.go create mode 100644 tests/ui-api-layer-acceptance-tests/domain/servicecatalog/binding_usage_test.go create mode 100644 tests/ui-api-layer-acceptance-tests/domain/servicecatalog/broker_test.go create mode 100644 tests/ui-api-layer-acceptance-tests/domain/servicecatalog/class_test.go create mode 100644 tests/ui-api-layer-acceptance-tests/domain/servicecatalog/instance_test.go create mode 100644 tests/ui-api-layer-acceptance-tests/domain/servicecatalog/servicecatalog_test.go create mode 100644 tests/ui-api-layer-acceptance-tests/domain/servicecatalog/testdata/charts/ups-broker/Chart.yaml create mode 100644 tests/ui-api-layer-acceptance-tests/domain/servicecatalog/testdata/charts/ups-broker/README.md create mode 100644 tests/ui-api-layer-acceptance-tests/domain/servicecatalog/testdata/charts/ups-broker/templates/_helpers.tpl create mode 100644 tests/ui-api-layer-acceptance-tests/domain/servicecatalog/testdata/charts/ups-broker/templates/broker-deployment.yaml create mode 100644 tests/ui-api-layer-acceptance-tests/domain/servicecatalog/testdata/charts/ups-broker/templates/broker-register.yaml create mode 100644 tests/ui-api-layer-acceptance-tests/domain/servicecatalog/testdata/charts/ups-broker/templates/broker-service.yaml create mode 100644 tests/ui-api-layer-acceptance-tests/domain/servicecatalog/testdata/charts/ups-broker/values.yaml create mode 100644 tests/ui-api-layer-acceptance-tests/graphql/client.go create mode 100644 tests/ui-api-layer-acceptance-tests/graphql/request.go create mode 100644 tests/ui-api-layer-acceptance-tests/k8s/client.go create mode 100644 tests/ui-api-layer-acceptance-tests/k8s/config.go create mode 100644 tests/ui-api-layer-acceptance-tests/tester.go create mode 100644 tests/ui-api-layer-acceptance-tests/waiter/waiter.go create mode 100644 tools/alpine-net/Dockerfile create mode 100644 tools/alpine-net/Jenkinsfile create mode 100644 tools/alpine-net/README.md create mode 100644 tools/stability-checker/.gitignore create mode 100644 tools/stability-checker/Gopkg.lock create mode 100644 tools/stability-checker/Gopkg.toml create mode 100644 tools/stability-checker/Jenkinsfile create mode 100644 tools/stability-checker/README.md create mode 100755 tools/stability-checker/before-commit.sh create mode 100644 tools/stability-checker/cmd/logs-printer/internal/printer/dto.go create mode 100644 tools/stability-checker/cmd/logs-printer/internal/printer/printer.go create mode 100644 tools/stability-checker/cmd/logs-printer/main.go create mode 100644 tools/stability-checker/cmd/stability-checker/main.go create mode 100644 tools/stability-checker/deploy/chart/stability-checker/.helmignore create mode 100644 tools/stability-checker/deploy/chart/stability-checker/Chart.yaml create mode 100644 tools/stability-checker/deploy/chart/stability-checker/templates/_helpers.tpl create mode 100644 tools/stability-checker/deploy/chart/stability-checker/templates/configmap.yaml create mode 100644 tools/stability-checker/deploy/chart/stability-checker/templates/deploy.yaml create mode 100644 tools/stability-checker/deploy/chart/stability-checker/templates/role-binding.yaml create mode 100644 tools/stability-checker/deploy/chart/stability-checker/templates/role.yaml create mode 100644 tools/stability-checker/deploy/chart/stability-checker/templates/service-account.yaml create mode 100644 tools/stability-checker/deploy/chart/stability-checker/values.yaml create mode 100644 tools/stability-checker/deploy/stability-checker/Dockerfile create mode 100644 tools/stability-checker/internal/dto.go create mode 100644 tools/stability-checker/internal/notifier/config.go create mode 100644 tools/stability-checker/internal/notifier/dep.go create mode 100644 tools/stability-checker/internal/notifier/notifier.go create mode 100644 tools/stability-checker/internal/notifier/renderer.go create mode 100644 tools/stability-checker/internal/notifier/slack_client.go create mode 100644 tools/stability-checker/internal/notifier/templates.go create mode 100644 tools/stability-checker/internal/runner/dep.go create mode 100644 tools/stability-checker/internal/runner/runner.go create mode 100644 tools/stability-checker/local/helpers/isready.sh create mode 100755 tools/stability-checker/local/input/testing-kyma.sh create mode 100644 tools/stability-checker/local/input/utils.sh create mode 100755 tools/stability-checker/local/provision_volume.sh create mode 100644 tools/stability-checker/local/provisioning.yaml create mode 100644 tools/stability-checker/platform/logger/config.go create mode 100644 tools/stability-checker/platform/logger/doc.go create mode 100644 tools/stability-checker/platform/logger/logger.go create mode 100644 tools/stability-checker/platform/logger/logger_mock.go create mode 100644 tools/watch-pods/.gitignore create mode 100644 tools/watch-pods/Dockerfile create mode 100644 tools/watch-pods/Gopkg.lock create mode 100644 tools/watch-pods/Gopkg.toml create mode 100644 tools/watch-pods/Jenkinsfile create mode 100644 tools/watch-pods/README.md create mode 100755 tools/watch-pods/build.sh create mode 100644 tools/watch-pods/internal/tester/tester.go create mode 100644 tools/watch-pods/internal/tester/tester_test.go create mode 100644 tools/watch-pods/internal/tester/watcher.go create mode 100644 tools/watch-pods/internal/tester/watcher_test.go create mode 100644 tools/watch-pods/main.go diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 000000000000..063798c7d1ec --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,33 @@ +--- +name: Bug report +about: Report a bug in the project +--- + +Confirm these statements before you submit the issue: + +- [ ] I have searched open and closed issues for duplicates. +- [ ] I have read the contributing guidelines. +--- + +**Description** + + + + + +**Expected result** + + + +**Actual result** + + + +**Steps to reproduce** + + + +**Troubleshooting** + + diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 000000000000..4d762f869745 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,22 @@ +--- +name: Feature request +about: Suggest an improvement to the project +--- + +Confirm these statements before you submit the issue: + +- [ ] I have searched open and closed issues for duplicates. +- [ ] I have read the contributing guidelines. +--- + +**Description** + + + +**Reasons** + + + +**Attachments** + + diff --git a/.github/issue-template.md b/.github/issue-template.md new file mode 100644 index 000000000000..35a4d4c5cdc7 --- /dev/null +++ b/.github/issue-template.md @@ -0,0 +1,9 @@ +Confirm these statements before you submit the issue: + +- [ ] I have searched open and closed issues for duplicates. +- [ ] I have read the contributing guidelines. +--- + +**Description** + + diff --git a/.github/pull-request-template.md b/.github/pull-request-template.md new file mode 100644 index 000000000000..dc2af3a8cb7a --- /dev/null +++ b/.github/pull-request-template.md @@ -0,0 +1,19 @@ +Confirm these statements before you submit your pull request: + +- [ ] I have read and submitted the required [Contributor Licence Agreements](https://github.com/kyma-project/community/blob/master/CONTRIBUTING.md#agreements-and-licenses). +- [ ] This pull request follows the contributing guidelines, recommended Git workflow, and templates. +- [ ] I have tested my changes. +- [ ] I have updated the relevant documentation. +--- + +**Description** + +Changes proposed in this pull request: + +- +- +- + +**Issue link** + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000000..cc643a667b82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +.idea/ +*.iml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# vendor +vendor/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +### OSX template +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk +resources/examples/lambda-messages/node_modules + +# Temporary files +local/tmp/ + +# Installer temporary files +/temp-* + +!templates diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000000..e7ad9bbdb254 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,207 @@ +# This file provides an overview of code owners in the `kyma` repository. + +# Each line is a file pattern followed by one or more owners. +# The last matching pattern has the most precedence. +# For more details, read the following article on GitHub: https://help.github.com/articles/about-codeowners/. + +# These are the default owners for the whole content of the `kyma` repository. The default owners are automatically added as reviewers when you open a pull request, unless different owners are specified in the file. +* @derberg @pbochynski @PK85 @a-thaler + +# The `installation` directory and subdirectories +/installation/ @Tomasz-Smelcerz-SAP @strekm @jakkab @crabtree @aszecowka @PK85 @mszostok @piotrmiskiewicz @polskikiel @damianpacierpnikatsap @piotrmscsap @pssap @mwieczorek + +# The `cluster-prerequisites` subdirectory +/resources/cluster-prerequisites @Tomasz-Smelcerz-SAP @strekm @jakkab @crabtree @aszecowka @PK85 @mszostok @piotrmiskiewicz @polskikiel @damianpacierpnikatsap @piotrmscsap @pssap + +# The `cluster-essentials` subchart +/resources/cluster-essentials/ @damianpacierpnikatsap @damjatsap @PK85 + +# The `api-controller` subchart +/resources/core/charts/api-controller/ @damianpacierpnikatsap @piotrmscsap @pssap + +# The `apiserver-proxy` subchart +/resources/core/charts/apiserver-proxy/ @damianpacierpnikatsap @piotrmscsap @pssap + +# The `azure-broker` subchart +/resources/core/charts/azure-broker/ @Tomasz-Smelcerz-SAP @strekm @jakkab @crabtree + +# The `cluster-users` subchart +/resources/core/charts/cluster-users/ @damianpacierpnikatsap @piotrmscsap @pssap + +# The `configurations-generator` subchart +/resources/core/charts/configurations-generator @damianpacierpnikatsap @piotrmscsap @pssap + +# The `application-connector` subchart +/resources/core/charts/application-connector/ @lszymik @akgalwas @janmedrek + +# The `console` subchart +/resources/core/charts/console/ @pekura @maxmarkus @jesusreal @kwiatekus @dariadomagala @hardl @y-kkamil @katadus @antiheld + +# The `dex` subchart +/resources/core/charts/dex/ @damianpacierpnikatsap @piotrmscsap @pssap + +# The `environments` subchart +/resources/core/charts/environments/ @Tomasz-Smelcerz-SAP @strekm @jakkab @crabtree + +# The `helm-broker` subchart +/resources/core/charts/helm-broker/ @aszecowka @PK85 @mszostok @piotrmiskiewicz @polskikiel + +# The `monitoring` subchart +/resources/core/charts/monitoring/ @gchbg @joek @rakesh-garimella @sayanh @venturasr + +# The `kubeless` subchart +/resources/core/charts/kubeless/ @gchbg @joek @rakesh-garimella @sayanh @venturasr + +# The `minio` subchart +/resources/core/charts/minio/ @aszecowka @PK85 @mszostok @piotrmiskiewicz @polskikiel + +# The `event-bus` subchart +/resources/core/charts/event-bus/ @a-thaler @Abd4llA @montaro @abbi-gaurav @marcobebway @radufa @sslavic + +# The `remote-environment-broker` subchart +/resources/core/charts/remote-environment-broker @aszecowka @PK85 @mszostok @piotrmiskiewicz @polskikiel + +# The `service-catalog` subchart +/resources/core/charts/service-catalog/ @aszecowka @PK85 @mszostok @piotrmiskiewicz @polskikiel @derberg + +# The `binding-usage-controller` subchart +/resources/core/charts/service-catalog/charts/binding-usage-controller/ @aszecowka @PK85 @mszostok @piotrmiskiewicz @polskikiel + +# The `catalog-service-api` subchart +/resources/core/charts/service-catalog/charts/catalog-service-api/ @akucharska @michal-hudy @pkosiec @mjasinski5 @derberg @magicmatatjahu + +# The `catalog-service-ui` subchart +/resources/core/charts/service-catalog/charts/catalog-service-ui/ @akucharska @michal-hudy @pkosiec @mjasinski5 @derberg @magicmatatjahu + +# The `catalog-ui` subchart +/resources/core/charts/service-catalog/charts/catalog-ui/ @akucharska @michal-hudy @pkosiec @mjasinski5 @derberg @magicmatatjahu + +# The `instances-ui` subchart +/resources/core/charts/service-catalog/charts/instances-ui/ @akucharska @michal-hudy @pkosiec @mjasinski5 @derberg @magicmatatjahu + +# The `service-instances-ui` subchart +/resources/core/charts/service-catalog/charts/service-instances-ui/ @akucharska @michal-hudy @pkosiec @mjasinski5 @derberg @magicmatatjahu + +# The `docs` subchart +/resources/core/charts/docs/ @akucharska @michal-hudy @pkosiec @mjasinski5 @derberg @magicmatatjahu + +# The `ui-api` subchart +/resources/core/charts/ui-api/ @akucharska @michal-hudy @pkosiec @mjasinski5 @derberg @magicmatatjahu @aszecowka @PK85 @mszostok @piotrmiskiewicz @polskikiel + +# The `templates` chart +/resources/core/templates/ @aszecowka @PK85 @mszostok @piotrmiskiewicz @polskikiel + +# The `templates` chart +/resources/core/templates/tests/ @aszecowka @PK85 @mszostok @piotrmiskiewicz @polskikiel @akucharska @michal-hudy @pkosiec @mjasinski5 @derberg @magicmatatjahu @pekura @maxmarkus @jesusreal @kwiatekus @dariadomagala @hardl @y-kkamil @katadus @antiheld + +# The `messaging` subdirectory +/resources/examples/lambda-messages/ @pbochynski @PK85 + +# The `tiller` subdirectory +/resources/tiller/ @aszecowka @PK85 @mszostok @piotrmiskiewicz @polskikiel + +# The `helm-broker-repo` subdirectory +/resources/helm-broker-repo/ @aszecowka @PK85 @mszostok @piotrmiskiewicz @polskikiel + +# The `istio` subdirectory +/resources/istio/ @damianpacierpnikatsap @piotrmscsap @pssap + +# The `jaeger` charts +/resources/core/charts/jaeger/ @a-thaler @Abd4llA @montaro @abbi-gaurav @marcobebway @radufa @sslavic + +# The `prometheus-operator` chart +/resources/prometheus-operator/ @gchbg @joek @rakesh-garimella @sayanh @venturasr + +# The `remote environment` chart +/resources/remote-environments/ @lszymik @akgalwas @janmedrek + +# Components +# Api controller Component +/components/api-controller/ @damianpacierpnikatsap @piotrmscsap @pssap + +# Binding usage controller Component +/components/binding-usage-controller/ @aszecowka @PK85 @mszostok @piotrmiskiewicz @polskikiel + +# Installer Component +/components/installer/ @Tomasz-Smelcerz-SAP @strekm @jakkab @crabtree + +# Configurations Generator Component +/components/configurations-generator/ @damianpacierpnikatsap @piotrmscsap @pssap + +# Environments controller Component +/components/environments/ @Tomasz-Smelcerz-SAP @strekm @jakkab @crabtree + +# Istio Extensions Component +/components/istio-webhook/ @damianpacierpnikatsap @piotrmscsap @pssap + +# Remote environment controller Component +/components/remote-environment-broker/ @aszecowka @PK85 @mszostok @piotrmiskiewicz @polskikiel + +# Helm broker controller Component +/components/helm-broker/ @aszecowka @PK85 @mszostok @piotrmiskiewicz @polskikiel + +# UI API Layer Component +/components/ui-api-layer/ @akucharska @michal-hudy @pkosiec @mjasinski5 @derberg @magicmatatjahu @aszecowka @PK85 @mszostok @piotrmiskiewicz @polskikiel + +# IDPPreset Component +/components/idppreset/ @pekura @maxmarkus @jesusreal @kwiatekus @dariadomagala @hardl @y-kkamil @katadus @antiheld + +# Connector Service Component +/components/connector-service/ @lszymik @akgalwas @janmedrek + +# Application Connector Component +/components/application-connector/ @lszymik @akgalwas @janmedrek + +# Event Bus Component +/components/event-bus/ @Abd4llA @montaro @abbi-gaurav @marcobebway @radufa @sslavic + +# Tests +# acceptance tests +/tests/acceptance/ @mszostok @polskikiel @piotrmiskiewicz @aszecowka @PK85 +/tests/acceptance/dex @pssap @piotrmscsap + +# test-environments +/tests/test-environemnts/ @Tomasz-Smelcerz-SAP @strekm @jakkab @crabtree + +# test-logging-monitoring tests +/tests/test-logging-monitoring/ @rakesh-garimella @joek @sayanh @venturasr @gchbg + +# api-controller acceptance tests +/tests/api-controller-acceptance-tests/ @damianpacierpnikatsap @piotrmscsap @pssap + +# UI API Layer acceptance tests +/tests/ui-api-layer-acceptance-tests/ @akucharska @michal-hudy @pkosiec @mjasinski5 @derberg @magicmatatjahu @aszecowka @PK85 @mszostok @piotrmiskiewicz @polskikiel + +# Connector Service Tests +/tests/connector-service-tests/ @lszymik @akgalwas @janmedrek + +# Application Connector Tests +/tests/application-connector-tests/ @lszymik @akgalwas @janmedrekk + +# Tools +# Stability Checker Component +/tools/stability-checker/ @aszecowka @PK85 @mszostok @piotrmiskiewicz @polskikiel + +# The alpine-net directory +/tools/alpine-net/ @suleymanakbas91 @ahmedami @clebs @a-thaler @lilitgh + +# The watch-pods directory +/tools/watch-pods/ @suleymanakbas91 @ahmedami @clebs @a-thaler @lilitgh + +# kubeless-test-client tests +/tests/kubeless-test-client/ @rakesh-garimella @joek @sayanh @venturasr @gchbg + +# Event Bus Tests +/tests/event-bus/ @Abd4llA @montaro @abbi-gaurav @marcobebway @radufa @sslavic + +# All .md files +*.md @kazydek @klaudiagrz @tomekpapiernik @dpolitesap + +# All files and subdirectories in /docs +/docs/ @kazydek @klaudiagrz @tomekpapiernik @dpolitesap + +# Orchestrator file +orchestrator.Jenkinsfile @suleymanakbas91 @clebs @a-thaler @lilitgh + +# Governance file +governance.Jenkinsfile @suleymanakbas91 @clebs @a-thaler @lilitgh @akucharska @michal-hudy @pkosiec @mjasinski5 @derberg @magicmatatjahu diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000000..de028b1f6a8e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,3 @@ +# Code of conduct + +Each contributor and maintainer of this project agrees to follow the [community Code of Conduct](https://github.com/kyma-project/community/blob/master/CODE_OF_CONDUCT.md) that relies on the CNCF Code of Conduct. Read it to learn about the agreed standards of behavior, shared values that govern our community, and details on how to report any suspected Code of Conduct violations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000000..31c67f06ed7b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,21 @@ +## Overview + +To contribute to this project, follow the rules from the general [CONTRIBUTING.md](https://github.com/kyma-project/community/blob/master/CONTRIBUTING.md) document in the `community` repository. + +## Documentation types + +These are the main types of documents used in the project: + +* `NOTES.txt`- This document type is an integral part of Helm charts. Its content displays in the terminal window after the chart installs. It is not mandatory to include `NOTES.txt` documents for sub-charts because the system ignores these documents. Provide `NOTES.txt` documents for the Core components. Use the [template](https://github.com/kyma-project/community/blob/master/guidelines/templates/resources/NOTES.txt) to create `NOTES.txt` documents. + +* `README.md` - This document type contains information about other files in the directory. Each main directory in this repository, such as `cluster` or `resources`, requires a `README.md` document. Additionally, each chart and sub-chart needs such a document. Add a `README.md` document when you create a new directory or chart. Use the [template](https://github.com/kyma-project/community//blob/master/guidelines/templates/resources/chart_README.md) to create `README.md` documents. + +Do not change the names or the order of the main sections in the `README.md` documents. However, you can create subsections to adjust each `README.md` document to the project's or chart's specific requirements. See the example of a [README.md](resources/core/README.md) document. + +## Contribution rules + +Apart from the general rules described in the `community` repository, every `kyma` repository contributor must follow these basic rules: + +* Do not copy charts from the Internet. Customize the Helm charts and simplify them to pertain only to the specific use case. Apply this rule to all documents associated with the charts, such as `README.md` and `NOTES.txt` documents. +* Follow the `IfNotPresent` pulling policy. Do not use the `latest` tag for all `Deployments` definitions for the local installation. +* Adjust any data copied from the Internet to the product needs. diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 000000000000..a3f57c034b10 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,107 @@ +#!/usr/bin/env groovy +def label = "kyma-${UUID.randomUUID().toString()}" +def commit_id='' +def isMaster = params.GIT_BRANCH == 'master' + +echo """ +******************************** +Job started with the following parameters: +DOCKER_REGISTRY=${env.DOCKER_REGISTRY} +DOCKER_CREDENTIALS=${env.DOCKER_CREDENTIALS} +GIT_REVISION=${params.GIT_REVISION} +GIT_BRANCH=${params.GIT_BRANCH} +APP_VERSION=${params.APP_VERSION} +******************************** +""" + +podTemplate(label: label) { + node(label) { + try { + timestamps { + ansiColor('xterm') { + timeout(time:40, unit:"MINUTES") { + stage("cleanup") { + cleanup() + } + + stage("checkout kyma") { + dir("kyma") { + checkout scm + } + } + + stage("build image") { + dir("kyma") { + sh "installation/cmd/ci.build.sh" + } + } + + stage("configure and test container") { + dir("kyma") { + sh "installation/cmd/ci.run.sh --non-interactive --exit-on-test-fail" + } + } + + if (isMaster && currentBuild.getPreviousBuild() && currentBuild.getPreviousBuild().getResult().toString() != "SUCCESS") { + echo "\033[32m Sending RECOVERY message on Slack \033[0m\n" + def recipients = emailextrecipients([[$class: 'DevelopersRecipientProvider'], [$class: 'RequesterRecipientProvider']]) + sendSlackNotification(":white_check_mark:Kyma heroes who made Kyma LOCAL great again: ${recipients}!\nSee details: ${env.BUILD_URL}console") + } + } + + if (isMaster) { + stage("save revision") { + dir("kyma") { + sh "git rev-parse --short HEAD > .git/commit-id" + commit_id = readFile('.git/commit-id') + commit_id = commit_id.trim() + } + } + } + } + } + } catch (ex) { + echo "Got exception: ${ex}" + currentBuild.result = "FAILURE" + def body = """${currentBuild.currentResult} ${env.JOB_NAME}${env.BUILD_DISPLAY_NAME}: on branch: ${params.GIT_BRANCH}. See details: ${env.BUILD_URL}""" + emailext body: body, recipientProviders: [[$class: 'DevelopersRecipientProvider'], [$class: 'CulpritsRecipientProvider'], [$class: 'RequesterRecipientProvider']], subject: "Kyma: ${currentBuild.currentResult}: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'" + if (isMaster) { + def culprits = emailextrecipients([[$class: 'CulpritsRecipientProvider'], [$class: 'RequesterRecipientProvider']]) + sendSlackNotification(":x:Kyma LOCAL BUILD FAILED! Committers since last success build: ${culprits}\nSee details: ${env.BUILD_URL}console") + } + } + } +} + +if(isMaster && currentBuild.currentResult == "SUCCESS") { + stage("trigger remote cluster"){ + build job: 'azure/master', parameters: [ + string(name:'REVISION', value: "${commit_id}")], + wait: false + } +} + +def sendSlackNotification(text) { + def channel = "#c4core-kyma-team" + echo "Sending notification on Slack to channel: ${channel}" + + withCredentials([string(credentialsId: 'kyma-slack-token', variable: 'token')]) { + sh """ + curl -H 'Content-type: application/json' \ + --data '{"text": "${text}", "channel": "${channel}"}' \ + https://sap-cx.slack.com/services/hooks/jenkins-ci?token=${token} + """ + } +} + +def cleanup(target = '') { + if (target) { + echo "cleaning up ${target}" + node(target) { + sh 'find . -not -name "." -not -name ".." -maxdepth 1 -exec rm -rf {} \\; ' + } + } else { + echo "cleaning up current node" + sh 'find . -not -name "." -not -name ".." -maxdepth 1 -exec rm -rf {} \\; ' + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000000..be36d55b6f12 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + 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 (c) 2018 SAP SE or an SAP affiliate company. All rights reserved. + + 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/NOTICE.md b/NOTICE.md new file mode 100644 index 000000000000..ccf38a4ad7df --- /dev/null +++ b/NOTICE.md @@ -0,0 +1 @@ +Copyright (c) 2018 SAP SE or an SAP affiliate company. All rights reserved. diff --git a/README.md b/README.md new file mode 100644 index 000000000000..d820f2df2cf0 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +

+ +

+ +## Overview + +Kyma is a cloud-native application development framework. + +It provides the last mile capabilities that a developer needs to build a cloud-native application using several open-source projects under the Cloud Native Computing Foundation (CNCF), such as Kubernetes, Istio, NATS, Kubeless, and Prometheus, to name a few. +It is designed natively on Kubernetes and, therefore, it is portable to all major cloud providers. + +Kyma allows you to integrate and extend products in a quick and modern way, using serverless computing and microservice architecture. + +The extensions and customizations you create are decoupled from the core applications, which means that: +* deployments are quick +* scaling is independent from the core applications +* the changes you make can be easily reverted without causing downtime of the production system + +Living outside of the core product, Kyma allows you to be completely language-agnostic and customize your solution using the technology stack you want to use, not the one the core product dictates. Additionally, Kyma follows the "batteries included" principle and comes with all of the "plumbing code" ready to use, allowing you to focus entirely on writing the domain code and business logic. + +[Read more](docs/kyma/docs/001-overview.md) about the product and its technology stack. + +Follow the sections for an overview of the `kyma` repository documentation, Kyma local deployment, and the available examples that you can test to learn how to use the product. + +### Documentation + +See the [`docs`](docs/README.md) folder to learn about [Kyma](docs/kyma/docs/) and its components. + +## Installation + +This section provides a reference to the local deployment of Kyma. + +### Local deployment + +To learn how to deploy Kyma locally, see the corresponding [Getting Started](docs/kyma/docs/031-gs-local-installation.md) guide. + +## Usage + +Learn how to use Kyma and test the available examples. + +### Examples + +Kyma comes with the ready-to-use code snippets that you can use to test the extensions and the core functionality. See the list of existing examples in the [`examples`](https://github.com/kyma-project/examples) repository. diff --git a/azure.Jenkinsfile b/azure.Jenkinsfile new file mode 100644 index 000000000000..ecfcce12e1e7 --- /dev/null +++ b/azure.Jenkinsfile @@ -0,0 +1,117 @@ +def label = sanitizeLabel(env.JOB_NAME, env.BUILD_NUMBER) +def isAzurePodCreated = false + +properties([ + buildDiscarder(logRotator(daysToKeepStr: '14', numToKeepStr: '30')), + disableConcurrentBuilds() +]) + +podTemplate(label: label) { + node(label) { + try { + timestamps { + timeout(time:40, unit:"MINUTES") { + ansiColor('xterm') { + stage("cleanup") { + cleanup() + } + + stage("checkout kyma scm") { + dir("kyma") { + checkout scm + } + } + + stage("build image") { + dir("kyma") { + sh "installation/cmd/ci.build.sh" + } + } + + env.IMAGE_TAG = UUID.randomUUID().toString() + + stage("push imige to ACR") { + withCredentials([usernamePassword(credentialsId: 'azure-kyma-spn', passwordVariable: 'ARM_CLIENT_SECRET', usernameVariable: 'ARM_CLIENT_ID'), + string(credentialsId: 'ci-azure-cluster-kubeconfig', variable: 'KUBECONFIG_JSON'), + string(credentialsId: 'ci-azure-cluster-acr-name', variable: 'ACR_NAME')]) { + dir("kyma") { + sh "installation/cmd/azure-ci.run.sh pushDockerImageToAcr" + } + } + } + + stage("run image on Azure k8s") { + isAzurePodCreated = true + withCredentials([usernamePassword(credentialsId: 'azure-kyma-spn', passwordVariable: 'ARM_CLIENT_SECRET', usernameVariable: 'ARM_CLIENT_ID'), + string(credentialsId: 'ci-azure-cluster-kubeconfig', variable: 'KUBECONFIG_JSON'), + string(credentialsId: 'ci-azure-cluster-acr-name', variable: 'ACR_NAME')]) { + def dockerEnv = "-e ACR_NAME -e KUBECONFIG_JSON -e IMAGE_TAG" + def dockerOpts = "-w='/kyma' --rm --volume ${WORKSPACE}/kyma:/kyma" + def dockerEntry = "--entrypoint /kyma/installation/cmd/azure-ci.run.sh" + def dockerEntryArg = "createPod" + sh "docker run $dockerOpts $dockerEnv $dockerEntry kyma-on-minikube:latest $dockerEntryArg" + } + } + + if (env.BRANCH_NAME == "master" && currentBuild.getPreviousBuild() && currentBuild.getPreviousBuild().getResult().toString() != "SUCCESS") { + echo "\033[32m Sending RECOVERY message on Slack \033[0m\n" + def recipients = emailextrecipients([[$class: 'DevelopersRecipientProvider'], [$class: 'RequesterRecipientProvider']]) + sendSlackNotification(":white_check_mark:Kyma heroes who made [Azure] Kyma LOCAL great again: ${recipients}!\nSee details: ${env.BUILD_URL}console") + } + } + } + } + } catch (ex) { + echo "Got exception: ${ex}" + currentBuild.result = "FAILURE" + def body = """${currentBuild.currentResult} ${env.JOB_NAME}${env.BUILD_DISPLAY_NAME}: on branch: ${env.BRANCH_NAME}. See details: ${env.BUILD_URL}""" + emailext body: body, recipientProviders: [[$class: 'DevelopersRecipientProvider'], [$class: 'CulpritsRecipientProvider'], [$class: 'RequesterRecipientProvider']], subject: "[AZURE] Kyma local: ${currentBuild.currentResult}: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'" + if (env.BRANCH_NAME == "master") { + def culprits = emailextrecipients([[$class: 'CulpritsRecipientProvider'], [$class: 'RequesterRecipientProvider']]) + sendSlackNotification(":x:[Azure] Kyma LOCAL BUILD FAILED! Committers since last success build: ${culprits}\nSee details: ${env.BUILD_URL}console") + } + } + finally { + if (isAzurePodCreated) { + echo "--- Delete minikube pod in azure cluster ---" + withCredentials([string(credentialsId: 'ci-azure-cluster-kubeconfig', variable: 'KUBECONFIG_JSON')]) { + def dockerEnv = "-e KUBECONFIG_JSON -e IMAGE_TAG" + def dockerOpts = "-w='/kyma' --rm --volume ${WORKSPACE}/kyma:/kyma" + def dockerEntry = "--entrypoint /kyma/installation/cmd/azure-ci.run.sh" + def dockerEntryArg = "deletePod" + sh "docker run $dockerOpts $dockerEnv $dockerEntry kyma-on-minikube:latest $dockerEntryArg" + } + } + } + } +} + +def sendSlackNotification(text) { + def channel = "#c4core-kyma-team" + echo "Sending notification on Slack to channel: ${channel}" + + withCredentials([string(credentialsId: 'kyma-slack-token', variable: 'token')]) { + sh """ + curl -H 'Content-type: application/json' \ + --data '{"text": "${text}", "channel": "${channel}"}' \ + https://sap-cx.slack.com/services/hooks/jenkins-ci?token=${token} + """ + } +} + +def sanitizeLabel(label, number) { + def labelSanitized = label.replaceAll(/[^-_.A-Za-z0-9]/, '_').take(62 - number.toString().size()) + "a${labelSanitized}${number}" +} + +def cleanup(target = '') { + if (target) { + echo "cleaning up ${target}" + node(target) { + sh 'find . -not -name "." -not -name ".." -maxdepth 1 -exec rm -rf {} \\; ' + } + } else { + echo "cleaning up current node" + sh 'find . -not -name "." -not -name ".." -maxdepth 1 -exec rm -rf {} \\; ' + } +} diff --git a/ci.Dockerfile b/ci.Dockerfile new file mode 100644 index 000000000000..ee3fb9c52327 --- /dev/null +++ b/ci.Dockerfile @@ -0,0 +1,62 @@ +FROM ubuntu:16.04 + +LABEL source="git@github.com:kyma-project/kyma.git" + +ARG KUBECTL_CLI_VERSION +ARG KUBELESS_CLI_VERSION +ARG MINIKUBE_VERSION +ARG HELM_VERSION + +# Get dependencies for curl of the docker +RUN apt-get update && apt-get install -y \ + bash \ + curl \ + jq \ + socat \ + sudo \ + vim \ + zip \ + && rm -rf /var/lib/apt/lists/* + +# Install kubectl +RUN curl -Lo /usr/local/bin/kubectl https://storage.googleapis.com/kubernetes-release/release/v$KUBECTL_CLI_VERSION/bin/linux/amd64/kubectl +RUN chmod +x /usr/local/bin/kubectl + +# Install Kubeless CLI +RUN curl -Lo /tmp/kubeless-binary.zip https://github.com/kubeless/kubeless/releases/download/v$KUBELESS_CLI_VERSION/kubeless_linux-amd64.zip && \ + unzip -uq /tmp/kubeless-binary.zip -d /tmp/ && \ + chmod +x /tmp/bundles/kubeless_linux-amd64/kubeless && \ + sudo mv /tmp/bundles/kubeless_linux-amd64/kubeless /usr/local/bin/ && \ + rm -rf /tmp/bundles && \ + rm -rf /tmp/kubeless-binary.zip + +# Install Minikube +RUN curl -Lo /usr/local/bin/minikube https://storage.googleapis.com/minikube/releases/v$MINIKUBE_VERSION/minikube-linux-amd64 +RUN chmod +x /usr/local/bin/minikube + +# Install Docker from Docker Inc. repositories. +RUN curl -sSL https://get.docker.com/ | sh + +# Install Helm +RUN curl -Lo /tmp/helm-linux-amd64.tar.gz https://kubernetes-helm.storage.googleapis.com/helm-v$HELM_VERSION-linux-amd64.tar.gz +RUN tar -xvf /tmp/helm-linux-amd64.tar.gz -C /tmp/ +RUN chmod +x /tmp/linux-amd64/helm && sudo mv /tmp/linux-amd64/helm /usr/local/bin/ + +# Copying into the container all the necessary files like scripts and resources definition +RUN mkdir /kyma + +COPY . /kyma + +ENV IGNORE_TEST_FAIL="true" +ENV RUN_TESTS="true" + +RUN echo 'alias kc="kubectl"' >> ~/.bashrc + +# minikube and docker start must be done on starting container to make it work +ENTRYPOINT /kyma/installation/scripts/docker-start.sh \ + && /kyma/installation/scripts/minikube.sh --vm-driver none \ + && /kyma/installation/scripts/installer-ci-local.sh \ + && /kyma/installation/scripts/is-installed.sh \ + && /kyma/installation/scripts/watch-pods.sh \ + && (($RUN_TESTS && /kyma/installation/scripts/testing.sh) || $IGNORE_TEST_FAIL) \ + && exec bash \ No newline at end of file diff --git a/components/api-controller/Dockerfile b/components/api-controller/Dockerfile new file mode 100644 index 000000000000..e9fdcb3fba75 --- /dev/null +++ b/components/api-controller/Dockerfile @@ -0,0 +1,6 @@ +FROM alpine:3.7 + +LABEL source="git@github.com:kyma-project/kyma.git" +ADD /bin/app /app + +ENTRYPOINT [ "/app"] \ No newline at end of file diff --git a/components/api-controller/Gopkg.lock b/components/api-controller/Gopkg.lock new file mode 100644 index 000000000000..674a78663aae --- /dev/null +++ b/components/api-controller/Gopkg.lock @@ -0,0 +1,201 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/davecgh/go-spew" + packages = ["spew"] + revision = "346938d642f2ec3594ed81d874461961cd0faa76" + version = "v1.1.0" + +[[projects]] + name = "github.com/ghodss/yaml" + packages = ["."] + revision = "0ca9ea5df5451ffdf184b4428c902747c2c11cd7" + version = "v1.0.0" + +[[projects]] + name = "github.com/gogo/protobuf" + packages = ["proto","sortkeys"] + revision = "342cbe0a04158f6dcb03ca0079991a51a4248c02" + version = "v0.5" + +[[projects]] + branch = "master" + name = "github.com/golang/glog" + packages = ["."] + revision = "23def4e6c14b4da8ac2ed8007337bc5eb5007998" + +[[projects]] + branch = "master" + name = "github.com/golang/groupcache" + packages = ["lru"] + revision = "66deaeb636dff1ac7d938ce666d090556056a4b0" + +[[projects]] + name = "github.com/golang/protobuf" + packages = ["proto","ptypes","ptypes/any","ptypes/duration","ptypes/timestamp"] + revision = "925541529c1fa6821df4e44ce2723319eb2be768" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "github.com/google/gofuzz" + packages = ["."] + revision = "24818f796faf91cd76ec7bddd72458fbced7a6c1" + +[[projects]] + name = "github.com/googleapis/gnostic" + packages = ["OpenAPIv2","compiler","extensions"] + revision = "ee43cbb60db7bd22502942cccbc39059117352ab" + version = "v0.1.0" + +[[projects]] + branch = "master" + name = "github.com/hashicorp/golang-lru" + packages = [".","simplelru"] + revision = "0fb14efe8c47ae851c0034ed7a448854d3d34cf3" + +[[projects]] + branch = "master" + name = "github.com/howeyc/gopass" + packages = ["."] + revision = "bf9dde6d0d2c004a008c27aaee91170c786f6db8" + +[[projects]] + name = "github.com/imdario/mergo" + packages = ["."] + revision = "163f41321a19dd09362d4c63cc2489db2015f1f4" + version = "0.3.2" + +[[projects]] + name = "github.com/json-iterator/go" + packages = ["."] + revision = "3353055b2a1a5ae1b6a8dfde887a524e7088f3a2" + version = "1.1.2" + +[[projects]] + name = "github.com/modern-go/concurrent" + packages = ["."] + revision = "bacd9c7ef1dd9b15be4a9909b8ac7a4e313eec94" + version = "1.0.3" + +[[projects]] + name = "github.com/modern-go/reflect2" + packages = ["."] + revision = "1df9eeb2bb81f327b96228865c5687bc2194af3f" + version = "1.0.0" + +[[projects]] + name = "github.com/satori/go.uuid" + packages = ["."] + revision = "f58768cc1a7a7e77a3bd49e98cdd21419399b6a3" + version = "v1.2.0" + +[[projects]] + name = "github.com/sirupsen/logrus" + packages = ["."] + revision = "d682213848ed68c0a260ca37d6dd5ace8423f5ba" + version = "v1.0.4" + +[[projects]] + name = "github.com/spf13/pflag" + packages = ["."] + revision = "e57e3eeb33f795204c1ca35f56c44f83227c6e66" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = ["ssh/terminal"] + revision = "85f98707c97e11569271e4d9b3d397e079c4f4d0" + +[[projects]] + branch = "master" + name = "golang.org/x/net" + packages = ["context","http2","http2/hpack","idna","lex/httplex"] + revision = "d25186b37f34ebdbbea8f488ef055638dfab272d" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = ["unix","windows"] + revision = "dd2ff4accc098aceecb86b36eaa7829b2a17b1c9" + +[[projects]] + name = "golang.org/x/text" + packages = ["collate","collate/build","internal/colltab","internal/gen","internal/tag","internal/triegen","internal/ucd","language","secure/bidirule","transform","unicode/bidi","unicode/cldr","unicode/norm","unicode/rangetable"] + revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" + version = "v0.3.0" + +[[projects]] + branch = "master" + name = "golang.org/x/time" + packages = ["rate"] + revision = "fbb02b2291d28baffd63558aa44b4b56f178d650" + +[[projects]] + branch = "master" + name = "golang.org/x/tools" + packages = ["go/ast/astutil","imports"] + revision = "059bec968c61383b574810040ba9410712de36c5" + +[[projects]] + name = "gopkg.in/inf.v0" + packages = ["."] + revision = "3887ee99ecf07df5b447e9b00d9c0b2adaa9f3e4" + version = "v0.9.0" + +[[projects]] + name = "gopkg.in/yaml.v2" + packages = ["."] + revision = "7f97868eec74b32b0982dd158a51a446d1da7eb5" + version = "v2.1.1" + +[[projects]] + name = "k8s.io/api" + packages = ["admissionregistration/v1alpha1","admissionregistration/v1beta1","apps/v1","apps/v1beta1","apps/v1beta2","authentication/v1","authentication/v1beta1","authorization/v1","authorization/v1beta1","autoscaling/v1","autoscaling/v2beta1","batch/v1","batch/v1beta1","batch/v2alpha1","certificates/v1beta1","core/v1","events/v1beta1","extensions/v1beta1","networking/v1","policy/v1beta1","rbac/v1","rbac/v1alpha1","rbac/v1beta1","scheduling/v1alpha1","settings/v1alpha1","storage/v1","storage/v1alpha1","storage/v1beta1"] + revision = "73d903622b7391f3312dcbac6483fed484e185f8" + version = "kubernetes-1.10.0" + +[[projects]] + branch = "master" + name = "k8s.io/apiextensions-apiserver" + packages = ["pkg/apis/apiextensions","pkg/apis/apiextensions/v1beta1","pkg/client/clientset/clientset","pkg/client/clientset/clientset/scheme","pkg/client/clientset/clientset/typed/apiextensions/v1beta1"] + revision = "69cef2ec8c370d2317c7de404cd222aafd88ca4a" + +[[projects]] + name = "k8s.io/apimachinery" + packages = ["pkg/api/errors","pkg/api/meta","pkg/api/resource","pkg/apis/meta/internalversion","pkg/apis/meta/v1","pkg/apis/meta/v1/unstructured","pkg/apis/meta/v1beta1","pkg/conversion","pkg/conversion/queryparams","pkg/fields","pkg/labels","pkg/runtime","pkg/runtime/schema","pkg/runtime/serializer","pkg/runtime/serializer/json","pkg/runtime/serializer/protobuf","pkg/runtime/serializer/recognizer","pkg/runtime/serializer/streaming","pkg/runtime/serializer/versioning","pkg/selection","pkg/types","pkg/util/cache","pkg/util/clock","pkg/util/diff","pkg/util/errors","pkg/util/framer","pkg/util/intstr","pkg/util/json","pkg/util/mergepatch","pkg/util/net","pkg/util/runtime","pkg/util/sets","pkg/util/strategicpatch","pkg/util/validation","pkg/util/validation/field","pkg/util/wait","pkg/util/yaml","pkg/version","pkg/watch","third_party/forked/golang/json","third_party/forked/golang/reflect"] + revision = "302974c03f7e50f16561ba237db776ab93594ef6" + version = "kubernetes-1.10.0" + +[[projects]] + name = "k8s.io/client-go" + packages = ["discovery","discovery/fake","kubernetes","kubernetes/fake","kubernetes/scheme","kubernetes/typed/admissionregistration/v1alpha1","kubernetes/typed/admissionregistration/v1alpha1/fake","kubernetes/typed/admissionregistration/v1beta1","kubernetes/typed/admissionregistration/v1beta1/fake","kubernetes/typed/apps/v1","kubernetes/typed/apps/v1/fake","kubernetes/typed/apps/v1beta1","kubernetes/typed/apps/v1beta1/fake","kubernetes/typed/apps/v1beta2","kubernetes/typed/apps/v1beta2/fake","kubernetes/typed/authentication/v1","kubernetes/typed/authentication/v1/fake","kubernetes/typed/authentication/v1beta1","kubernetes/typed/authentication/v1beta1/fake","kubernetes/typed/authorization/v1","kubernetes/typed/authorization/v1/fake","kubernetes/typed/authorization/v1beta1","kubernetes/typed/authorization/v1beta1/fake","kubernetes/typed/autoscaling/v1","kubernetes/typed/autoscaling/v1/fake","kubernetes/typed/autoscaling/v2beta1","kubernetes/typed/autoscaling/v2beta1/fake","kubernetes/typed/batch/v1","kubernetes/typed/batch/v1/fake","kubernetes/typed/batch/v1beta1","kubernetes/typed/batch/v1beta1/fake","kubernetes/typed/batch/v2alpha1","kubernetes/typed/batch/v2alpha1/fake","kubernetes/typed/certificates/v1beta1","kubernetes/typed/certificates/v1beta1/fake","kubernetes/typed/core/v1","kubernetes/typed/core/v1/fake","kubernetes/typed/events/v1beta1","kubernetes/typed/events/v1beta1/fake","kubernetes/typed/extensions/v1beta1","kubernetes/typed/extensions/v1beta1/fake","kubernetes/typed/networking/v1","kubernetes/typed/networking/v1/fake","kubernetes/typed/policy/v1beta1","kubernetes/typed/policy/v1beta1/fake","kubernetes/typed/rbac/v1","kubernetes/typed/rbac/v1/fake","kubernetes/typed/rbac/v1alpha1","kubernetes/typed/rbac/v1alpha1/fake","kubernetes/typed/rbac/v1beta1","kubernetes/typed/rbac/v1beta1/fake","kubernetes/typed/scheduling/v1alpha1","kubernetes/typed/scheduling/v1alpha1/fake","kubernetes/typed/settings/v1alpha1","kubernetes/typed/settings/v1alpha1/fake","kubernetes/typed/storage/v1","kubernetes/typed/storage/v1/fake","kubernetes/typed/storage/v1alpha1","kubernetes/typed/storage/v1alpha1/fake","kubernetes/typed/storage/v1beta1","kubernetes/typed/storage/v1beta1/fake","pkg/apis/clientauthentication","pkg/apis/clientauthentication/v1alpha1","pkg/version","plugin/pkg/client/auth/exec","rest","rest/watch","testing","tools/auth","tools/cache","tools/clientcmd","tools/clientcmd/api","tools/clientcmd/api/latest","tools/clientcmd/api/v1","tools/metrics","tools/pager","tools/record","tools/reference","transport","util/buffer","util/cert","util/flowcontrol","util/homedir","util/integer","util/retry","util/workqueue"] + revision = "989be4278f353e42f26c416c53757d16fcff77db" + version = "kubernetes-1.10.1" + +[[projects]] + name = "k8s.io/code-generator" + packages = ["cmd/client-gen","cmd/client-gen/args","cmd/client-gen/generators","cmd/client-gen/generators/fake","cmd/client-gen/generators/scheme","cmd/client-gen/generators/util","cmd/client-gen/path","cmd/client-gen/types","pkg/util"] + revision = "7ead8f38b01cf8653249f5af80ce7b2c8aba12e2" + version = "kubernetes-1.10.0" + +[[projects]] + branch = "master" + name = "k8s.io/gengo" + packages = ["args","generator","namer","parser","types"] + revision = "01a732e01d00cb9a81bb0ca050d3e6d2b947927b" + +[[projects]] + branch = "master" + name = "k8s.io/kube-openapi" + packages = ["pkg/util/proto"] + revision = "50ae88d24ede7b8bad68e23c805b5d3da5c8abaf" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "018e79134d9717f35b89ef71076c12e47dcdb3d7e8e4d57d703bc02a6f85d938" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/components/api-controller/Gopkg.toml b/components/api-controller/Gopkg.toml new file mode 100644 index 000000000000..16407099d41a --- /dev/null +++ b/components/api-controller/Gopkg.toml @@ -0,0 +1,25 @@ +required = ["k8s.io/code-generator/cmd/client-gen"] + +[[constraint]] + name = "github.com/sirupsen/logrus" + version = "1.0.4" + +[[constraint]] + name = "k8s.io/api" + version = "kubernetes-1.10.1" + +[[constraint]] + name = "k8s.io/apimachinery" + version = "kubernetes-1.10.1" + +[[constraint]] + name = "k8s.io/client-go" + version = "kubernetes-1.10.1" + +[[constraint]] + name = "k8s.io/code-generator" + version = "kubernetes-1.10.1" + +[[constraint]] + name = "github.com/satori/go.uuid" + version = "1.2.0" diff --git a/components/api-controller/Jenkinsfile b/components/api-controller/Jenkinsfile new file mode 100755 index 000000000000..5dc29b0d8259 --- /dev/null +++ b/components/api-controller/Jenkinsfile @@ -0,0 +1,100 @@ +#!/usr/bin/env groovy +def label = "kyma-${UUID.randomUUID().toString()}" +def application = 'api-controller' +def isMaster = params.GIT_BRANCH == 'master' + +def dockerPushRoot = isMaster + ? "${env.DOCKER_REGISTRY}" + : "${env.DOCKER_REGISTRY}snapshot/" + +def dockerImageTag = isMaster + ? params.APP_VERSION + : params.GIT_BRANCH + +echo """ +******************************** +Job started with the following parameters: +DOCKER_REGISTRY=${env.DOCKER_REGISTRY} +DOCKER_CREDENTIALS=${env.DOCKER_CREDENTIALS} +GIT_REVISION=${params.GIT_REVISION} +GIT_BRANCH=${params.GIT_BRANCH} +APP_VERSION=${params.APP_VERSION} +APP_FOLDER=${env.APP_FOLDER} +******************************** +""" + +podTemplate(label: label) { + node(label) { + try { + timestamps { + timeout(time:20, unit:"MINUTES") { + ansiColor('xterm') { + stage("setup") { + checkout scm + + if(dockerImageTag == ""){ + error("No version for docker tag defined, please set APP_VERSION parameter for master branch or GIT_BRANCH parameter for any branch") + } + + withCredentials([usernamePassword(credentialsId: env.DOCKER_CREDENTIALS, passwordVariable: 'pwd', usernameVariable: 'uname')]) { + sh "docker login -u $uname -p '$pwd' $env.DOCKER_REGISTRY" + } + } + + stage("install dependencies $application") { + execute("make resolve") + } + + stage("code quality $application") { + execute("gometalinter --vendor --deadline=2m --disable-all " + + "--enable=vet " + + "--skip=\$(go list ./... | grep \"/fake\") " + + "./...") + } + + stage("build $application") { + execute("CGO_ENABLED=0 go build -o bin/app cmd/controller/main.go") + } + + stage("test $application") { + execute("2>&1 go test -short -v ./... | go2xunit -fail -output unit-tests.xml") + junit '**/unit-tests.xml' + } + + if (isMaster) { + stage("IP scan $application (Sourceclear)"){ + withCredentials([string(credentialsId: 'SRCCLR_API_TOKEN', variable: 'SRCCLR_API_TOKEN')]) { + execute("make scan","SRCCLR_API_TOKEN=$SRCCLR_API_TOKEN") + } + } + } + + stage("build image $application") { + dir(env.APP_FOLDER){ + sh "docker build -t $application:latest ." + } + } + + stage("push image $application") { + sh "docker tag ${application}:latest ${dockerPushRoot}${application}:${dockerImageTag}" + sh "docker push ${dockerPushRoot}${application}:${dockerImageTag}" + } + } + } + } + } catch (ex) { + echo "Got exception: ${ex}" + currentBuild.result = "FAILURE" + def body = "${currentBuild.currentResult} ${env.JOB_NAME}${env.BUILD_DISPLAY_NAME}: on branch: ${params.GIT_BRANCH}. See details: ${env.BUILD_URL}" + emailext body: body, recipientProviders: [[$class: 'DevelopersRecipientProvider'], [$class: 'CulpritsRecipientProvider'], [$class: 'RequesterRecipientProvider']], subject: "${currentBuild.currentResult}: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'" + } + } +} + +def execute(command, envs = []) { + def buildpack = 'golang-buildpack:0.0.8' + def repositoryName = 'kyma' + def envText = envs=='' ? '' : "--env $envs" + workDir = pwd() + sh "docker run --rm -v $workDir:/go/src/github.com/kyma-project/$repositoryName/ -w /go/src/github.com/kyma-project/$repositoryName/$env.APP_FOLDER $envText ${env.DOCKER_REGISTRY}$buildpack /bin/bash -c '$command'" +} \ No newline at end of file diff --git a/components/api-controller/README.md b/components/api-controller/README.md new file mode 100644 index 000000000000..e3a89449a425 --- /dev/null +++ b/components/api-controller/README.md @@ -0,0 +1,75 @@ +``` + _ ____ ___ ____ _ _ _ + / \ | _ \_ _| / ___|___ _ __ | |_ _ __ ___ | | | ___ _ __ + / _ \ | |_) | | | | / _ \| '_ \| __| '__/ _ \| | |/ _ \ '__| + / ___ \| __/| | | |__| (_) | | | | |_| | | (_) | | | __/ | +/_/ \_\_| |___| \____\___/|_| |_|\__|_| \___/|_|_|\___|_| +``` + + +## Overview + +The Kyma API Controller is a core component that manages Istio authentication policies and Ingresses, and allows to expose services using the Kyma Console. It is implemented according to the [Kubernetes Operator](https://coreos.com/blog/introducing-operators.html) principles and operates on `api.gateway.kyma.cx` Custom Resource Definition (CRD) resources. + +This [Helm chart](/resources/core/charts/api-controller/Chart.yaml) defines the component's installation. + +## Prerequisites + +You need these tools to work with the API Controller: + +- [Go distribution](https://golang.org) +- [Docker](https://www.docker.com/) + + +## Details + +This section describes how to run the controller locally, how to build the Docker image for the production environment, how to use the environment variables, and how to test the Kyma API Controller. + +### Run the component locally + +Run Minikube with Istio to use the API Controller locally. Run this command to run the application without building the binary: + +```bash +$ go run cmd/controller/main.go +``` + +### Use environment variables + +Use these environment variables to configure the application: + +| Name | Required | Default | Description | +|-----|---------|--------|------------| +| API_CONTROLLER_LOG_LEVEL | No | `info` | Show detailed logs in the application. + + +### Test + +Run all tests: + +```bash +$ go test -v ./... +``` + +Run all tests with coverage: + +```bash +$ go test -coverprofile=coverage_report.out -v ./... +``` + +Run unit tests only: + +```bash +$ go test -short -v ./... +``` + +Run unit tests with coverage: + +```bash +go test -short -coverprofile=coverage_report.out -v ./... +``` + +Run integration tests only: + +```bash +$ go test -run Integration -v ./... +``` diff --git a/components/api-controller/cmd/controller/main.go b/components/api-controller/cmd/controller/main.go new file mode 100644 index 000000000000..ebd24f9d4e71 --- /dev/null +++ b/components/api-controller/cmd/controller/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "os" + "path/filepath" + "time" + + istioAuthenticationClient "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned" + kyma "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned" + kymaInformers "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions" + authenticationV2 "github.com/kyma-project/kyma/components/api-controller/pkg/controller/authentication/v2" + "github.com/kyma-project/kyma/components/api-controller/pkg/controller/crd" + ingressV1 "github.com/kyma-project/kyma/components/api-controller/pkg/controller/ingress/v1" + serviceV1 "github.com/kyma-project/kyma/components/api-controller/pkg/controller/service/v1" + "github.com/kyma-project/kyma/components/api-controller/pkg/controller/v1alpha2" + log "github.com/sirupsen/logrus" + apiExtensionsClient "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + k8sClient "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +func main() { + + log.SetLevel(getLoggerLevel()) + + log.Info("Starting API controller application...") + + stop := make(chan struct{}) + + kubeConfig := initKubeConfig() + + apiExtensionsClientSet := apiExtensionsClient.NewForConfigOrDie(kubeConfig) + + registerer := crd.NewRegistrar(apiExtensionsClientSet) + registerer.Register(v1alpha2.Crd()) + + k8sClientSet := k8sClient.NewForConfigOrDie(kubeConfig) + + ingressV1Interface := ingressV1.New(k8sClientSet) + serviceV1Interface := serviceV1.New(k8sClientSet) + + istioAuthenticationClientSet := istioAuthenticationClient.NewForConfigOrDie(kubeConfig) + authenticationV2Interface := authenticationV2.New(istioAuthenticationClientSet) + + kymaClientSet := kyma.NewForConfigOrDie(kubeConfig) + + internalInformerFactory := kymaInformers.NewSharedInformerFactory(kymaClientSet, time.Second*30) + go internalInformerFactory.Start(stop) + + v1alpha2Controller := v1alpha2.NewController(kymaClientSet, ingressV1Interface, serviceV1Interface, authenticationV2Interface, internalInformerFactory) + v1alpha2Controller.Run(2, stop) +} + +func initKubeConfig() *rest.Config { + kubeConfigLocation := filepath.Join(os.Getenv("HOME"), ".kube", "config") + kubeConfig, err := clientcmd.BuildConfigFromFlags("", kubeConfigLocation) + if err != nil { + log.Warn("unable to build kube config from file. Trying in-cluster configuration") + kubeConfig, err = rest.InClusterConfig() + if err != nil { + log.Fatal("cannot find Service Account in pod to build in-cluster kube config") + } + } + return kubeConfig +} + +func getLoggerLevel() log.Level { + + logLevel := os.Getenv("API_CONTROLLER_LOG_LEVEL") + if logLevel != "" { + level, err := log.ParseLevel(logLevel) + if err != nil { + println("Error while setting log level: " + logLevel + ". Root cause: " + err.Error()) + } else { + return level + } + } + return log.InfoLevel +} diff --git a/components/api-controller/doc.go b/components/api-controller/doc.go new file mode 100644 index 000000000000..cb026ca6c459 --- /dev/null +++ b/components/api-controller/doc.go @@ -0,0 +1,4 @@ +package api_controller + +// this files is required only because this project MUST have at least one go file on project root, to be fetched +// using go dep, as a dependency in another project diff --git a/components/api-controller/docs/.file b/components/api-controller/docs/.file new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/components/api-controller/examples/cr-v1alpha1.yaml b/components/api-controller/examples/cr-v1alpha1.yaml new file mode 100644 index 000000000000..49054a4163ff --- /dev/null +++ b/components/api-controller/examples/cr-v1alpha1.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: gateway.kyma.cx/v1alpha1 +kind: api +metadata: + name: sample-api +spec: + service: + name: kubernetes + port: 443 + hostname: kubernetes.kyma.local + authorization: + enabled: true + rules: + - path: /abc + - pathMatcher: '"/def*".matches(request.path)' + - pathMatcher: '"/test*".matches(request.path)' diff --git a/components/api-controller/examples/cr-v1alpha2.yaml b/components/api-controller/examples/cr-v1alpha2.yaml new file mode 100644 index 000000000000..88b0928687f2 --- /dev/null +++ b/components/api-controller/examples/cr-v1alpha2.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: gateway.kyma.cx/v1alpha2 +kind: Api +metadata: + name: sample-api +spec: + service: + name: kubernetes + port: 443 + hostname: kubernetes.kyma.local + authentication: + - type: JWT + jwt: + issuer: https://accounts.google.com + jwksUri: https://www.googleapis.com/oauth2/v3/certs diff --git a/components/api-controller/hack/custom-boilerplate.go.txt b/components/api-controller/hack/custom-boilerplate.go.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/components/api-controller/hack/update-codegen.sh b/components/api-controller/hack/update-codegen.sh new file mode 100755 index 000000000000..24902086ef67 --- /dev/null +++ b/components/api-controller/hack/update-codegen.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +set -o errexit +set -o nounset +set -o pipefail + +SCRIPT_ROOT=$(dirname ${BASH_SOURCE})/.. + +echo $SCRIPT_ROOT + +CODEGEN_PKG=${CODEGEN_PKG:-$(cd ${SCRIPT_ROOT}; ls -d -1 ./vendor/k8s.io/code-generator 2>/dev/null || echo ../code-generator)} + +echo $CODEGEN_PKG + +# generate the code with: +# --output-base because this script should also be able to run inside the vendor dir of +# k8s.io/kubernetes. The output-base is needed for the generators to output into the vendor dir +# instead of the $GOPATH directly. For normal projects this can be dropped. +${CODEGEN_PKG}/generate-groups.sh "deepcopy,client,informer,lister" \ + github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx github.com/kyma-project/kyma/components/api-controller/pkg/apis \ + gateway.kyma.cx:v1alpha2 \ + --output-base "$(dirname ${BASH_SOURCE})/../../../.." \ + --go-header-file ${SCRIPT_ROOT}/hack/custom-boilerplate.go.txt + +${CODEGEN_PKG}/generate-groups.sh "deepcopy,client,informer,lister" \ + github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io github.com/kyma-project/kyma/components/api-controller/pkg/apis \ + authentication.istio.io:v1alpha1 \ + --output-base "$(dirname ${BASH_SOURCE})/../../../.." \ + --go-header-file ${SCRIPT_ROOT}/hack/custom-boilerplate.go.txt diff --git a/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1/doc.go b/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1/doc.go new file mode 100644 index 000000000000..30f47fcdd476 --- /dev/null +++ b/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1/doc.go @@ -0,0 +1,2 @@ +// +k8s:deepcopy-gen=package +package v1alpha1 diff --git a/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1/register.go b/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1/register.go new file mode 100644 index 000000000000..d91d8d65564a --- /dev/null +++ b/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1/register.go @@ -0,0 +1,42 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + Group = "authentication.istio.io" + Version = "v1alpha1" +) + +var ( + + // SchemeGroupVersion is group version used to register these objects + SchemeGroupVersion = schema.GroupVersion{ + Group: Group, + Version: Version, + } + + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + AddToScheme = SchemeBuilder.AddToScheme +) + +// Kind takes an unqualified kind and returns back a Group qualified GroupKind +func Kind(kind string) schema.GroupKind { + return SchemeGroupVersion.WithKind(kind).GroupKind() +} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} + +// Adds the list of known types to Scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypeWithName(SchemeGroupVersion.WithKind("Policy"), &Policy{}) + scheme.AddKnownTypeWithName(SchemeGroupVersion.WithKind("PolicyList"), &PolicyList{}) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1/types.go b/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1/types.go new file mode 100644 index 000000000000..cf44b7148e64 --- /dev/null +++ b/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1/types.go @@ -0,0 +1,77 @@ +package v1alpha1 + +import ( + "fmt" + + k8sMeta "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +genclient +// +genclient:noStatus +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// Policy describes Istio policy +type Policy struct { + k8sMeta.TypeMeta `json:",inline"` + k8sMeta.ObjectMeta `json:"metadata,omitempty"` + Spec *PolicySpec `json:"spec"` +} + +func (p *Policy) String() string { + return fmt.Sprintf("{Namespace: %v, Name: %v, UID: %v, Spec: %v}", p.Namespace, p.Name, p.UID, p.Spec) +} + +// PolicySpec is the spec for Policy resource +type PolicySpec struct { + Targets Targets `json:"targets"` + PrincipalBinding PrincipalBinding `json:"principalBinding"` + Origins Origins `json:"origins,omitempty"` +} + +type PrincipalBinding string + +const ( + UseOrigin PrincipalBinding = "USE_ORIGIN" +) + +func (p *PolicySpec) String() string { + return fmt.Sprintf("{Targets: %v, PrincipalBinding: %v, Origins: %v}", p.Targets, p.PrincipalBinding, p.Origins) +} + +type Targets []*Target + +type Target struct { + Name string `json:"name"` +} + +func (t *Target) String() string { + return fmt.Sprintf("{Name: %s}", t.Name) +} + +type Origins []*Origin + +type Origin struct { + Jwt *Jwt `json:"jwt"` +} + +func (o *Origin) String() string { + return fmt.Sprintf("{Jwt: %v}", o.Jwt) +} + +type Jwt struct { + Issuer string `json:"issuer"` + JwksUri string `json:"jwksUri"` +} + +func (j *Jwt) String() string { + return fmt.Sprintf("{Issuer: %s, JwksUri: %s}", j.Issuer, j.JwksUri) +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// PolicyList is a list of Rule resources +type PolicyList struct { + k8sMeta.TypeMeta `json:",inline"` + k8sMeta.ListMeta `json:"metadata,omitempty"` + Items []Policy `json:"items"` +} diff --git a/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1/zz_generated.deepcopy.go b/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 000000000000..4a8111b5e6ac --- /dev/null +++ b/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,228 @@ +// +build !ignore_autogenerated + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + 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 *Jwt) DeepCopyInto(out *Jwt) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Jwt. +func (in *Jwt) DeepCopy() *Jwt { + if in == nil { + return nil + } + out := new(Jwt) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Origin) DeepCopyInto(out *Origin) { + *out = *in + if in.Jwt != nil { + in, out := &in.Jwt, &out.Jwt + if *in == nil { + *out = nil + } else { + *out = new(Jwt) + **out = **in + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Origin. +func (in *Origin) DeepCopy() *Origin { + if in == nil { + return nil + } + out := new(Origin) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in Origins) DeepCopyInto(out *Origins) { + { + in := &in + *out = make(Origins, len(*in)) + for i := range *in { + if (*in)[i] == nil { + (*out)[i] = nil + } else { + (*out)[i] = new(Origin) + (*in)[i].DeepCopyInto((*out)[i]) + } + } + return + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Origins. +func (in Origins) DeepCopy() Origins { + if in == nil { + return nil + } + out := new(Origins) + in.DeepCopyInto(out) + return *out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Policy) DeepCopyInto(out *Policy) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + if in.Spec != nil { + in, out := &in.Spec, &out.Spec + if *in == nil { + *out = nil + } else { + *out = new(PolicySpec) + (*in).DeepCopyInto(*out) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Policy. +func (in *Policy) DeepCopy() *Policy { + if in == nil { + return nil + } + out := new(Policy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Policy) 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 *PolicyList) DeepCopyInto(out *PolicyList) { + *out = *in + out.TypeMeta = in.TypeMeta + out.ListMeta = in.ListMeta + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Policy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PolicyList. +func (in *PolicyList) DeepCopy() *PolicyList { + if in == nil { + return nil + } + out := new(PolicyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PolicyList) 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 *PolicySpec) DeepCopyInto(out *PolicySpec) { + *out = *in + if in.Targets != nil { + in, out := &in.Targets, &out.Targets + *out = make(Targets, len(*in)) + for i := range *in { + if (*in)[i] == nil { + (*out)[i] = nil + } else { + (*out)[i] = new(Target) + (*in)[i].DeepCopyInto((*out)[i]) + } + } + } + if in.Origins != nil { + in, out := &in.Origins, &out.Origins + *out = make(Origins, len(*in)) + for i := range *in { + if (*in)[i] == nil { + (*out)[i] = nil + } else { + (*out)[i] = new(Origin) + (*in)[i].DeepCopyInto((*out)[i]) + } + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PolicySpec. +func (in *PolicySpec) DeepCopy() *PolicySpec { + if in == nil { + return nil + } + out := new(PolicySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Target) DeepCopyInto(out *Target) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Target. +func (in *Target) DeepCopy() *Target { + if in == nil { + return nil + } + out := new(Target) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in Targets) DeepCopyInto(out *Targets) { + { + in := &in + *out = make(Targets, len(*in)) + for i := range *in { + if (*in)[i] == nil { + (*out)[i] = nil + } else { + (*out)[i] = new(Target) + (*in)[i].DeepCopyInto((*out)[i]) + } + } + return + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Targets. +func (in Targets) DeepCopy() Targets { + if in == nil { + return nil + } + out := new(Targets) + in.DeepCopyInto(out) + return *out +} diff --git a/components/api-controller/pkg/apis/gateway.kyma.cx/meta/v1/types.go b/components/api-controller/pkg/apis/gateway.kyma.cx/meta/v1/types.go new file mode 100644 index 000000000000..29d29e322d50 --- /dev/null +++ b/components/api-controller/pkg/apis/gateway.kyma.cx/meta/v1/types.go @@ -0,0 +1,72 @@ +package v1 + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/types" +) + +type StatusCode int + +func (c *StatusCode) String() string { + return fmt.Sprintf("%T", c) +} + +const ( + Empty StatusCode = iota + InProgress + Done + Error +) + +func (s StatusCode) IsEmpty() bool { + return s == Empty +} + +func (s StatusCode) IsInProgress() bool { + return s == InProgress +} + +func (s StatusCode) IsDone() bool { + return s == Done +} + +func (s StatusCode) IsError() bool { + return s == Error +} + +type GatewayResourceStatus struct { + Code StatusCode `json:"code"` + Resource GatewayResource `json:"resource,omitempty"` + LastError string `json:"lastError,omitempty"` +} + +func (g *GatewayResourceStatus) String() string { + return fmt.Sprintf("{Code: %v, Resource: %v, LastError: %v}", g.Code, g.Resource, g.LastError) +} + +func (s *GatewayResourceStatus) IsEmpty() bool { + return s.Code.IsEmpty() +} + +func (s *GatewayResourceStatus) IsInProgress() bool { + return s.Code.IsInProgress() +} + +func (s *GatewayResourceStatus) IsDone() bool { + return s.Code.IsDone() +} + +func (s *GatewayResourceStatus) IsError() bool { + return s.Code.IsError() +} + +type GatewayResource struct { + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + Uid types.UID `json:"uid,omitempty"` +} + +func (r *GatewayResource) String() string { + return fmt.Sprintf("{Name: %s, Version: %s, Uid: %v}", r.Name, r.Version, r.Uid) +} diff --git a/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2/doc.go b/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2/doc.go new file mode 100644 index 000000000000..838e4f079b74 --- /dev/null +++ b/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2/doc.go @@ -0,0 +1,2 @@ +// +k8s:deepcopy-gen=package +package v1alpha2 diff --git a/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2/register.go b/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2/register.go new file mode 100644 index 000000000000..620ea6227ad2 --- /dev/null +++ b/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2/register.go @@ -0,0 +1,42 @@ +package v1alpha2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + Group = "gateway.kyma.cx" + Version = "v1alpha2" + KindName = "Api" + ListKindName = "ApiList" +) + +var ( + // SchemeGroupVersion is group version used to register these objects + SchemeGroupVersion = schema.GroupVersion{ + Group: Group, + Version: Version, + } + + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + AddToScheme = SchemeBuilder.AddToScheme +) + +// Kind takes an unqualified kind and returns back a Group qualified GroupKind +func Kind(kind string) schema.GroupKind { + return SchemeGroupVersion.WithKind(kind).GroupKind() +} + +// GatewayResource takes an unqualified resource and returns a Group qualified GroupResource +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} + +// Adds the list of known types to Scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, &Api{}, &ApiList{}) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2/types.go b/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2/types.go new file mode 100644 index 000000000000..96e845b06ab7 --- /dev/null +++ b/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2/types.go @@ -0,0 +1,85 @@ +package v1alpha2 + +import ( + kymaMeta "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/meta/v1" + k8sMeta "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +type Api struct { + k8sMeta.TypeMeta `json:",inline"` + k8sMeta.ObjectMeta `json:"metadata,omitempty"` + + Spec ApiSpec `json:"spec"` + Status ApiStatus `json:"status"` +} + +type ApiSpec struct { + Service Service `json:"service"` + Hostname string `json:"hostname"` + Authentication Authentication `json:"authentication"` +} + +type Service struct { + Name string `json:"name"` + Port int `json:"port"` +} + +type Authentication []AuthenticationRule + +type AuthenticationRule struct { + Type AuthenticationType `json:"type"` + Jwt JwtAuthentication `json:"jwt"` +} + +type AuthenticationType string + +const ( + JwtType AuthenticationType = "JWT" +) + +type JwtAuthentication struct { + JwksUri string `json:"jwksUri"` + Issuer string `json:"issuer"` +} + +type ApiStatus struct { + AuthenticationStatus kymaMeta.GatewayResourceStatus `json:"authenticationStatus,omitempty"` + IngressStatus kymaMeta.GatewayResourceStatus `json:"ingressStatus,omitempty"` +} + +func (s *ApiStatus) IsEmpty() bool { + return s.IngressStatus.IsEmpty() && s.AuthenticationStatus.IsEmpty() +} + +func (s *ApiStatus) IsDone() bool { + return s.IngressStatus.IsDone() && s.AuthenticationStatus.IsDone() +} + +func (s *ApiStatus) IsInProgress() bool { + return s.IngressStatus.IsInProgress() || s.AuthenticationStatus.IsInProgress() +} + +func (s *ApiStatus) IsError() bool { + return s.IngressStatus.IsError() || s.AuthenticationStatus.IsError() +} + +func (s *ApiStatus) SetInProgress() { + s.AuthenticationStatus = kymaMeta.GatewayResourceStatus{ + Code: kymaMeta.InProgress, + } + s.IngressStatus = kymaMeta.GatewayResourceStatus{ + Code: kymaMeta.InProgress, + } +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +type ApiList struct { + k8sMeta.TypeMeta `json:",inline"` + k8sMeta.ListMeta `json:"metadata,omitempty"` + + Items []Api `json:"items"` +} diff --git a/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2/zz_generated.deepcopy.go b/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2/zz_generated.deepcopy.go new file mode 100644 index 000000000000..50c3df174267 --- /dev/null +++ b/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2/zz_generated.deepcopy.go @@ -0,0 +1,179 @@ +// +build !ignore_autogenerated + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + 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 *Api) DeepCopyInto(out *Api) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + out.Status = in.Status + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Api. +func (in *Api) DeepCopy() *Api { + if in == nil { + return nil + } + out := new(Api) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Api) 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 *ApiList) DeepCopyInto(out *ApiList) { + *out = *in + out.TypeMeta = in.TypeMeta + out.ListMeta = in.ListMeta + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Api, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApiList. +func (in *ApiList) DeepCopy() *ApiList { + if in == nil { + return nil + } + out := new(ApiList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ApiList) 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 *ApiSpec) DeepCopyInto(out *ApiSpec) { + *out = *in + out.Service = in.Service + if in.Authentication != nil { + in, out := &in.Authentication, &out.Authentication + *out = make(Authentication, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApiSpec. +func (in *ApiSpec) DeepCopy() *ApiSpec { + if in == nil { + return nil + } + out := new(ApiSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ApiStatus) DeepCopyInto(out *ApiStatus) { + *out = *in + out.AuthenticationStatus = in.AuthenticationStatus + out.IngressStatus = in.IngressStatus + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApiStatus. +func (in *ApiStatus) DeepCopy() *ApiStatus { + if in == nil { + return nil + } + out := new(ApiStatus) + 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) { + { + in := &in + *out = make(Authentication, len(*in)) + copy(*out, *in) + return + } +} + +// 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 *AuthenticationRule) DeepCopyInto(out *AuthenticationRule) { + *out = *in + out.Jwt = in.Jwt + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthenticationRule. +func (in *AuthenticationRule) DeepCopy() *AuthenticationRule { + if in == nil { + return nil + } + out := new(AuthenticationRule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *JwtAuthentication) DeepCopyInto(out *JwtAuthentication) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JwtAuthentication. +func (in *JwtAuthentication) DeepCopy() *JwtAuthentication { + if in == nil { + return nil + } + out := new(JwtAuthentication) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Service) DeepCopyInto(out *Service) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Service. +func (in *Service) DeepCopy() *Service { + if in == nil { + return nil + } + out := new(Service) + in.DeepCopyInto(out) + return out +} diff --git a/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/clientset.go b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/clientset.go new file mode 100644 index 000000000000..fcd09efb6b7b --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/clientset.go @@ -0,0 +1,84 @@ +// Code generated by client-gen. DO NOT EDIT. + +package versioned + +import ( + glog "github.com/golang/glog" + authenticationv1alpha1 "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1" + discovery "k8s.io/client-go/discovery" + rest "k8s.io/client-go/rest" + flowcontrol "k8s.io/client-go/util/flowcontrol" +) + +type Interface interface { + Discovery() discovery.DiscoveryInterface + AuthenticationV1alpha1() authenticationv1alpha1.AuthenticationV1alpha1Interface + // Deprecated: please explicitly pick a version if possible. + Authentication() authenticationv1alpha1.AuthenticationV1alpha1Interface +} + +// Clientset contains the clients for groups. Each group has exactly one +// version included in a Clientset. +type Clientset struct { + *discovery.DiscoveryClient + authenticationV1alpha1 *authenticationv1alpha1.AuthenticationV1alpha1Client +} + +// AuthenticationV1alpha1 retrieves the AuthenticationV1alpha1Client +func (c *Clientset) AuthenticationV1alpha1() authenticationv1alpha1.AuthenticationV1alpha1Interface { + return c.authenticationV1alpha1 +} + +// Deprecated: Authentication retrieves the default version of AuthenticationClient. +// Please explicitly pick a version. +func (c *Clientset) Authentication() authenticationv1alpha1.AuthenticationV1alpha1Interface { + return c.authenticationV1alpha1 +} + +// Discovery retrieves the DiscoveryClient +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + if c == nil { + return nil + } + return c.DiscoveryClient +} + +// NewForConfig creates a new Clientset for the given config. +func NewForConfig(c *rest.Config) (*Clientset, error) { + configShallowCopy := *c + if configShallowCopy.RateLimiter == nil && configShallowCopy.QPS > 0 { + configShallowCopy.RateLimiter = flowcontrol.NewTokenBucketRateLimiter(configShallowCopy.QPS, configShallowCopy.Burst) + } + var cs Clientset + var err error + cs.authenticationV1alpha1, err = authenticationv1alpha1.NewForConfig(&configShallowCopy) + if err != nil { + return nil, err + } + + cs.DiscoveryClient, err = discovery.NewDiscoveryClientForConfig(&configShallowCopy) + if err != nil { + glog.Errorf("failed to create the DiscoveryClient: %v", err) + return nil, err + } + return &cs, nil +} + +// NewForConfigOrDie creates a new Clientset for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *Clientset { + var cs Clientset + cs.authenticationV1alpha1 = authenticationv1alpha1.NewForConfigOrDie(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClientForConfigOrDie(c) + return &cs +} + +// New creates a new Clientset for the given RESTClient. +func New(c rest.Interface) *Clientset { + var cs Clientset + cs.authenticationV1alpha1 = authenticationv1alpha1.New(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClient(c) + return &cs +} diff --git a/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/doc.go b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/doc.go new file mode 100644 index 000000000000..0e0c2a8900e2 --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated clientset. +package versioned diff --git a/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/fake/clientset_generated.go b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/fake/clientset_generated.go new file mode 100644 index 000000000000..947859f3cc36 --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/fake/clientset_generated.go @@ -0,0 +1,65 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + clientset "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned" + authenticationv1alpha1 "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1" + fakeauthenticationv1alpha1 "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/fake" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/discovery" + fakediscovery "k8s.io/client-go/discovery/fake" + "k8s.io/client-go/testing" +) + +// NewSimpleClientset returns a clientset that will respond with the provided objects. +// It's backed by a very simple object tracker that processes creates, updates and deletions as-is, +// without applying any validations and/or defaults. It shouldn't be considered a replacement +// for a real clientset and is mostly useful in simple unit tests. +func NewSimpleClientset(objects ...runtime.Object) *Clientset { + o := testing.NewObjectTracker(scheme, codecs.UniversalDecoder()) + for _, obj := range objects { + if err := o.Add(obj); err != nil { + panic(err) + } + } + + fakePtr := testing.Fake{} + fakePtr.AddReactor("*", "*", testing.ObjectReaction(o)) + fakePtr.AddWatchReactor("*", func(action testing.Action) (handled bool, ret watch.Interface, err error) { + gvr := action.GetResource() + ns := action.GetNamespace() + watch, err := o.Watch(gvr, ns) + if err != nil { + return false, nil, err + } + return true, watch, nil + }) + + return &Clientset{fakePtr, &fakediscovery.FakeDiscovery{Fake: &fakePtr}} +} + +// Clientset implements clientset.Interface. Meant to be embedded into a +// struct to get a default implementation. This makes faking out just the method +// you want to test easier. +type Clientset struct { + testing.Fake + discovery *fakediscovery.FakeDiscovery +} + +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + return c.discovery +} + +var _ clientset.Interface = &Clientset{} + +// AuthenticationV1alpha1 retrieves the AuthenticationV1alpha1Client +func (c *Clientset) AuthenticationV1alpha1() authenticationv1alpha1.AuthenticationV1alpha1Interface { + return &fakeauthenticationv1alpha1.FakeAuthenticationV1alpha1{Fake: &c.Fake} +} + +// Authentication retrieves the AuthenticationV1alpha1Client +func (c *Clientset) Authentication() authenticationv1alpha1.AuthenticationV1alpha1Interface { + return &fakeauthenticationv1alpha1.FakeAuthenticationV1alpha1{Fake: &c.Fake} +} diff --git a/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/fake/doc.go b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/fake/doc.go new file mode 100644 index 000000000000..3630ed1cd17d --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/fake/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated fake clientset. +package fake diff --git a/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/fake/register.go b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/fake/register.go new file mode 100644 index 000000000000..eb785f4dbac7 --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/fake/register.go @@ -0,0 +1,38 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + authenticationv1alpha1 "github.com/kyma-project/kyma/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var scheme = runtime.NewScheme() +var codecs = serializer.NewCodecFactory(scheme) +var parameterCodec = runtime.NewParameterCodec(scheme) + +func init() { + v1.AddToGroupVersion(scheme, schema.GroupVersion{Version: "v1"}) + AddToScheme(scheme) +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +func AddToScheme(scheme *runtime.Scheme) { + authenticationv1alpha1.AddToScheme(scheme) +} diff --git a/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/scheme/doc.go b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/scheme/doc.go new file mode 100644 index 000000000000..14db57a58f8d --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/scheme/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package contains the scheme of the automatically generated clientset. +package scheme diff --git a/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/scheme/register.go b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/scheme/register.go new file mode 100644 index 000000000000..289fce579062 --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/scheme/register.go @@ -0,0 +1,38 @@ +// Code generated by client-gen. DO NOT EDIT. + +package scheme + +import ( + authenticationv1alpha1 "github.com/kyma-project/kyma/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var Scheme = runtime.NewScheme() +var Codecs = serializer.NewCodecFactory(Scheme) +var ParameterCodec = runtime.NewParameterCodec(Scheme) + +func init() { + v1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"}) + AddToScheme(Scheme) +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +func AddToScheme(scheme *runtime.Scheme) { + authenticationv1alpha1.AddToScheme(scheme) +} diff --git a/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/authentication.istio.io_client.go b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/authentication.istio.io_client.go new file mode 100644 index 000000000000..d49bd6dc438a --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/authentication.istio.io_client.go @@ -0,0 +1,74 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1" + "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/scheme" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + rest "k8s.io/client-go/rest" +) + +type AuthenticationV1alpha1Interface interface { + RESTClient() rest.Interface + PoliciesGetter +} + +// AuthenticationV1alpha1Client is used to interact with features provided by the authentication.istio.io group. +type AuthenticationV1alpha1Client struct { + restClient rest.Interface +} + +func (c *AuthenticationV1alpha1Client) Policies(namespace string) PolicyInterface { + return newPolicies(c, namespace) +} + +// NewForConfig creates a new AuthenticationV1alpha1Client for the given config. +func NewForConfig(c *rest.Config) (*AuthenticationV1alpha1Client, error) { + config := *c + if err := setConfigDefaults(&config); err != nil { + return nil, err + } + client, err := rest.RESTClientFor(&config) + if err != nil { + return nil, err + } + return &AuthenticationV1alpha1Client{client}, nil +} + +// NewForConfigOrDie creates a new AuthenticationV1alpha1Client for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *AuthenticationV1alpha1Client { + client, err := NewForConfig(c) + if err != nil { + panic(err) + } + return client +} + +// New creates a new AuthenticationV1alpha1Client for the given RESTClient. +func New(c rest.Interface) *AuthenticationV1alpha1Client { + return &AuthenticationV1alpha1Client{c} +} + +func setConfigDefaults(config *rest.Config) error { + gv := v1alpha1.SchemeGroupVersion + config.GroupVersion = &gv + config.APIPath = "/apis" + config.NegotiatedSerializer = serializer.DirectCodecFactory{CodecFactory: scheme.Codecs} + + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } + + return nil +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *AuthenticationV1alpha1Client) RESTClient() rest.Interface { + if c == nil { + return nil + } + return c.restClient +} diff --git a/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/doc.go b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/doc.go new file mode 100644 index 000000000000..93a7ca4e0e2b --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated typed clients. +package v1alpha1 diff --git a/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/fake/doc.go b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/fake/doc.go new file mode 100644 index 000000000000..2b5ba4c8e442 --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/fake/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// Package fake has the automatically generated clients. +package fake diff --git a/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/fake/fake_authentication.istio.io_client.go b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/fake/fake_authentication.istio.io_client.go new file mode 100644 index 000000000000..e574f4dca1e2 --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/fake/fake_authentication.istio.io_client.go @@ -0,0 +1,24 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1" + rest "k8s.io/client-go/rest" + testing "k8s.io/client-go/testing" +) + +type FakeAuthenticationV1alpha1 struct { + *testing.Fake +} + +func (c *FakeAuthenticationV1alpha1) Policies(namespace string) v1alpha1.PolicyInterface { + return &FakePolicies{c, namespace} +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *FakeAuthenticationV1alpha1) RESTClient() rest.Interface { + var ret *rest.RESTClient + return ret +} diff --git a/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/fake/fake_policy.go b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/fake/fake_policy.go new file mode 100644 index 000000000000..d9f3192fe212 --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/fake/fake_policy.go @@ -0,0 +1,112 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakePolicies implements PolicyInterface +type FakePolicies struct { + Fake *FakeAuthenticationV1alpha1 + ns string +} + +var policiesResource = schema.GroupVersionResource{Group: "authentication.istio.io", Version: "v1alpha1", Resource: "policies"} + +var policiesKind = schema.GroupVersionKind{Group: "authentication.istio.io", Version: "v1alpha1", Kind: "Policy"} + +// Get takes name of the policy, and returns the corresponding policy object, and an error if there is any. +func (c *FakePolicies) Get(name string, options v1.GetOptions) (result *v1alpha1.Policy, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(policiesResource, c.ns, name), &v1alpha1.Policy{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Policy), err +} + +// List takes label and field selectors, and returns the list of Policies that match those selectors. +func (c *FakePolicies) List(opts v1.ListOptions) (result *v1alpha1.PolicyList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(policiesResource, policiesKind, c.ns, opts), &v1alpha1.PolicyList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha1.PolicyList{} + for _, item := range obj.(*v1alpha1.PolicyList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested policies. +func (c *FakePolicies) Watch(opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(policiesResource, c.ns, opts)) + +} + +// Create takes the representation of a policy and creates it. Returns the server's representation of the policy, and an error, if there is any. +func (c *FakePolicies) Create(policy *v1alpha1.Policy) (result *v1alpha1.Policy, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(policiesResource, c.ns, policy), &v1alpha1.Policy{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Policy), err +} + +// Update takes the representation of a policy and updates it. Returns the server's representation of the policy, and an error, if there is any. +func (c *FakePolicies) Update(policy *v1alpha1.Policy) (result *v1alpha1.Policy, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(policiesResource, c.ns, policy), &v1alpha1.Policy{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Policy), err +} + +// Delete takes name of the policy and deletes it. Returns an error if one occurs. +func (c *FakePolicies) Delete(name string, options *v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(policiesResource, c.ns, name), &v1alpha1.Policy{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakePolicies) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(policiesResource, c.ns, listOptions) + + _, err := c.Fake.Invokes(action, &v1alpha1.PolicyList{}) + return err +} + +// Patch applies the patch and returns the patched policy. +func (c *FakePolicies) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.Policy, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(policiesResource, c.ns, name, data, subresources...), &v1alpha1.Policy{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Policy), err +} diff --git a/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/generated_expansion.go b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/generated_expansion.go new file mode 100644 index 000000000000..93f0b4bd9722 --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/generated_expansion.go @@ -0,0 +1,5 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +type PolicyExpansion interface{} diff --git a/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/policy.go b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/policy.go new file mode 100644 index 000000000000..e15ffc306310 --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1/policy.go @@ -0,0 +1,141 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1" + scheme "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// PoliciesGetter has a method to return a PolicyInterface. +// A group's client should implement this interface. +type PoliciesGetter interface { + Policies(namespace string) PolicyInterface +} + +// PolicyInterface has methods to work with Policy resources. +type PolicyInterface interface { + Create(*v1alpha1.Policy) (*v1alpha1.Policy, error) + Update(*v1alpha1.Policy) (*v1alpha1.Policy, error) + Delete(name string, options *v1.DeleteOptions) error + DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error + Get(name string, options v1.GetOptions) (*v1alpha1.Policy, error) + List(opts v1.ListOptions) (*v1alpha1.PolicyList, error) + Watch(opts v1.ListOptions) (watch.Interface, error) + Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.Policy, err error) + PolicyExpansion +} + +// policies implements PolicyInterface +type policies struct { + client rest.Interface + ns string +} + +// newPolicies returns a Policies +func newPolicies(c *AuthenticationV1alpha1Client, namespace string) *policies { + return &policies{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the policy, and returns the corresponding policy object, and an error if there is any. +func (c *policies) Get(name string, options v1.GetOptions) (result *v1alpha1.Policy, err error) { + result = &v1alpha1.Policy{} + err = c.client.Get(). + Namespace(c.ns). + Resource("policies"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of Policies that match those selectors. +func (c *policies) List(opts v1.ListOptions) (result *v1alpha1.PolicyList, err error) { + result = &v1alpha1.PolicyList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("policies"). + VersionedParams(&opts, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested policies. +func (c *policies) Watch(opts v1.ListOptions) (watch.Interface, error) { + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("policies"). + VersionedParams(&opts, scheme.ParameterCodec). + Watch() +} + +// Create takes the representation of a policy and creates it. Returns the server's representation of the policy, and an error, if there is any. +func (c *policies) Create(policy *v1alpha1.Policy) (result *v1alpha1.Policy, err error) { + result = &v1alpha1.Policy{} + err = c.client.Post(). + Namespace(c.ns). + Resource("policies"). + Body(policy). + Do(). + Into(result) + return +} + +// Update takes the representation of a policy and updates it. Returns the server's representation of the policy, and an error, if there is any. +func (c *policies) Update(policy *v1alpha1.Policy) (result *v1alpha1.Policy, err error) { + result = &v1alpha1.Policy{} + err = c.client.Put(). + Namespace(c.ns). + Resource("policies"). + Name(policy.Name). + Body(policy). + Do(). + Into(result) + return +} + +// Delete takes name of the policy and deletes it. Returns an error if one occurs. +func (c *policies) Delete(name string, options *v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("policies"). + Name(name). + Body(options). + Do(). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *policies) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("policies"). + VersionedParams(&listOptions, scheme.ParameterCodec). + Body(options). + Do(). + Error() +} + +// Patch applies the patch and returns the patched policy. +func (c *policies) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.Policy, err error) { + result = &v1alpha1.Policy{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("policies"). + SubResource(subresources...). + Name(name). + Body(data). + Do(). + Into(result) + return +} diff --git a/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/authentication.istio.io/interface.go b/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/authentication.istio.io/interface.go new file mode 100644 index 000000000000..790923f5a781 --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/authentication.istio.io/interface.go @@ -0,0 +1,30 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package authentication + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/authentication.istio.io/v1alpha1" + internalinterfaces "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/internalinterfaces" +) + +// Interface provides access to each of this group's versions. +type Interface interface { + // V1alpha1 provides access to shared informers for resources in V1alpha1. + V1alpha1() v1alpha1.Interface +} + +type group struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// V1alpha1 returns a new v1alpha1.Interface. +func (g *group) V1alpha1() v1alpha1.Interface { + return v1alpha1.New(g.factory, g.namespace, g.tweakListOptions) +} diff --git a/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/authentication.istio.io/v1alpha1/interface.go b/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/authentication.istio.io/v1alpha1/interface.go new file mode 100644 index 000000000000..e54781f4169e --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/authentication.istio.io/v1alpha1/interface.go @@ -0,0 +1,29 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + internalinterfaces "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/internalinterfaces" +) + +// Interface provides access to all the informers in this group version. +type Interface interface { + // Policies returns a PolicyInformer. + Policies() PolicyInformer +} + +type version struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// Policies returns a PolicyInformer. +func (v *version) Policies() PolicyInformer { + return &policyInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} diff --git a/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/authentication.istio.io/v1alpha1/policy.go b/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/authentication.istio.io/v1alpha1/policy.go new file mode 100644 index 000000000000..d46d61411a11 --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/authentication.istio.io/v1alpha1/policy.go @@ -0,0 +1,73 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + time "time" + + authentication_istio_io_v1alpha1 "github.com/kyma-project/kyma/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1" + versioned "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/internalinterfaces" + v1alpha1 "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/listers/authentication.istio.io/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// PolicyInformer provides access to a shared informer and lister for +// Policies. +type PolicyInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha1.PolicyLister +} + +type policyInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewPolicyInformer constructs a new informer for Policy type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewPolicyInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredPolicyInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredPolicyInformer constructs a new informer for Policy type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredPolicyInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.AuthenticationV1alpha1().Policies(namespace).List(options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.AuthenticationV1alpha1().Policies(namespace).Watch(options) + }, + }, + &authentication_istio_io_v1alpha1.Policy{}, + resyncPeriod, + indexers, + ) +} + +func (f *policyInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredPolicyInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *policyInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&authentication_istio_io_v1alpha1.Policy{}, f.defaultInformer) +} + +func (f *policyInformer) Lister() v1alpha1.PolicyLister { + return v1alpha1.NewPolicyLister(f.Informer().GetIndexer()) +} diff --git a/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/factory.go b/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/factory.go new file mode 100644 index 000000000000..309227dc3fb3 --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/factory.go @@ -0,0 +1,115 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + reflect "reflect" + sync "sync" + time "time" + + versioned "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned" + authentication_istio_io "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/authentication.istio.io" + internalinterfaces "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/internalinterfaces" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +type sharedInformerFactory struct { + client versioned.Interface + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc + lock sync.Mutex + defaultResync time.Duration + + informers map[reflect.Type]cache.SharedIndexInformer + // startedInformers is used for tracking which informers have been started. + // This allows Start() to be called multiple times safely. + startedInformers map[reflect.Type]bool +} + +// NewSharedInformerFactory constructs a new instance of sharedInformerFactory +func NewSharedInformerFactory(client versioned.Interface, defaultResync time.Duration) SharedInformerFactory { + return NewFilteredSharedInformerFactory(client, defaultResync, v1.NamespaceAll, nil) +} + +// NewFilteredSharedInformerFactory constructs a new instance of sharedInformerFactory. +// Listers obtained via this SharedInformerFactory will be subject to the same filters +// as specified here. +func NewFilteredSharedInformerFactory(client versioned.Interface, defaultResync time.Duration, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) SharedInformerFactory { + return &sharedInformerFactory{ + client: client, + namespace: namespace, + tweakListOptions: tweakListOptions, + defaultResync: defaultResync, + informers: make(map[reflect.Type]cache.SharedIndexInformer), + startedInformers: make(map[reflect.Type]bool), + } +} + +// Start initializes all requested informers. +func (f *sharedInformerFactory) Start(stopCh <-chan struct{}) { + f.lock.Lock() + defer f.lock.Unlock() + + for informerType, informer := range f.informers { + if !f.startedInformers[informerType] { + go informer.Run(stopCh) + f.startedInformers[informerType] = true + } + } +} + +// WaitForCacheSync waits for all started informers' cache were synced. +func (f *sharedInformerFactory) WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool { + informers := func() map[reflect.Type]cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informers := map[reflect.Type]cache.SharedIndexInformer{} + for informerType, informer := range f.informers { + if f.startedInformers[informerType] { + informers[informerType] = informer + } + } + return informers + }() + + res := map[reflect.Type]bool{} + for informType, informer := range informers { + res[informType] = cache.WaitForCacheSync(stopCh, informer.HasSynced) + } + return res +} + +// InternalInformerFor returns the SharedIndexInformer for obj using an internal +// client. +func (f *sharedInformerFactory) InformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informerType := reflect.TypeOf(obj) + informer, exists := f.informers[informerType] + if exists { + return informer + } + informer = newFunc(f.client, f.defaultResync) + f.informers[informerType] = informer + + return informer +} + +// SharedInformerFactory provides shared informers for resources in all known +// API group versions. +type SharedInformerFactory interface { + internalinterfaces.SharedInformerFactory + ForResource(resource schema.GroupVersionResource) (GenericInformer, error) + WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool + + Authentication() authentication_istio_io.Interface +} + +func (f *sharedInformerFactory) Authentication() authentication_istio_io.Interface { + return authentication_istio_io.New(f, f.namespace, f.tweakListOptions) +} diff --git a/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/generic.go b/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/generic.go new file mode 100644 index 000000000000..ecc693347abb --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/generic.go @@ -0,0 +1,46 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + "fmt" + + v1alpha1 "github.com/kyma-project/kyma/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +// GenericInformer is type of SharedIndexInformer which will locate and delegate to other +// sharedInformers based on type +type GenericInformer interface { + Informer() cache.SharedIndexInformer + Lister() cache.GenericLister +} + +type genericInformer struct { + informer cache.SharedIndexInformer + resource schema.GroupResource +} + +// Informer returns the SharedIndexInformer. +func (f *genericInformer) Informer() cache.SharedIndexInformer { + return f.informer +} + +// Lister returns the GenericLister. +func (f *genericInformer) Lister() cache.GenericLister { + return cache.NewGenericLister(f.Informer().GetIndexer(), f.resource) +} + +// ForResource gives generic access to a shared informer of the matching type +// TODO extend this to unknown resources with a client pool +func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { + switch resource { + // Group=authentication.istio.io, Version=v1alpha1 + case v1alpha1.SchemeGroupVersion.WithResource("policies"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Authentication().V1alpha1().Policies().Informer()}, nil + + } + + return nil, fmt.Errorf("no informer found for %v", resource) +} diff --git a/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/internalinterfaces/factory_interfaces.go b/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/internalinterfaces/factory_interfaces.go new file mode 100644 index 000000000000..12d454e3feb9 --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/informers/externalversions/internalinterfaces/factory_interfaces.go @@ -0,0 +1,22 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package internalinterfaces + +import ( + time "time" + + versioned "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + cache "k8s.io/client-go/tools/cache" +) + +type NewInformerFunc func(versioned.Interface, time.Duration) cache.SharedIndexInformer + +// SharedInformerFactory a small interface to allow for adding an informer without an import cycle +type SharedInformerFactory interface { + Start(stopCh <-chan struct{}) + InformerFor(obj runtime.Object, newFunc NewInformerFunc) cache.SharedIndexInformer +} + +type TweakListOptionsFunc func(*v1.ListOptions) diff --git a/components/api-controller/pkg/clients/authentication.istio.io/listers/authentication.istio.io/v1alpha1/expansion_generated.go b/components/api-controller/pkg/clients/authentication.istio.io/listers/authentication.istio.io/v1alpha1/expansion_generated.go new file mode 100644 index 000000000000..972ce8c9e028 --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/listers/authentication.istio.io/v1alpha1/expansion_generated.go @@ -0,0 +1,11 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +// PolicyListerExpansion allows custom methods to be added to +// PolicyLister. +type PolicyListerExpansion interface{} + +// PolicyNamespaceListerExpansion allows custom methods to be added to +// PolicyNamespaceLister. +type PolicyNamespaceListerExpansion interface{} diff --git a/components/api-controller/pkg/clients/authentication.istio.io/listers/authentication.istio.io/v1alpha1/policy.go b/components/api-controller/pkg/clients/authentication.istio.io/listers/authentication.istio.io/v1alpha1/policy.go new file mode 100644 index 000000000000..dffef57f5014 --- /dev/null +++ b/components/api-controller/pkg/clients/authentication.istio.io/listers/authentication.istio.io/v1alpha1/policy.go @@ -0,0 +1,78 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// PolicyLister helps list Policies. +type PolicyLister interface { + // List lists all Policies in the indexer. + List(selector labels.Selector) (ret []*v1alpha1.Policy, err error) + // Policies returns an object that can list and get Policies. + Policies(namespace string) PolicyNamespaceLister + PolicyListerExpansion +} + +// policyLister implements the PolicyLister interface. +type policyLister struct { + indexer cache.Indexer +} + +// NewPolicyLister returns a new PolicyLister. +func NewPolicyLister(indexer cache.Indexer) PolicyLister { + return &policyLister{indexer: indexer} +} + +// List lists all Policies in the indexer. +func (s *policyLister) List(selector labels.Selector) (ret []*v1alpha1.Policy, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.Policy)) + }) + return ret, err +} + +// Policies returns an object that can list and get Policies. +func (s *policyLister) Policies(namespace string) PolicyNamespaceLister { + return policyNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// PolicyNamespaceLister helps list and get Policies. +type PolicyNamespaceLister interface { + // List lists all Policies in the indexer for a given namespace. + List(selector labels.Selector) (ret []*v1alpha1.Policy, err error) + // Get retrieves the Policy from the indexer for a given namespace and name. + Get(name string) (*v1alpha1.Policy, error) + PolicyNamespaceListerExpansion +} + +// policyNamespaceLister implements the PolicyNamespaceLister +// interface. +type policyNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all Policies in the indexer for a given namespace. +func (s policyNamespaceLister) List(selector labels.Selector) (ret []*v1alpha1.Policy, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.Policy)) + }) + return ret, err +} + +// Get retrieves the Policy from the indexer for a given namespace and name. +func (s policyNamespaceLister) Get(name string) (*v1alpha1.Policy, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha1.Resource("policy"), name) + } + return obj.(*v1alpha1.Policy), nil +} diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/clientset.go b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/clientset.go new file mode 100644 index 000000000000..1839e8c2dd32 --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/clientset.go @@ -0,0 +1,84 @@ +// Code generated by client-gen. DO NOT EDIT. + +package versioned + +import ( + glog "github.com/golang/glog" + gatewayv1alpha2 "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2" + discovery "k8s.io/client-go/discovery" + rest "k8s.io/client-go/rest" + flowcontrol "k8s.io/client-go/util/flowcontrol" +) + +type Interface interface { + Discovery() discovery.DiscoveryInterface + GatewayV1alpha2() gatewayv1alpha2.GatewayV1alpha2Interface + // Deprecated: please explicitly pick a version if possible. + Gateway() gatewayv1alpha2.GatewayV1alpha2Interface +} + +// Clientset contains the clients for groups. Each group has exactly one +// version included in a Clientset. +type Clientset struct { + *discovery.DiscoveryClient + gatewayV1alpha2 *gatewayv1alpha2.GatewayV1alpha2Client +} + +// GatewayV1alpha2 retrieves the GatewayV1alpha2Client +func (c *Clientset) GatewayV1alpha2() gatewayv1alpha2.GatewayV1alpha2Interface { + return c.gatewayV1alpha2 +} + +// Deprecated: Gateway retrieves the default version of GatewayClient. +// Please explicitly pick a version. +func (c *Clientset) Gateway() gatewayv1alpha2.GatewayV1alpha2Interface { + return c.gatewayV1alpha2 +} + +// Discovery retrieves the DiscoveryClient +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + if c == nil { + return nil + } + return c.DiscoveryClient +} + +// NewForConfig creates a new Clientset for the given config. +func NewForConfig(c *rest.Config) (*Clientset, error) { + configShallowCopy := *c + if configShallowCopy.RateLimiter == nil && configShallowCopy.QPS > 0 { + configShallowCopy.RateLimiter = flowcontrol.NewTokenBucketRateLimiter(configShallowCopy.QPS, configShallowCopy.Burst) + } + var cs Clientset + var err error + cs.gatewayV1alpha2, err = gatewayv1alpha2.NewForConfig(&configShallowCopy) + if err != nil { + return nil, err + } + + cs.DiscoveryClient, err = discovery.NewDiscoveryClientForConfig(&configShallowCopy) + if err != nil { + glog.Errorf("failed to create the DiscoveryClient: %v", err) + return nil, err + } + return &cs, nil +} + +// NewForConfigOrDie creates a new Clientset for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *Clientset { + var cs Clientset + cs.gatewayV1alpha2 = gatewayv1alpha2.NewForConfigOrDie(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClientForConfigOrDie(c) + return &cs +} + +// New creates a new Clientset for the given RESTClient. +func New(c rest.Interface) *Clientset { + var cs Clientset + cs.gatewayV1alpha2 = gatewayv1alpha2.New(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClient(c) + return &cs +} diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/doc.go b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/doc.go new file mode 100644 index 000000000000..0e0c2a8900e2 --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated clientset. +package versioned diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/fake/clientset_generated.go b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/fake/clientset_generated.go new file mode 100644 index 000000000000..a34272af8092 --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/fake/clientset_generated.go @@ -0,0 +1,65 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + clientset "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned" + gatewayv1alpha2 "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2" + fakegatewayv1alpha2 "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/fake" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/discovery" + fakediscovery "k8s.io/client-go/discovery/fake" + "k8s.io/client-go/testing" +) + +// NewSimpleClientset returns a clientset that will respond with the provided objects. +// It's backed by a very simple object tracker that processes creates, updates and deletions as-is, +// without applying any validations and/or defaults. It shouldn't be considered a replacement +// for a real clientset and is mostly useful in simple unit tests. +func NewSimpleClientset(objects ...runtime.Object) *Clientset { + o := testing.NewObjectTracker(scheme, codecs.UniversalDecoder()) + for _, obj := range objects { + if err := o.Add(obj); err != nil { + panic(err) + } + } + + fakePtr := testing.Fake{} + fakePtr.AddReactor("*", "*", testing.ObjectReaction(o)) + fakePtr.AddWatchReactor("*", func(action testing.Action) (handled bool, ret watch.Interface, err error) { + gvr := action.GetResource() + ns := action.GetNamespace() + watch, err := o.Watch(gvr, ns) + if err != nil { + return false, nil, err + } + return true, watch, nil + }) + + return &Clientset{fakePtr, &fakediscovery.FakeDiscovery{Fake: &fakePtr}} +} + +// Clientset implements clientset.Interface. Meant to be embedded into a +// struct to get a default implementation. This makes faking out just the method +// you want to test easier. +type Clientset struct { + testing.Fake + discovery *fakediscovery.FakeDiscovery +} + +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + return c.discovery +} + +var _ clientset.Interface = &Clientset{} + +// GatewayV1alpha2 retrieves the GatewayV1alpha2Client +func (c *Clientset) GatewayV1alpha2() gatewayv1alpha2.GatewayV1alpha2Interface { + return &fakegatewayv1alpha2.FakeGatewayV1alpha2{Fake: &c.Fake} +} + +// Gateway retrieves the GatewayV1alpha2Client +func (c *Clientset) Gateway() gatewayv1alpha2.GatewayV1alpha2Interface { + return &fakegatewayv1alpha2.FakeGatewayV1alpha2{Fake: &c.Fake} +} diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/fake/doc.go b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/fake/doc.go new file mode 100644 index 000000000000..3630ed1cd17d --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/fake/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated fake clientset. +package fake diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/fake/register.go b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/fake/register.go new file mode 100644 index 000000000000..29a1dba8a18b --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/fake/register.go @@ -0,0 +1,38 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + gatewayv1alpha2 "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var scheme = runtime.NewScheme() +var codecs = serializer.NewCodecFactory(scheme) +var parameterCodec = runtime.NewParameterCodec(scheme) + +func init() { + v1.AddToGroupVersion(scheme, schema.GroupVersion{Version: "v1"}) + AddToScheme(scheme) +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +func AddToScheme(scheme *runtime.Scheme) { + gatewayv1alpha2.AddToScheme(scheme) +} diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/scheme/doc.go b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/scheme/doc.go new file mode 100644 index 000000000000..14db57a58f8d --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/scheme/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package contains the scheme of the automatically generated clientset. +package scheme diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/scheme/register.go b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/scheme/register.go new file mode 100644 index 000000000000..26a04c601673 --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/scheme/register.go @@ -0,0 +1,38 @@ +// Code generated by client-gen. DO NOT EDIT. + +package scheme + +import ( + gatewayv1alpha2 "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var Scheme = runtime.NewScheme() +var Codecs = serializer.NewCodecFactory(Scheme) +var ParameterCodec = runtime.NewParameterCodec(Scheme) + +func init() { + v1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"}) + AddToScheme(Scheme) +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +func AddToScheme(scheme *runtime.Scheme) { + gatewayv1alpha2.AddToScheme(scheme) +} diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/api.go b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/api.go new file mode 100644 index 000000000000..e1de6731330b --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/api.go @@ -0,0 +1,158 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + v1alpha2 "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2" + scheme "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// ApisGetter has a method to return a ApiInterface. +// A group's client should implement this interface. +type ApisGetter interface { + Apis(namespace string) ApiInterface +} + +// ApiInterface has methods to work with Api resources. +type ApiInterface interface { + Create(*v1alpha2.Api) (*v1alpha2.Api, error) + Update(*v1alpha2.Api) (*v1alpha2.Api, error) + UpdateStatus(*v1alpha2.Api) (*v1alpha2.Api, error) + Delete(name string, options *v1.DeleteOptions) error + DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error + Get(name string, options v1.GetOptions) (*v1alpha2.Api, error) + List(opts v1.ListOptions) (*v1alpha2.ApiList, error) + Watch(opts v1.ListOptions) (watch.Interface, error) + Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha2.Api, err error) + ApiExpansion +} + +// apis implements ApiInterface +type apis struct { + client rest.Interface + ns string +} + +// newApis returns a Apis +func newApis(c *GatewayV1alpha2Client, namespace string) *apis { + return &apis{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the api, and returns the corresponding api object, and an error if there is any. +func (c *apis) Get(name string, options v1.GetOptions) (result *v1alpha2.Api, err error) { + result = &v1alpha2.Api{} + err = c.client.Get(). + Namespace(c.ns). + Resource("apis"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of Apis that match those selectors. +func (c *apis) List(opts v1.ListOptions) (result *v1alpha2.ApiList, err error) { + result = &v1alpha2.ApiList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("apis"). + VersionedParams(&opts, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested apis. +func (c *apis) Watch(opts v1.ListOptions) (watch.Interface, error) { + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("apis"). + VersionedParams(&opts, scheme.ParameterCodec). + Watch() +} + +// Create takes the representation of a api and creates it. Returns the server's representation of the api, and an error, if there is any. +func (c *apis) Create(api *v1alpha2.Api) (result *v1alpha2.Api, err error) { + result = &v1alpha2.Api{} + err = c.client.Post(). + Namespace(c.ns). + Resource("apis"). + Body(api). + Do(). + Into(result) + return +} + +// Update takes the representation of a api and updates it. Returns the server's representation of the api, and an error, if there is any. +func (c *apis) Update(api *v1alpha2.Api) (result *v1alpha2.Api, err error) { + result = &v1alpha2.Api{} + err = c.client.Put(). + Namespace(c.ns). + Resource("apis"). + Name(api.Name). + Body(api). + Do(). + Into(result) + return +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + +func (c *apis) UpdateStatus(api *v1alpha2.Api) (result *v1alpha2.Api, err error) { + result = &v1alpha2.Api{} + err = c.client.Put(). + Namespace(c.ns). + Resource("apis"). + Name(api.Name). + SubResource("status"). + Body(api). + Do(). + Into(result) + return +} + +// Delete takes name of the api and deletes it. Returns an error if one occurs. +func (c *apis) Delete(name string, options *v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("apis"). + Name(name). + Body(options). + Do(). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *apis) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("apis"). + VersionedParams(&listOptions, scheme.ParameterCodec). + Body(options). + Do(). + Error() +} + +// Patch applies the patch and returns the patched api. +func (c *apis) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha2.Api, err error) { + result = &v1alpha2.Api{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("apis"). + SubResource(subresources...). + Name(name). + Body(data). + Do(). + Into(result) + return +} diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/doc.go b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/doc.go new file mode 100644 index 000000000000..c11da26828d9 --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated typed clients. +package v1alpha2 diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/fake/doc.go b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/fake/doc.go new file mode 100644 index 000000000000..2b5ba4c8e442 --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/fake/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// Package fake has the automatically generated clients. +package fake diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/fake/fake_api.go b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/fake/fake_api.go new file mode 100644 index 000000000000..393aa37ad53e --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/fake/fake_api.go @@ -0,0 +1,124 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha2 "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeApis implements ApiInterface +type FakeApis struct { + Fake *FakeGatewayV1alpha2 + ns string +} + +var apisResource = schema.GroupVersionResource{Group: "gateway.kyma.cx", Version: "v1alpha2", Resource: "apis"} + +var apisKind = schema.GroupVersionKind{Group: "gateway.kyma.cx", Version: "v1alpha2", Kind: "Api"} + +// Get takes name of the api, and returns the corresponding api object, and an error if there is any. +func (c *FakeApis) Get(name string, options v1.GetOptions) (result *v1alpha2.Api, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(apisResource, c.ns, name), &v1alpha2.Api{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha2.Api), err +} + +// List takes label and field selectors, and returns the list of Apis that match those selectors. +func (c *FakeApis) List(opts v1.ListOptions) (result *v1alpha2.ApiList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(apisResource, apisKind, c.ns, opts), &v1alpha2.ApiList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha2.ApiList{} + for _, item := range obj.(*v1alpha2.ApiList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested apis. +func (c *FakeApis) Watch(opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(apisResource, c.ns, opts)) + +} + +// Create takes the representation of a api and creates it. Returns the server's representation of the api, and an error, if there is any. +func (c *FakeApis) Create(api *v1alpha2.Api) (result *v1alpha2.Api, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(apisResource, c.ns, api), &v1alpha2.Api{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha2.Api), err +} + +// Update takes the representation of a api and updates it. Returns the server's representation of the api, and an error, if there is any. +func (c *FakeApis) Update(api *v1alpha2.Api) (result *v1alpha2.Api, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(apisResource, c.ns, api), &v1alpha2.Api{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha2.Api), err +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). +func (c *FakeApis) UpdateStatus(api *v1alpha2.Api) (*v1alpha2.Api, error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateSubresourceAction(apisResource, "status", c.ns, api), &v1alpha2.Api{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha2.Api), err +} + +// Delete takes name of the api and deletes it. Returns an error if one occurs. +func (c *FakeApis) Delete(name string, options *v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(apisResource, c.ns, name), &v1alpha2.Api{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeApis) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(apisResource, c.ns, listOptions) + + _, err := c.Fake.Invokes(action, &v1alpha2.ApiList{}) + return err +} + +// Patch applies the patch and returns the patched api. +func (c *FakeApis) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha2.Api, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(apisResource, c.ns, name, data, subresources...), &v1alpha2.Api{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha2.Api), err +} diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/fake/fake_gateway.kyma.cx_client.go b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/fake/fake_gateway.kyma.cx_client.go new file mode 100644 index 000000000000..628f5d26c26f --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/fake/fake_gateway.kyma.cx_client.go @@ -0,0 +1,24 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha2 "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2" + rest "k8s.io/client-go/rest" + testing "k8s.io/client-go/testing" +) + +type FakeGatewayV1alpha2 struct { + *testing.Fake +} + +func (c *FakeGatewayV1alpha2) Apis(namespace string) v1alpha2.ApiInterface { + return &FakeApis{c, namespace} +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *FakeGatewayV1alpha2) RESTClient() rest.Interface { + var ret *rest.RESTClient + return ret +} diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/gateway.kyma.cx_client.go b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/gateway.kyma.cx_client.go new file mode 100644 index 000000000000..4ac1b6fbb885 --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/gateway.kyma.cx_client.go @@ -0,0 +1,74 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + v1alpha2 "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2" + "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/scheme" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + rest "k8s.io/client-go/rest" +) + +type GatewayV1alpha2Interface interface { + RESTClient() rest.Interface + ApisGetter +} + +// GatewayV1alpha2Client is used to interact with features provided by the gateway.kyma.cx group. +type GatewayV1alpha2Client struct { + restClient rest.Interface +} + +func (c *GatewayV1alpha2Client) Apis(namespace string) ApiInterface { + return newApis(c, namespace) +} + +// NewForConfig creates a new GatewayV1alpha2Client for the given config. +func NewForConfig(c *rest.Config) (*GatewayV1alpha2Client, error) { + config := *c + if err := setConfigDefaults(&config); err != nil { + return nil, err + } + client, err := rest.RESTClientFor(&config) + if err != nil { + return nil, err + } + return &GatewayV1alpha2Client{client}, nil +} + +// NewForConfigOrDie creates a new GatewayV1alpha2Client for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *GatewayV1alpha2Client { + client, err := NewForConfig(c) + if err != nil { + panic(err) + } + return client +} + +// New creates a new GatewayV1alpha2Client for the given RESTClient. +func New(c rest.Interface) *GatewayV1alpha2Client { + return &GatewayV1alpha2Client{c} +} + +func setConfigDefaults(config *rest.Config) error { + gv := v1alpha2.SchemeGroupVersion + config.GroupVersion = &gv + config.APIPath = "/apis" + config.NegotiatedSerializer = serializer.DirectCodecFactory{CodecFactory: scheme.Codecs} + + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } + + return nil +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *GatewayV1alpha2Client) RESTClient() rest.Interface { + if c == nil { + return nil + } + return c.restClient +} diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/generated_expansion.go b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/generated_expansion.go new file mode 100644 index 000000000000..5991b6c2351b --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/generated_expansion.go @@ -0,0 +1,5 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha2 + +type ApiExpansion interface{} diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/factory.go b/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/factory.go new file mode 100644 index 000000000000..ff088727e106 --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/factory.go @@ -0,0 +1,115 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + reflect "reflect" + sync "sync" + time "time" + + versioned "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned" + gateway_kyma_cx "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/gateway.kyma.cx" + internalinterfaces "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/internalinterfaces" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +type sharedInformerFactory struct { + client versioned.Interface + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc + lock sync.Mutex + defaultResync time.Duration + + informers map[reflect.Type]cache.SharedIndexInformer + // startedInformers is used for tracking which informers have been started. + // This allows Start() to be called multiple times safely. + startedInformers map[reflect.Type]bool +} + +// NewSharedInformerFactory constructs a new instance of sharedInformerFactory +func NewSharedInformerFactory(client versioned.Interface, defaultResync time.Duration) SharedInformerFactory { + return NewFilteredSharedInformerFactory(client, defaultResync, v1.NamespaceAll, nil) +} + +// NewFilteredSharedInformerFactory constructs a new instance of sharedInformerFactory. +// Listers obtained via this SharedInformerFactory will be subject to the same filters +// as specified here. +func NewFilteredSharedInformerFactory(client versioned.Interface, defaultResync time.Duration, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) SharedInformerFactory { + return &sharedInformerFactory{ + client: client, + namespace: namespace, + tweakListOptions: tweakListOptions, + defaultResync: defaultResync, + informers: make(map[reflect.Type]cache.SharedIndexInformer), + startedInformers: make(map[reflect.Type]bool), + } +} + +// Start initializes all requested informers. +func (f *sharedInformerFactory) Start(stopCh <-chan struct{}) { + f.lock.Lock() + defer f.lock.Unlock() + + for informerType, informer := range f.informers { + if !f.startedInformers[informerType] { + go informer.Run(stopCh) + f.startedInformers[informerType] = true + } + } +} + +// WaitForCacheSync waits for all started informers' cache were synced. +func (f *sharedInformerFactory) WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool { + informers := func() map[reflect.Type]cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informers := map[reflect.Type]cache.SharedIndexInformer{} + for informerType, informer := range f.informers { + if f.startedInformers[informerType] { + informers[informerType] = informer + } + } + return informers + }() + + res := map[reflect.Type]bool{} + for informType, informer := range informers { + res[informType] = cache.WaitForCacheSync(stopCh, informer.HasSynced) + } + return res +} + +// InternalInformerFor returns the SharedIndexInformer for obj using an internal +// client. +func (f *sharedInformerFactory) InformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informerType := reflect.TypeOf(obj) + informer, exists := f.informers[informerType] + if exists { + return informer + } + informer = newFunc(f.client, f.defaultResync) + f.informers[informerType] = informer + + return informer +} + +// SharedInformerFactory provides shared informers for resources in all known +// API group versions. +type SharedInformerFactory interface { + internalinterfaces.SharedInformerFactory + ForResource(resource schema.GroupVersionResource) (GenericInformer, error) + WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool + + Gateway() gateway_kyma_cx.Interface +} + +func (f *sharedInformerFactory) Gateway() gateway_kyma_cx.Interface { + return gateway_kyma_cx.New(f, f.namespace, f.tweakListOptions) +} diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/gateway.kyma.cx/interface.go b/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/gateway.kyma.cx/interface.go new file mode 100644 index 000000000000..6a929cc87615 --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/gateway.kyma.cx/interface.go @@ -0,0 +1,30 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package gateway + +import ( + v1alpha2 "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/gateway.kyma.cx/v1alpha2" + internalinterfaces "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/internalinterfaces" +) + +// Interface provides access to each of this group's versions. +type Interface interface { + // V1alpha2 provides access to shared informers for resources in V1alpha2. + V1alpha2() v1alpha2.Interface +} + +type group struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// V1alpha2 returns a new v1alpha2.Interface. +func (g *group) V1alpha2() v1alpha2.Interface { + return v1alpha2.New(g.factory, g.namespace, g.tweakListOptions) +} diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/gateway.kyma.cx/v1alpha2/api.go b/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/gateway.kyma.cx/v1alpha2/api.go new file mode 100644 index 000000000000..ce0155d8babe --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/gateway.kyma.cx/v1alpha2/api.go @@ -0,0 +1,73 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + time "time" + + gateway_kyma_cx_v1alpha2 "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2" + versioned "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/internalinterfaces" + v1alpha2 "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/listers/gateway.kyma.cx/v1alpha2" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// ApiInformer provides access to a shared informer and lister for +// Apis. +type ApiInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha2.ApiLister +} + +type apiInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewApiInformer constructs a new informer for Api type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewApiInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredApiInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredApiInformer constructs a new informer for Api type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredApiInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.GatewayV1alpha2().Apis(namespace).List(options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.GatewayV1alpha2().Apis(namespace).Watch(options) + }, + }, + &gateway_kyma_cx_v1alpha2.Api{}, + resyncPeriod, + indexers, + ) +} + +func (f *apiInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredApiInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *apiInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&gateway_kyma_cx_v1alpha2.Api{}, f.defaultInformer) +} + +func (f *apiInformer) Lister() v1alpha2.ApiLister { + return v1alpha2.NewApiLister(f.Informer().GetIndexer()) +} diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/gateway.kyma.cx/v1alpha2/interface.go b/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/gateway.kyma.cx/v1alpha2/interface.go new file mode 100644 index 000000000000..74c117b2efd4 --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/gateway.kyma.cx/v1alpha2/interface.go @@ -0,0 +1,29 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + internalinterfaces "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/internalinterfaces" +) + +// Interface provides access to all the informers in this group version. +type Interface interface { + // Apis returns a ApiInformer. + Apis() ApiInformer +} + +type version struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// Apis returns a ApiInformer. +func (v *version) Apis() ApiInformer { + return &apiInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/generic.go b/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/generic.go new file mode 100644 index 000000000000..db5aca60919f --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/generic.go @@ -0,0 +1,46 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + "fmt" + + v1alpha2 "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +// GenericInformer is type of SharedIndexInformer which will locate and delegate to other +// sharedInformers based on type +type GenericInformer interface { + Informer() cache.SharedIndexInformer + Lister() cache.GenericLister +} + +type genericInformer struct { + informer cache.SharedIndexInformer + resource schema.GroupResource +} + +// Informer returns the SharedIndexInformer. +func (f *genericInformer) Informer() cache.SharedIndexInformer { + return f.informer +} + +// Lister returns the GenericLister. +func (f *genericInformer) Lister() cache.GenericLister { + return cache.NewGenericLister(f.Informer().GetIndexer(), f.resource) +} + +// ForResource gives generic access to a shared informer of the matching type +// TODO extend this to unknown resources with a client pool +func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { + switch resource { + // Group=gateway.kyma.cx, Version=v1alpha2 + case v1alpha2.SchemeGroupVersion.WithResource("apis"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Gateway().V1alpha2().Apis().Informer()}, nil + + } + + return nil, fmt.Errorf("no informer found for %v", resource) +} diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/internalinterfaces/factory_interfaces.go b/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/internalinterfaces/factory_interfaces.go new file mode 100644 index 000000000000..89dd01da66b7 --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/internalinterfaces/factory_interfaces.go @@ -0,0 +1,22 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package internalinterfaces + +import ( + time "time" + + versioned "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + cache "k8s.io/client-go/tools/cache" +) + +type NewInformerFunc func(versioned.Interface, time.Duration) cache.SharedIndexInformer + +// SharedInformerFactory a small interface to allow for adding an informer without an import cycle +type SharedInformerFactory interface { + Start(stopCh <-chan struct{}) + InformerFor(obj runtime.Object, newFunc NewInformerFunc) cache.SharedIndexInformer +} + +type TweakListOptionsFunc func(*v1.ListOptions) diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/listers/gateway.kyma.cx/v1alpha2/api.go b/components/api-controller/pkg/clients/gateway.kyma.cx/listers/gateway.kyma.cx/v1alpha2/api.go new file mode 100644 index 000000000000..b4c78a08f438 --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/listers/gateway.kyma.cx/v1alpha2/api.go @@ -0,0 +1,78 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + v1alpha2 "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// ApiLister helps list Apis. +type ApiLister interface { + // List lists all Apis in the indexer. + List(selector labels.Selector) (ret []*v1alpha2.Api, err error) + // Apis returns an object that can list and get Apis. + Apis(namespace string) ApiNamespaceLister + ApiListerExpansion +} + +// apiLister implements the ApiLister interface. +type apiLister struct { + indexer cache.Indexer +} + +// NewApiLister returns a new ApiLister. +func NewApiLister(indexer cache.Indexer) ApiLister { + return &apiLister{indexer: indexer} +} + +// List lists all Apis in the indexer. +func (s *apiLister) List(selector labels.Selector) (ret []*v1alpha2.Api, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha2.Api)) + }) + return ret, err +} + +// Apis returns an object that can list and get Apis. +func (s *apiLister) Apis(namespace string) ApiNamespaceLister { + return apiNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// ApiNamespaceLister helps list and get Apis. +type ApiNamespaceLister interface { + // List lists all Apis in the indexer for a given namespace. + List(selector labels.Selector) (ret []*v1alpha2.Api, err error) + // Get retrieves the Api from the indexer for a given namespace and name. + Get(name string) (*v1alpha2.Api, error) + ApiNamespaceListerExpansion +} + +// apiNamespaceLister implements the ApiNamespaceLister +// interface. +type apiNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all Apis in the indexer for a given namespace. +func (s apiNamespaceLister) List(selector labels.Selector) (ret []*v1alpha2.Api, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha2.Api)) + }) + return ret, err +} + +// Get retrieves the Api from the indexer for a given namespace and name. +func (s apiNamespaceLister) Get(name string) (*v1alpha2.Api, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha2.Resource("api"), name) + } + return obj.(*v1alpha2.Api), nil +} diff --git a/components/api-controller/pkg/clients/gateway.kyma.cx/listers/gateway.kyma.cx/v1alpha2/expansion_generated.go b/components/api-controller/pkg/clients/gateway.kyma.cx/listers/gateway.kyma.cx/v1alpha2/expansion_generated.go new file mode 100644 index 000000000000..e23c9055b0ca --- /dev/null +++ b/components/api-controller/pkg/clients/gateway.kyma.cx/listers/gateway.kyma.cx/v1alpha2/expansion_generated.go @@ -0,0 +1,11 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha2 + +// ApiListerExpansion allows custom methods to be added to +// ApiLister. +type ApiListerExpansion interface{} + +// ApiNamespaceListerExpansion allows custom methods to be added to +// ApiNamespaceLister. +type ApiNamespaceListerExpansion interface{} diff --git a/components/api-controller/pkg/controller/authentication/v2/authenticationv2.go b/components/api-controller/pkg/controller/authentication/v2/authenticationv2.go new file mode 100644 index 000000000000..507731b0c1a2 --- /dev/null +++ b/components/api-controller/pkg/controller/authentication/v2/authenticationv2.go @@ -0,0 +1,182 @@ +package v2 + +import ( + "reflect" + + istioAuthApi "github.com/kyma-project/kyma/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1" + kymaMeta "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/meta/v1" + istioAuth "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned" + istioAuthTyped "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/typed/authentication.istio.io/v1alpha1" + "github.com/kyma-project/kyma/components/api-controller/pkg/controller/commons" + "github.com/kyma-project/kyma/components/api-controller/pkg/controller/meta" + log "github.com/sirupsen/logrus" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + k8sMeta "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type istioImpl struct { + istioAuthInterface istioAuth.Interface +} + +func New(a istioAuth.Interface) Interface { + return &istioImpl{ + istioAuthInterface: a, + } +} + +func (a *istioImpl) Create(dto *Dto) (*kymaMeta.GatewayResource, error) { + + if isAuthenticationDisabled(dto) { + return nil, nil + } + + istioAuthPolicy := toIstioAuthPolicy(dto) + + log.Debugf("Creating authentication policy: %v", istioAuthPolicy) + + created, err := a.istioAuthPolicyInterface(dto.MetaDto).Create(istioAuthPolicy) + if err != nil { + return nil, commons.HandleError(err, "error while creating authentication policy") + } + + log.Debugf("Authentication policy created: %v", istioAuthPolicy) + return gatewayResourceFrom(created), nil +} + +func (a *istioImpl) Update(oldDto, newDto *Dto) (*kymaMeta.GatewayResource, error) { + + if isAuthenticationDisabled(newDto) { + + log.Debugf("Authentication disabled. Trying to delete the old authentication policy...") + // no new newRule; we only have to delete the old one, if it exists + if err := a.Delete(oldDto); err != nil { + return nil, err + } + return nil, nil + } + + // there is a authentication policy to update / create + newIstioAuthPolicy := toIstioAuthPolicy(newDto) + + log.Debugf("Authentication enabled. Trying to create or update authentication policy: %v", newIstioAuthPolicy) + + // checking if authentication policy has to be created + if oldDto == nil || oldDto.Status.Resource.Name == "" { + + log.Debug("Authentication policy does not exist. Creating...") + + // create newRule + createdResource, err := a.Create(newDto) + + if err != nil { + return nil, commons.HandleError(err, "error while recreating authentication policy (can not create a new one)") + } + + log.Debugf("Authentication policy created: %v", createdResource) + return createdResource, nil + } + + oldIstioAuthPolicy := toIstioAuthPolicy(oldDto) + + if a.isEqual(oldIstioAuthPolicy, newIstioAuthPolicy) { + + log.Debugf("Update skipped: authentication policy has not changed.") + return nil, nil + } + + newIstioAuthPolicy.ObjectMeta.ResourceVersion = oldDto.Status.Resource.Version + + // newRule should be updated (i was not recreated and it was differs from the old one) + log.Debugf("Updating authentication policy: %v", newIstioAuthPolicy) + + updated, err := a.istioAuthPolicyInterface(newDto.MetaDto).Update(newIstioAuthPolicy) + if err != nil { + return nil, commons.HandleError(err, "error while updating authentication policy") + } + + log.Debugf("Authentication policy updated: %v", updated) + return gatewayResourceFrom(updated), nil +} + +func (a *istioImpl) Delete(dto *Dto) error { + + if dto == nil { + log.Debug("Delete skipped: no authentication policy to delete.") + return nil + } + return a.deleteByName(dto.MetaDto) +} + +func (a *istioImpl) deleteByName(meta meta.Dto) error { + + // if there is no rule to delete, just skip it + if meta.Name == "" { + log.Debug("Delete skipped: no authentication policy to delete.") + return nil + } + log.Debugf("Deleting authentication policy: %s", meta.Name) + + err := a.istioAuthPolicyInterface(meta).Delete(meta.Name, &k8sMeta.DeleteOptions{}) + if err != nil && !apiErrors.IsNotFound(err) { + return commons.HandleError(err, "error while deleting authentication policy") + } + + log.Debugf("Authentication policy deleted: %+v", meta.Name) + return nil +} + +func (a *istioImpl) istioAuthPolicyInterface(metaDto meta.Dto) istioAuthTyped.PolicyInterface { + return a.istioAuthInterface.AuthenticationV1alpha1().Policies(metaDto.Namespace) +} + +func (a *istioImpl) isEqual(oldRule *istioAuthApi.Policy, newRule *istioAuthApi.Policy) bool { + return reflect.DeepEqual(oldRule.Spec, newRule.Spec) +} + +func toIstioAuthPolicy(dto *Dto) *istioAuthApi.Policy { + + objectMetadata := k8sMeta.ObjectMeta{ + Name: dto.MetaDto.Name, + Namespace: dto.MetaDto.Namespace, + Labels: dto.MetaDto.Labels, + } + + spec := &istioAuthApi.PolicySpec{ + Targets: []*istioAuthApi.Target{ + {Name: dto.ServiceName}, + }, + } + + origins := make(istioAuthApi.Origins, 0, 1) + for _, rule := range dto.Rules { + + if rule.Type == JwtType { + origins = append(origins, &istioAuthApi.Origin{ + Jwt: &istioAuthApi.Jwt{ + Issuer: rule.Jwt.Issuer, + JwksUri: rule.Jwt.JwksUri, + }, + }) + } + } + spec.Origins = origins + + spec.PrincipalBinding = istioAuthApi.UseOrigin + + return &istioAuthApi.Policy{ + ObjectMeta: objectMetadata, + Spec: spec, + } +} + +func isAuthenticationDisabled(dto *Dto) bool { + return dto == nil || len(dto.Rules) == 0 +} + +func gatewayResourceFrom(policy *istioAuthApi.Policy) *kymaMeta.GatewayResource { + return &kymaMeta.GatewayResource{ + Name: policy.Name, + Uid: policy.UID, + Version: policy.ResourceVersion, + } +} diff --git a/components/api-controller/pkg/controller/authentication/v2/authenticationv2_integration_test.go b/components/api-controller/pkg/controller/authentication/v2/authenticationv2_integration_test.go new file mode 100644 index 000000000000..2b31c1420bb9 --- /dev/null +++ b/components/api-controller/pkg/controller/authentication/v2/authenticationv2_integration_test.go @@ -0,0 +1,111 @@ +package v2 + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + kymaMeta "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/meta/v1" + istioAuth "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned" + "github.com/kyma-project/kyma/components/api-controller/pkg/controller/meta" + "k8s.io/client-go/tools/clientcmd" +) + +func TestIntegrationCreateUpdateAndDeleteAuthentication(t *testing.T) { + + if testing.Short() { + t.Skip("Skipping in short mode.") + return + } + + // given + + authentication, err := authenticationFromDefaultConfig() + if err != nil { + t.Error(err) + return + } + + rules := Rules{ + { + Type: JwtType, + Jwt: Jwt{ + Issuer: "https://accounts.google.com", + JwksUri: "https://www.googleapis.com/oauth2/v3/certs", + }, + }, + } + + authDto := &Dto{ + MetaDto: meta.Dto{ + Name: "httpbin-api", + Namespace: "default", + }, + ServiceName: "sample-app-kfvcdftg-0", + Rules: rules, + } + + // when + + t.Logf("CREATE: %+v", authDto) + + createdResource, err2 := authentication.Create(authDto) + if err2 != nil { + t.Errorf("Unable to create authentication. Root cause: %v", err2) + return + } + authDto.Status.Resource = *createdResource + + t.Logf("CREATED RESOURCE: %+v", createdResource) + + rules = Rules{ + { + Type: JwtType, + Jwt: Jwt{ + Issuer: "https://dex.nightly.cluster.kyma.cx", + JwksUri: "https://dex.nightly.cluster.kyma.cx/keys", + }, + }, + } + + t.Logf("UPDATE: %+v", authDto) + + deleteAuthentication := func() { + + t.Logf("DELETE: %+v", authDto) + + err4 := authentication.Delete(authDto) + if err4 != nil { + t.Errorf("Unable to delete authentication. Root cause: %v", err4) + return + } + } + defer deleteAuthentication() + + oldApiDto := &Dto{ + Status: kymaMeta.GatewayResourceStatus{ + Resource: *createdResource, + }, + } + + _, err3 := authentication.Update(oldApiDto, authDto) + if err3 != nil { + t.Errorf("Unable to update authentication. Root cause: %v", err3) + return + } +} + +func authenticationFromDefaultConfig() (Interface, error) { + + kubeConfigLocation := filepath.Join(os.Getenv("HOME"), ".kube", "config") + + kubeConfig, err := clientcmd.BuildConfigFromFlags("", kubeConfigLocation) + if err != nil { + return nil, fmt.Errorf("unable to load kube config. Root cause: %v", err) + } + + clientset := istioAuth.NewForConfigOrDie(kubeConfig) + + return New(clientset), nil +} diff --git a/components/api-controller/pkg/controller/authentication/v2/authenticationv2_test.go b/components/api-controller/pkg/controller/authentication/v2/authenticationv2_test.go new file mode 100644 index 000000000000..08573bf65016 --- /dev/null +++ b/components/api-controller/pkg/controller/authentication/v2/authenticationv2_test.go @@ -0,0 +1,377 @@ +package v2 + +import ( + "testing" + + istioAuthApi "github.com/kyma-project/kyma/components/api-controller/pkg/apis/authentication.istio.io/v1alpha1" + kymaMeta "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/meta/v1" + istioFakes "github.com/kyma-project/kyma/components/api-controller/pkg/clients/authentication.istio.io/clientset/versioned/fake" + "github.com/kyma-project/kyma/components/api-controller/pkg/controller/meta" + k8sMeta "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestCreateAuthentication_ShouldCreateNewPolicy(t *testing.T) { + + // given + fakeIstioAuth := istioFakes.NewSimpleClientset() + authentication := New(fakeIstioAuth) + + dto := &Dto{ + MetaDto: meta.Dto{ + Name: "test-api", + Namespace: "test-namespace", + }, + ServiceName: "dummy-service", + Rules: Rules{ + { + Type: JwtType, + Jwt: Jwt{ + Issuer: "https://accounts.google.com", + JwksUri: "https://www.googleapis.com/oauth2/v3/certs", + }, + }, + }, + } + + // when + gatewayResource, err := authentication.Create(dto) + + // then + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + + if gatewayResource == nil { + t.Error("Gateway resource should not be nil.") + return + } + + if gatewayResource.Name != "test-api" { + t.Error("Gateway resource name should be the same as api name.") + return + } +} + +func TestCreateAuthentication_ShouldNotCreatePolicyIfDisabled(t *testing.T) { + + // given + fakeIstioConfig := istioFakes.NewSimpleClientset() + authentication := New(fakeIstioConfig) + + var dto *Dto = nil + + // when + gatewayResource, err := authentication.Create(dto) + + // then + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + + if gatewayResource != nil { + t.Error("Gateway resource should be nil.") + return + } +} + +func TestCreateAuthRule_ShouldNotCreatePolicyIfRulesEmpty(t *testing.T) { + + // given + fakeIstioConfig := istioFakes.NewSimpleClientset() + authentication := New(fakeIstioConfig) + + dto := &Dto{ + MetaDto: meta.Dto{ + Name: "test-api", + Namespace: "test-namespace", + }, + ServiceName: "dummy-service", + } + + // when + gatewayResource, err := authentication.Create(dto) + + // then + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + + if gatewayResource != nil { + t.Error("Gateway resource should be nil.") + return + } +} + +func TestUpdateAuthentication_ShouldCreateNewPolicy(t *testing.T) { + + // given + fakeIstioConfig := istioFakes.NewSimpleClientset() + authentication := New(fakeIstioConfig) + + oldDto := &Dto{} + + newDto := &Dto{ + MetaDto: meta.Dto{ + Name: "test-api", + Namespace: "test-namespace", + }, + ServiceName: "dummy-service", + Rules: Rules{ + { + Type: JwtType, + Jwt: Jwt{ + Issuer: "https://accounts.google.com", + JwksUri: "https://www.googleapis.com/oauth2/v3/certs", + }, + }, + }, + } + + // when + gatewayResource, err := authentication.Update(oldDto, newDto) + + // then + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + + if gatewayResource == nil { + t.Error("Gateway resource should not be nil.") + return + } + + if gatewayResource.Name != "test-api" { + t.Error("Gateway resource name should be the same as api name.") + return + } +} + +func TestUpdateAuthentication_ShouldDeleteOldPolicyIfAuthenticationDisabled(t *testing.T) { + + // given + testAuthentication := &istioAuthApi.Policy{ + ObjectMeta: k8sMeta.ObjectMeta{ + Name: "test-api", + Namespace: "test-namespace", + }, + Spec: &istioAuthApi.PolicySpec{}, + } + fakeIstioConfig := istioFakes.NewSimpleClientset(testAuthentication) + authentication := New(fakeIstioConfig) + + oldDto := &Dto{ + MetaDto: meta.Dto{ + Name: "test-api", + Namespace: "test-namespace", + }, + Status: kymaMeta.GatewayResourceStatus{ + Resource: kymaMeta.GatewayResource{ + Name: "test-api", + }, + }, + } + + var newDto *Dto = nil + + // when + gatewayResource, err := authentication.Update(oldDto, newDto) + + // then + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + + if gatewayResource != nil { + t.Error("Gateway resource should be nil (should only delete old resource).") + return + } +} + +func TestUpdateAuthentication_ShouldDeleteOldPolicyIfRulesEmpty(t *testing.T) { + + // given + testAuthentication := &istioAuthApi.Policy{ + ObjectMeta: k8sMeta.ObjectMeta{ + Name: "test-api", + Namespace: "test-namespace", + }, + Spec: &istioAuthApi.PolicySpec{}, + } + fakeIstioConfig := istioFakes.NewSimpleClientset(testAuthentication) + authentication := New(fakeIstioConfig) + + oldDto := &Dto{ + MetaDto: meta.Dto{ + Name: "test-api", + Namespace: "test-namespace", + }, + Status: kymaMeta.GatewayResourceStatus{ + Resource: kymaMeta.GatewayResource{ + Name: "test-api", + }, + }, + } + + newDto := &Dto{ + MetaDto: meta.Dto{ + Name: "test-api", + Namespace: "test-namespace", + }, + ServiceName: "dummy-service", + } + + // when + gatewayResource, err := authentication.Update(oldDto, newDto) + + // then + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + + if gatewayResource != nil { + t.Error("Gateway resource should be nil (should only delete old resource).") + return + } +} + +func TestUpdateAuthentication_ShouldDoNothingIfSRulesHasNotChanged(t *testing.T) { + + // given + fakeIstioConfig := istioFakes.NewSimpleClientset() + authentication := New(fakeIstioConfig) + + oldApi := &Dto{ + MetaDto: meta.Dto{ + Name: "test-api", + Namespace: "test-namespace", + }, + ServiceName: "dummy-service", + Rules: Rules{ + { + Type: JwtType, + Jwt: Jwt{ + Issuer: "https://accounts.google.com", + JwksUri: "https://www.googleapis.com/oauth2/v3/certs", + }, + }, + }, + Status: kymaMeta.GatewayResourceStatus{ + Resource: kymaMeta.GatewayResource{ + Name: "test-api", + }, + }, + } + + newDto := &Dto{ + MetaDto: meta.Dto{ + Name: "test-api", + Namespace: "test-namespace", + }, + ServiceName: "dummy-service", + Rules: Rules{ + { + Type: JwtType, + Jwt: Jwt{ + Issuer: "https://accounts.google.com", + JwksUri: "https://www.googleapis.com/oauth2/v3/certs", + }, + }, + }, + } + + // when + gatewayResource, err := authentication.Update(oldApi, newDto) + + // then + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + + if gatewayResource != nil { + t.Error("Gateway resource should be nil (should do nothing).") + return + } +} + +func TestDeleteAuthentication(t *testing.T) { + + // given + testAuthentication := &istioAuthApi.Policy{ + ObjectMeta: k8sMeta.ObjectMeta{ + Name: "test-api", + Namespace: "test-namespace", + }, + Spec: &istioAuthApi.PolicySpec{}, + } + fakeIstioConfig := istioFakes.NewSimpleClientset(testAuthentication) + authentication := New(fakeIstioConfig) + + dto := &Dto{ + MetaDto: meta.Dto{ + Name: "test-api", + Namespace: "test-namespace", + }, + Status: kymaMeta.GatewayResourceStatus{ + Resource: kymaMeta.GatewayResource{ + Name: "test-api", + Version: "1", + }, + }, + } + + // when + err := authentication.Delete(dto) + + // then + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } +} + +func TestDeleteAuthentication_ShouldNotFailIfNil(t *testing.T) { + + // given + fakeIstioConfig := istioFakes.NewSimpleClientset() + authentication := New(fakeIstioConfig) + + var dto *Dto = nil + + // when + err := authentication.Delete(dto) + + // then + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } +} + +func TestDeleteAuthentication_ShouldNotFailIfOldNameEmpty(t *testing.T) { + + // given + fakeIstioConfig := istioFakes.NewSimpleClientset() + authRules := New(fakeIstioConfig) + + dto := &Dto{ + MetaDto: meta.Dto{ + Namespace: "test-namepsace", + }, + } + + // when + err := authRules.Delete(dto) + + // then + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } +} diff --git a/components/api-controller/pkg/controller/authentication/v2/doc.go b/components/api-controller/pkg/controller/authentication/v2/doc.go new file mode 100644 index 000000000000..e5780bd17ca5 --- /dev/null +++ b/components/api-controller/pkg/controller/authentication/v2/doc.go @@ -0,0 +1,4 @@ +/* +Second version of authentication in Kyma. Based on Istio authentication policies. +*/ +package v2 diff --git a/components/api-controller/pkg/controller/authentication/v2/interface.go b/components/api-controller/pkg/controller/authentication/v2/interface.go new file mode 100644 index 000000000000..daa21c051a76 --- /dev/null +++ b/components/api-controller/pkg/controller/authentication/v2/interface.go @@ -0,0 +1,43 @@ +package v2 + +import ( + "fmt" + + kymaMeta "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/meta/v1" + "github.com/kyma-project/kyma/components/api-controller/pkg/controller/meta" +) + +type Interface interface { + Create(dto *Dto) (*kymaMeta.GatewayResource, error) + Update(oldDto, newDto *Dto) (*kymaMeta.GatewayResource, error) + Delete(dto *Dto) error +} + +type Dto struct { + MetaDto meta.Dto + ServiceName string + Rules Rules + Status kymaMeta.GatewayResourceStatus +} + +type Rules []Rule + +type Rule struct { + Type Type + Jwt Jwt +} + +func (r *Rule) String() string { + return fmt.Sprintf("{Type: %s, Jwt: %s}", r.Type, r.Jwt) +} + +type Type string + +const ( + JwtType Type = "JWT" +) + +type Jwt struct { + Issuer string + JwksUri string +} diff --git a/components/api-controller/pkg/controller/commons/errors.go b/components/api-controller/pkg/controller/commons/errors.go new file mode 100644 index 000000000000..5db8df8b7707 --- /dev/null +++ b/components/api-controller/pkg/controller/commons/errors.go @@ -0,0 +1,15 @@ +package commons + +import ( + "fmt" + + "github.com/satori/go.uuid" + log "github.com/sirupsen/logrus" +) + +// Logs root cause and returns new error to hide implementation details +func HandleError(rootCause error, msg string) error { + errId := uuid.NewV4() + log.Errorf("[Error '%s']: %v", errId, rootCause) + return fmt.Errorf("%s (error code = '%s')", msg, errId) +} diff --git a/components/api-controller/pkg/controller/crd/crd.go b/components/api-controller/pkg/controller/crd/crd.go new file mode 100644 index 000000000000..03d4c477d6a4 --- /dev/null +++ b/components/api-controller/pkg/controller/crd/crd.go @@ -0,0 +1,58 @@ +package crd + +import ( + "fmt" + + log "github.com/sirupsen/logrus" + apiExtensionsApi "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + apiExtensions "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1beta1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + k8sMeta "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type Registrar struct { + apiExtensionsInterface apiExtensions.Interface +} + +func NewRegistrar(apiExtensionsInterface apiExtensions.Interface) *Registrar { + return &Registrar{apiExtensionsInterface: apiExtensionsInterface} +} + +func (r *Registrar) Register(crd *apiExtensionsApi.CustomResourceDefinition) { + + crdLabel := fmt.Sprintf("%s/%s", crd.ObjectMeta.Name, crd.Spec.Version) + + log.Infof("Creating CRD '%s'...", crdLabel) + _, err := r.CustomResourceDefinitions().Create(crd) + + if err != nil { + + if apiErrors.IsAlreadyExists(err) { + + log.Infof("CRD '%s' already exists - updating...", crdLabel) + + existingCrd, getErr := r.CustomResourceDefinitions().Get(crd.Name, k8sMeta.GetOptions{}) + if getErr != nil { + log.Errorf("unable to get existing CRD '%s'. Error: %v", crdLabel, getErr) + return + } + + crd.ResourceVersion = existingCrd.ResourceVersion + + _, updateErr := r.CustomResourceDefinitions().Update(crd) + if updateErr != nil { + log.Errorf("unable to update existing CRD '%s'. Error: %v", crdLabel, updateErr) + return + } + } + + log.Errorf("unable to create CRD '%s'. Error: %v", crdLabel, err) + return + } + + log.Infof("CRD '%s' successfully created.", crdLabel) +} +func (r *Registrar) CustomResourceDefinitions() v1beta1.CustomResourceDefinitionInterface { + return r.apiExtensionsInterface.ApiextensionsV1beta1().CustomResourceDefinitions() +} diff --git a/components/api-controller/pkg/controller/ingress/v1/doc.go b/components/api-controller/pkg/controller/ingress/v1/doc.go new file mode 100644 index 000000000000..a6f393ab1d38 --- /dev/null +++ b/components/api-controller/pkg/controller/ingress/v1/doc.go @@ -0,0 +1,4 @@ +/* +First version of ingress support in Kyma. Based on Istio Ingress Controller. +*/ +package v1 diff --git a/components/api-controller/pkg/controller/ingress/v1/ingressv1.go b/components/api-controller/pkg/controller/ingress/v1/ingressv1.go new file mode 100644 index 000000000000..1fa856869009 --- /dev/null +++ b/components/api-controller/pkg/controller/ingress/v1/ingressv1.go @@ -0,0 +1,141 @@ +package v1 + +import ( + "fmt" + "reflect" + + kymaMeta "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/meta/v1" + k8sApiExtensions "k8s.io/api/extensions/v1beta1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + k8sMeta "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/apimachinery/pkg/util/runtime" + k8s "k8s.io/client-go/kubernetes" +) + +type ingress struct { + kubeClient k8s.Interface +} + +// New returns initialized controller for ingresses +func New(kubeClient k8s.Interface) Interface { + return &ingress{ + kubeClient: kubeClient, + } +} + +func (i *ingress) Get(dto *Dto) (k8sApiExtensions.Ingress, error) { + + ingName := ingressNameFrom(dto) + ing, err := i.kubeClient.ExtensionsV1beta1().Ingresses(dto.MetaDto.Namespace).Get(ingName, k8sMeta.GetOptions{}) + + return *ing, err +} + +func (i *ingress) Create(dto *Dto) (*kymaMeta.GatewayResource, error) { + + ing := ingressFrom(dto) + + createdIng, err := i.kubeClient.ExtensionsV1beta1().Ingresses(dto.MetaDto.Namespace).Create(&ing) + if err != nil { + runtime.HandleError(fmt.Errorf("failed to create Ingress '%s' in namespace %s", ing.Name, dto.MetaDto.Namespace)) + return nil, err + } + + return gatewayResourceFrom(createdIng), nil +} + +func (i *ingress) Update(oldDto, newDto *Dto) (*kymaMeta.GatewayResource, error) { + + newIng := ingressFrom(newDto) + oldIng := ingressFrom(oldDto) + + var err error + var updatedIng *k8sApiExtensions.Ingress + + if newIng.Name != oldIng.Name { + + err := i.Delete(oldDto) + if err != nil { + return nil, fmt.Errorf("error while deleting old ingress. Root cause: %v", err) + } + createdIng, err := i.Create(newDto) + if err != nil { + return nil, fmt.Errorf("error while creating new ingress. Root cause: %v", err) + } + return createdIng, nil + + } else if !reflect.DeepEqual(newIng.Spec, oldIng.Spec) { + + updatedIng, err = i.kubeClient.ExtensionsV1beta1().Ingresses(newDto.MetaDto.Namespace).Update(&newIng) + if err != nil { + runtime.HandleError(fmt.Errorf("could not update Ingress '%s' in namespace '%s'. Details : %s", newIng.Name, newIng.Namespace, err.Error())) + return nil, err + } + return gatewayResourceFrom(updatedIng), nil + } + // if there were no changes in ingress + return nil, nil +} + +func (i *ingress) Delete(dto *Dto) error { + + err := i.kubeClient.ExtensionsV1beta1().Ingresses(dto.MetaDto.Namespace).Delete(ingressNameFrom(dto), &k8sMeta.DeleteOptions{}) + + if err != nil && !apiErrors.IsNotFound(err) { + runtime.HandleError(fmt.Errorf("could not delete Ingress '%s' in namespace '%s'. Details : %s", dto.MetaDto.Name, dto.MetaDto.Namespace, err.Error())) + } + return err +} + +func ingressNameFrom(dto *Dto) string { + return dto.ServiceName + "-ing" +} + +func ingressFrom(dto *Dto) k8sApiExtensions.Ingress { + + ing := k8sApiExtensions.Ingress{} + + ing.Labels = dto.MetaDto.Labels + + annotations := make(map[string]string) + annotations["kubernetes.io/ingress.class"] = "istio" + ing.Annotations = annotations + + ing.APIVersion = "v1" + ing.Name = ingressNameFrom(dto) + ing.Spec = k8sApiExtensions.IngressSpec{ + TLS: []k8sApiExtensions.IngressTLS{ + { + SecretName: "istio-ingress-certs", + }, + }, + Rules: []k8sApiExtensions.IngressRule{ + { + Host: dto.Hostname, + IngressRuleValue: k8sApiExtensions.IngressRuleValue{ + HTTP: &k8sApiExtensions.HTTPIngressRuleValue{ + Paths: []k8sApiExtensions.HTTPIngressPath{ + { + Path: "/.*", + Backend: k8sApiExtensions.IngressBackend{ + ServiceName: dto.ServiceName, + ServicePort: intstr.FromInt(dto.ServicePort), + }, + }, + }, + }, + }, + }, + }, + } + return ing +} + +func gatewayResourceFrom(api *k8sApiExtensions.Ingress) *kymaMeta.GatewayResource { + return &kymaMeta.GatewayResource{ + Name: api.Name, + Uid: api.UID, + Version: api.ResourceVersion, + } +} diff --git a/components/api-controller/pkg/controller/ingress/v1/ingressv1_integration_test.go b/components/api-controller/pkg/controller/ingress/v1/ingressv1_integration_test.go new file mode 100644 index 000000000000..cd6c160be981 --- /dev/null +++ b/components/api-controller/pkg/controller/ingress/v1/ingressv1_integration_test.go @@ -0,0 +1,75 @@ +package v1 + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/kyma-project/kyma/components/api-controller/pkg/controller/meta" + k8s "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" +) + +func TestIntegrationCreateUpdateDeleteIngress(t *testing.T) { + + if testing.Short() { + t.Skip("Skipping in short mode.") + } + + ingCtrl, err := defaultConfig() + + if err != nil { + t.Fatal(err.Error()) + } + + metaDto := meta.Dto{ + Namespace: "default", + } + + dto := &Dto{ + MetaDto: metaDto, + ServiceName: "kubernetes", + ServicePort: 443, + Hostname: "test.com", + } + + t.Logf("Creating Ingress %+v", dto) + + _, createErr := ingCtrl.Create(dto) + if createErr != nil { + t.Errorf("Unable to create ingress. Root cause : %v", createErr) + } + + updatedDto := dto + updatedDto.Hostname = "changed.com" + + _, updateErr := ingCtrl.Update(dto, updatedDto) + if updateErr != nil { + t.Errorf("Unable to update ingress. Root cause : %v", updateErr) + } + + deleteIng := func() { + t.Logf("DELETE Ingress") + deleteErr := ingCtrl.Delete(updatedDto) + if deleteErr != nil { + t.Errorf("Unable to delete Ingress. Details : %s", deleteErr.Error()) + } + } + + defer deleteIng() +} + +func defaultConfig() (*ingress, error) { + + kubeConfigLocation := filepath.Join(os.Getenv("HOME"), ".kube", "config") + + kubeConfig, err := clientcmd.BuildConfigFromFlags("", kubeConfigLocation) + if err != nil { + return nil, fmt.Errorf("Unable to load kube config. Root cause: %v", err) + } + + cs := k8s.NewForConfigOrDie(kubeConfig) + + return &ingress{cs}, nil +} diff --git a/components/api-controller/pkg/controller/ingress/v1/ingressv1_test.go b/components/api-controller/pkg/controller/ingress/v1/ingressv1_test.go new file mode 100644 index 000000000000..43d315e02b59 --- /dev/null +++ b/components/api-controller/pkg/controller/ingress/v1/ingressv1_test.go @@ -0,0 +1,140 @@ +package v1 + +import ( + "testing" + + k8sCore "k8s.io/api/core/v1" + k8sMeta "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestCreateIngress(t *testing.T) { + + dto := fakeDto() + cs := fake.NewSimpleClientset() + + ingressCtrl := New(cs) + _, err := ingressCtrl.Create(dto) + + if err != nil { + t.Errorf("Error creating Ingress. Detials : %s", err.Error()) + } +} + +func TestCreateIngForExistingIng(t *testing.T) { + + dto := fakeDto() + ing := ingressFrom(dto) + cs := fake.NewSimpleClientset(&ing) + + ingressCtrl := New(cs) + _, err := ingressCtrl.Create(dto) + + if err == nil { + t.Error("Should not create Ingress because it already exsists but it did!") + } +} + +func TestGetIngress(t *testing.T) { + + dto := fakeDto() + ing := ingressFrom(dto) + cs := fake.NewSimpleClientset(&ing) + + ingressCtrl := New(cs) + _, err := ingressCtrl.Get(dto) + + if err != nil { + t.Errorf("Error to get Ingress. Detials : %s", err.Error()) + } +} + +func TestUpdateIngress(t *testing.T) { + + oldApi := fakeDto() + ing := ingressFrom(oldApi) + + t.Run("service assigned to ingress has changed so ingress will be deleted and created again", func(t *testing.T) { + + svc := fakeService("fake-service", int32(oldApi.ServicePort)) + + newApi := fakeDto() + newApi.ServiceName = "fake-service" + + cs := fake.NewSimpleClientset(&ing, &svc) + + ingressCtrl := New(cs) + updatedResource, err := ingressCtrl.Update(oldApi, newApi) + + if err != nil { + t.Errorf("Error while updating Ingress. Details : %s", err.Error()) + } else if updatedResource == nil { + t.Errorf("Error while updating Ingress. Ingress be udpated (old name: '%s', new name: '%s')", oldApi.ServiceName, newApi.ServiceName) + } else if updatedResource.Name != newApi.ServiceName+"-ing" { + t.Errorf("Error while updating Ingress. Ingress should have name : %s, but is: %s", newApi.ServiceName+"-ing", updatedResource.Name) + } + }) + + t.Run("port of assigned service has changed so ingress resource will be updated", func(t *testing.T) { + svc := fakeService(oldApi.ServiceName, 80) + newApi := oldApi + newApi.ServicePort = 80 + + cs := fake.NewSimpleClientset(&ing, &svc) + + ingressCtrl := New(cs) + _, err := ingressCtrl.Update(oldApi, newApi) + + if err != nil { + t.Errorf("Error while updating Ingress. Details : %s", err.Error()) + } + }) + + t.Run("nothing has changed so ingress shouldn't be updated", func(t *testing.T) { + newApi := oldApi + + cs := fake.NewSimpleClientset(&ing) + + ingressCtrl := New(cs) + updatedResource, err := ingressCtrl.Update(oldApi, newApi) + + if err != nil { + t.Errorf("Error while updating Ingress. Details : %s", err.Error()) + } + if updatedResource != nil { + t.Error("Error while updating Ingress. Should not update ingress because nothing has changed.") + } + }) +} + +func TestDeleteIngress(t *testing.T) { + + dto := fakeDto() + ing := ingressFrom(dto) + cs := fake.NewSimpleClientset(&ing) + + ingressCtrl := New(cs) + err := ingressCtrl.Delete(dto) + + if err != nil { + t.Errorf("Error deleting Ingress. Detials : %s", err.Error()) + } +} + +func fakeDto() *Dto { + return &Dto{ + ServiceName: "kubernetes", + ServicePort: 443, + } +} + +func fakeService(name string, port int32) k8sCore.Service { + return k8sCore.Service{ + ObjectMeta: k8sMeta.ObjectMeta{ + Name: name, + }, + Spec: k8sCore.ServiceSpec{ + Ports: []k8sCore.ServicePort{{Port: port}}, + }, + } +} diff --git a/components/api-controller/pkg/controller/ingress/v1/interface.go b/components/api-controller/pkg/controller/ingress/v1/interface.go new file mode 100644 index 000000000000..0959d493a1fe --- /dev/null +++ b/components/api-controller/pkg/controller/ingress/v1/interface.go @@ -0,0 +1,21 @@ +package v1 + +import ( + kymaMeta "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/meta/v1" + "github.com/kyma-project/kyma/components/api-controller/pkg/controller/meta" + k8sApiExtensions "k8s.io/api/extensions/v1beta1" +) + +type Dto struct { + MetaDto meta.Dto + Hostname string + ServiceName string + ServicePort int +} + +type Interface interface { + Get(dto *Dto) (k8sApiExtensions.Ingress, error) + Create(dto *Dto) (*kymaMeta.GatewayResource, error) + Update(oldDto, newDto *Dto) (*kymaMeta.GatewayResource, error) + Delete(dto *Dto) error +} diff --git a/components/api-controller/pkg/controller/meta/dto.go b/components/api-controller/pkg/controller/meta/dto.go new file mode 100644 index 000000000000..1c8335dbc0ee --- /dev/null +++ b/components/api-controller/pkg/controller/meta/dto.go @@ -0,0 +1,7 @@ +package meta + +type Dto struct { + Name string + Namespace string + Labels map[string]string +} diff --git a/components/api-controller/pkg/controller/service/v1/interface.go b/components/api-controller/pkg/controller/service/v1/interface.go new file mode 100644 index 000000000000..80fc75411d90 --- /dev/null +++ b/components/api-controller/pkg/controller/service/v1/interface.go @@ -0,0 +1,16 @@ +package v1 + +import "k8s.io/apimachinery/pkg/util/intstr" + +type Interface interface { + Validate(name, namespace string, portNameOrNumber intstr.IntOrString) (ValidationResult, error) +} + +type ValidationResult int + +const ( + NotValidated ValidationResult = -1 + OK = iota + FailedWithNotFound + FailedWithInvalidPort +) diff --git a/components/api-controller/pkg/controller/service/v1/servicev1.go b/components/api-controller/pkg/controller/service/v1/servicev1.go new file mode 100644 index 000000000000..0afdbf570a88 --- /dev/null +++ b/components/api-controller/pkg/controller/service/v1/servicev1.go @@ -0,0 +1,95 @@ +package v1 + +import ( + "fmt" + + k8sCore "k8s.io/api/core/v1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + k8sMeta "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + k8s "k8s.io/client-go/kubernetes" +) + +type services struct { + k8sInterface k8s.Interface +} + +func New(k8sInterface k8s.Interface) Interface { + return &services{ + k8sInterface: k8sInterface, + } +} + +func (s *services) Validate(name, namespace string, portNameOrNumber intstr.IntOrString) (ValidationResult, error) { + + service, err := s.get(name, namespace) + if err != nil { + return NotValidated, fmt.Errorf("Error while validating service: '%s'. Root cause:\n%v.", name, err) + } + return validateService(service, portNameOrNumber) +} + +func (s *services) get(name, namespace string) (*k8sCore.Service, error) { + + servicesClient := s.k8sInterface.CoreV1().Services(namespace) + + getOptions := k8sMeta.GetOptions{} + service, err := servicesClient.Get(name, getOptions) + + if err != nil { + + if apiErrors.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("Error while getting service: '%s'. Root cause:\n%v.", name, err) + } + + return service, nil +} + +func validateService(service *k8sCore.Service, portNameOrNumber intstr.IntOrString) (ValidationResult, error) { + + if service == nil { + return FailedWithNotFound, nil + } + + portValidationResult := validatePort(service, portNameOrNumber) + if !portValidationResult { + return FailedWithInvalidPort, nil + } + + return OK, nil +} + +func validatePort(service *k8sCore.Service, portNameOrNumber intstr.IntOrString) bool { + + validatePort := portValidatorFor(portNameOrNumber) + + ports := service.Spec.Ports + for i := 0; i < len(ports); i++ { + + if validatePort(&ports[i]) { + return true + } + } + return false +} + +type PortValidator func(port *k8sCore.ServicePort) bool + +func portValidatorFor(portNameOrNumber intstr.IntOrString) PortValidator { + + switch portNameOrNumber.Type { + case intstr.Int: + portAsInt32 := portNameOrNumber.IntVal + return func(port *k8sCore.ServicePort) bool { + return port.Port == portAsInt32 + } + case intstr.String: + return func(port *k8sCore.ServicePort) bool { + return port.Name == portNameOrNumber.StrVal + } + default: + panic("Unsupported value type.") + } +} diff --git a/components/api-controller/pkg/controller/service/v1/servicev1_integration_test.go b/components/api-controller/pkg/controller/service/v1/servicev1_integration_test.go new file mode 100644 index 000000000000..58e2941d8da7 --- /dev/null +++ b/components/api-controller/pkg/controller/service/v1/servicev1_integration_test.go @@ -0,0 +1,70 @@ +package v1 + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" +) + +func TestIntegrationGet(t *testing.T) { + + if testing.Short() { + t.Skip("Skipping in short mode.") + } + + services, err := servicesFromDefaultConfig() + if err != nil { + t.Error(err) + } + + svc, err2 := services.get("kubernetes", "default") + if err2 != nil { + t.Errorf("Unable to get service. Root cause: %v", err2) + } + + t.Logf("Kubernetes service: %v\n", svc) + + if svc == nil { + t.Errorf("Kubernetes servie not found") + } +} + +func TestIntegrationGet_ShouldReturnNotFound(t *testing.T) { + + if testing.Short() { + t.Skip("Skipping in short mode.") + } + + services, err := servicesFromDefaultConfig() + if err != nil { + t.Error(err) + } + + svc, err2 := services.get("notfoundservice", "default") + if err2 != nil { + t.Errorf("Unable to get service. Root cause: %v", err2) + } + + if svc != nil { + t.Logf("Kubernetes service: %v\n", svc) + t.Errorf("Should not found the service.") + } +} + +func servicesFromDefaultConfig() (*services, error) { + + kubeConfigLocation := filepath.Join(os.Getenv("HOME"), ".kube", "config") + + kubeConfig, err := clientcmd.BuildConfigFromFlags("", kubeConfigLocation) + if err != nil { + return nil, fmt.Errorf("Unable to load kube config. Root cause: %v", err) + } + + clientset := kubernetes.NewForConfigOrDie(kubeConfig) + + return &services{clientset}, nil +} diff --git a/components/api-controller/pkg/controller/service/v1/servicev1_test.go b/components/api-controller/pkg/controller/service/v1/servicev1_test.go new file mode 100644 index 000000000000..3fa49a57ebe9 --- /dev/null +++ b/components/api-controller/pkg/controller/service/v1/servicev1_test.go @@ -0,0 +1,131 @@ +package v1 + +import ( + "testing" + + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +func TestValidate_ShouldReturnNotFound(t *testing.T) { + + validationResult, err := validateService(nil, intstr.FromString("http")) + + if err != nil { + t.Errorf("Error validating service. Root cause:\n%v", err) + } + + if validationResult != FailedWithNotFound { + t.Errorf("Should return: %v but got: %v", FailedWithNotFound, validationResult) + } +} + +func TestValidate_ShouldReturnInvalidPort(t *testing.T) { + + service := v1.Service{ + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{ + {Name: "http"}, + }, + }, + } + + validationResult, err := validateService(&service, intstr.FromInt(80)) + + if err != nil { + t.Errorf("Error validating service. Root cause:\n%v", err) + } + + if validationResult != FailedWithInvalidPort { + t.Errorf("Should return: %v but got: %v", FailedWithInvalidPort, validationResult) + } +} + +func TestValidate_ShouldReturnOK(t *testing.T) { + + service := v1.Service{ + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{ + {Port: 80}, + }, + }, + } + + validationResult, err := validateService(&service, intstr.FromInt(80)) + + if err != nil { + t.Errorf("Error validating service. Root cause:\n%v", err) + } + + if validationResult != OK { + t.Errorf("Should return: %v but got: %v", OK, validationResult) + } +} + +func TestValidatePort_ShouldReturnTrueForPortName(t *testing.T) { + + service := v1.Service{ + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{ + {Name: "http"}, + }, + }, + } + + result := validatePort(&service, intstr.FromString("http")) + + if !result { + t.Errorf("Should return true for service port name") + } +} + +func TestValidatePort_ShouldReturnFalseForPortName(t *testing.T) { + + service := v1.Service{ + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{ + {Name: "http"}, + }, + }, + } + + result := validatePort(&service, intstr.FromString("http-but-invalid")) + + if result { + t.Errorf("Should return false for service port name") + } +} + +func TestValidatePort_ShouldReturnTrueForPortNumber(t *testing.T) { + + service := v1.Service{ + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{ + {Port: 80}, + }, + }, + } + + result := validatePort(&service, intstr.FromInt(80)) + + if !result { + t.Errorf("Should return true for service port name") + } +} + +func TestValidatePort_ShouldReturnFalseForPortNumber(t *testing.T) { + + service := v1.Service{ + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{ + {Port: 80}, + }, + }, + } + + result := validatePort(&service, intstr.FromInt(81)) + + if result { + t.Errorf("Should return false for service port name") + } +} diff --git a/components/api-controller/pkg/controller/v1alpha2/apistatus.go b/components/api-controller/pkg/controller/v1alpha2/apistatus.go new file mode 100644 index 000000000000..e767d8721be6 --- /dev/null +++ b/components/api-controller/pkg/controller/v1alpha2/apistatus.go @@ -0,0 +1,44 @@ +package v1alpha2 + +import ( + kymaMeta "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/meta/v1" + kymaApi "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2" + kyma "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned" + log "github.com/sirupsen/logrus" +) + +type ApiStatusHelper struct { + kymaInterface kyma.Interface + apiCopy *kymaApi.Api + hasChanged bool +} + +func NewApiStatusHelper(kymaInterface kyma.Interface, api *kymaApi.Api) *ApiStatusHelper { + return &ApiStatusHelper{ + kymaInterface: kymaInterface, + apiCopy: api.DeepCopy(), + hasChanged: false, + } +} + +func (su *ApiStatusHelper) SetAuthenticationStatus(authStatus *kymaMeta.GatewayResourceStatus) { + su.apiCopy.Status.AuthenticationStatus = *authStatus + su.hasChanged = true +} + +func (su *ApiStatusHelper) SetIngressStatus(ingressStatus *kymaMeta.GatewayResourceStatus) { + su.apiCopy.Status.IngressStatus = *ingressStatus + su.hasChanged = true +} + +func (su *ApiStatusHelper) Update() { + + if su.hasChanged { + + log.Infof("Saving status for: %+v", su.apiCopy) + + if _, err2 := su.kymaInterface.GatewayV1alpha2().Apis(su.apiCopy.Namespace).Update(su.apiCopy); err2 != nil { + log.Errorf("Error while saving API status. Root cause: %s", err2) + } + } +} diff --git a/components/api-controller/pkg/controller/v1alpha2/controller.go b/components/api-controller/pkg/controller/v1alpha2/controller.go new file mode 100644 index 000000000000..77678c4a9e2a --- /dev/null +++ b/components/api-controller/pkg/controller/v1alpha2/controller.go @@ -0,0 +1,487 @@ +package v1alpha2 + +import ( + "fmt" + + log "github.com/sirupsen/logrus" + + "time" + + kymaMeta "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/meta/v1" + kymaApi "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2" + kyma "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned" + kymaInformers "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions" + kymaListers "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/listers/gateway.kyma.cx/v1alpha2" + + "reflect" + + authentication "github.com/kyma-project/kyma/components/api-controller/pkg/controller/authentication/v2" + ingress "github.com/kyma-project/kyma/components/api-controller/pkg/controller/ingress/v1" + "github.com/kyma-project/kyma/components/api-controller/pkg/controller/meta" + service "github.com/kyma-project/kyma/components/api-controller/pkg/controller/service/v1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/tools/record" + "k8s.io/client-go/util/workqueue" +) + +type Controller struct { + kymaInterface kyma.Interface + apisLister kymaListers.ApiLister + apisSynced cache.InformerSynced + queue workqueue.RateLimitingInterface + recorder record.EventRecorder + ingCtrl ingress.Interface + services service.Interface + authentication authentication.Interface +} + +func NewController( + kymaInterface kyma.Interface, + ingresses ingress.Interface, + services service.Interface, + authentication authentication.Interface, + internalInformerFactory kymaInformers.SharedInformerFactory) *Controller { + + apisInformer := internalInformerFactory.Gateway().V1alpha2().Apis() + + c := &Controller{ + + kymaInterface: kymaInterface, + ingCtrl: ingresses, + services: services, + authentication: authentication, + apisLister: apisInformer.Lister(), + apisSynced: apisInformer.Informer().HasSynced, + queue: workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "apis"), + } + + apisInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + + event := CreateEvent{ + api: obj.(*kymaApi.Api), + } + c.queue.AddRateLimited(event) + }, + UpdateFunc: func(old, new interface{}) { + + oldApiDef := old.(*kymaApi.Api) + newApiDef := new.(*kymaApi.Api) + + if newApiDef.ResourceVersion == oldApiDef.ResourceVersion { + return + } + + event := UpdateEvent{ + newApi: newApiDef, + oldApi: oldApiDef, + } + c.queue.AddRateLimited(event) + }, + DeleteFunc: func(obj interface{}) { + + event := DeleteEvent{ + api: obj.(*kymaApi.Api), + } + c.queue.AddRateLimited(event) + }, + }) + + return c +} + +func (c *Controller) Run(workers int, stopCh <-chan struct{}) { + + log.Info("Starting the main controller...") + + defer func() { + c.queue.ShutDown() + }() + + for i := 0; i < workers; i++ { + // start workers + go wait.Until(c.worker, time.Second, stopCh) + } + + // wait until we receive a stop signal + <-stopCh +} + +func (c *Controller) worker() { + // process until we're told to stop + for c.processNextWorkItem() { + } +} + +type BackendEvent interface { + String() string +} + +type CreateEvent struct { + api *kymaApi.Api +} + +func (e CreateEvent) String() string { + return fmt.Sprintf("CreateEvent{api=%+v}", e.api) +} + +type UpdateEvent struct { + oldApi *kymaApi.Api + newApi *kymaApi.Api +} + +func (e UpdateEvent) String() string { + return fmt.Sprintf("UpdateEvent{oldApi=%+v, newApi=%+v}", e.oldApi, e.newApi) +} + +type DeleteEvent struct { + api *kymaApi.Api +} + +func (e DeleteEvent) String() string { + return fmt.Sprintf("DeleteEvent{api=%+v}", e.api) +} + +func (c *Controller) processNextWorkItem() bool { + + log.Info("Trying to process next item...") + + event, quit := c.queue.Get() + if quit { + return false + } + + log.Infof("Got event %+v", event) + + defer c.queue.Done(event) + + err := c.syncHandler(event.(BackendEvent)) + c.handleErr(err, event) + return true +} + +func (c *Controller) syncHandler(event BackendEvent) error { + + switch event.(type) { + case CreateEvent: + createEvent := event.(CreateEvent) + return c.onCreate(createEvent.api) + case UpdateEvent: + updateEvent := event.(UpdateEvent) + return c.onUpdate(updateEvent.oldApi, updateEvent.newApi) + case DeleteEvent: + deleteEvent := event.(DeleteEvent) + return c.onDelete(deleteEvent.api) + } + + return nil +} + +func (c *Controller) onCreate(apiObj *kymaApi.Api) error { + + log.Infof("CREATING: %+v", apiObj) + + namespace := apiObj.Namespace + name := apiObj.Name + + api, err := c.apisLister.Apis(namespace).Get(name) + + if err != nil { + + if apiErrors.IsNotFound(err) { + runtime.HandleError(fmt.Errorf("API '%+v' in work queue no longer exists", api)) + return nil + } + return err + } + + apiStatusHelper := c.apiStatusHelperFor(api) + if api.Status.IsEmpty() { + api.Status.SetInProgress() + } + defer apiStatusHelper.Update() + + metaDto := toMetaDto(api) + + createIngressStatus := c.createIngress(metaDto, api, apiStatusHelper) + createAuthenticationStatus := c.createAuthentication(metaDto, api, apiStatusHelper) + + if createIngressStatus.IsError() || createAuthenticationStatus.IsError() { + return fmt.Errorf("error while processing create: %s/%s ver: %s", api.Namespace, api.Name, api.ResourceVersion) + } + return nil +} + +func (c *Controller) createIngress(metaDto meta.Dto, api *kymaApi.Api, apiStatusHelper *ApiStatusHelper) kymaMeta.StatusCode { + + ingressCreatorAdapter := func(api *kymaApi.Api) (*kymaMeta.GatewayResource, error) { + return c.ingCtrl.Create(toIngressDto(metaDto, api)) + } + + return c.tmplCreateResource(api, &api.Status.IngressStatus, "ingress", ingressCreatorAdapter, + func(status *kymaMeta.GatewayResourceStatus) { + apiStatusHelper.SetIngressStatus(status) + }) +} + +func (c *Controller) createAuthentication(metaDto meta.Dto, api *kymaApi.Api, apiStatusHelper *ApiStatusHelper) kymaMeta.StatusCode { + + authenticationCreatorAdapter := func(api *kymaApi.Api) (*kymaMeta.GatewayResource, error) { + return c.authentication.Create(toAuthenticationDto(metaDto, api)) + } + + return c.tmplCreateResource(api, &api.Status.AuthenticationStatus, "Authentication", authenticationCreatorAdapter, + func(status *kymaMeta.GatewayResourceStatus) { + apiStatusHelper.SetAuthenticationStatus(status) + }) +} + +func (c *Controller) tmplCreateResource( + api *kymaApi.Api, + resourceStatus *kymaMeta.GatewayResourceStatus, + resourceName string, + resourceCreator func(api *kymaApi.Api) (*kymaMeta.GatewayResource, error), + statusSetter func(status *kymaMeta.GatewayResourceStatus)) kymaMeta.StatusCode { + + if resourceStatus.IsDone() { + log.Debugf("%s has been already created for: %s/%s ver: %s", resourceName, api.Namespace, api.Name, api.ResourceVersion) + return kymaMeta.Done + } + + log.Debugf("Creating %s for: %s/%s ver: %s", resourceName, api.Namespace, api.Name, api.ResourceVersion) + + // Error occurred when creating ingress - save error in API CR status + createdResource, createErr := resourceCreator(api) + + if createErr != nil { + + log.Errorf("Error while creating %s for: %s/%s ver: %s. Root cause: %s", resourceName, api.Namespace, api.Name, api.ResourceVersion, createErr) + + statusSetter(&kymaMeta.GatewayResourceStatus{ + Code: kymaMeta.Error, + LastError: createErr.Error(), + }) + + return kymaMeta.Error + } + + log.Infof("%s created for: %s/%s ver: %s", resourceName, api.Namespace, api.Name, api.ResourceVersion) + + status := &kymaMeta.GatewayResourceStatus{ + Code: kymaMeta.Done, + } + if createdResource != nil { + status.Resource = *createdResource + } + + // if there was no error: create new resource status without an error + statusSetter(status) + + return kymaMeta.Done +} + +func (c *Controller) onUpdate(oldApi, newApi *kymaApi.Api) error { + + log.Infof("UPDATING: OLD: %+v; NEW: %+v", oldApi, newApi) + + // if update is done (so it is not in progress; it is not a retry) + if newApi.ResourceVersion == oldApi.ResourceVersion || reflect.DeepEqual(newApi.Spec, oldApi.Spec) { + log.Info("SKIPPED: all changes has been already applied to the API (both specs are equal).") + return nil + } + + apiStatusHelper := c.apiStatusHelperFor(newApi) + if newApi.Status.IsDone() { + newApi.Status.SetInProgress() + } + defer apiStatusHelper.Update() + + oldMetaDto := toMetaDto(oldApi) + newMetaDto := toMetaDto(newApi) + + updateIngressStatus := c.updateIngress(oldApi, oldMetaDto, newApi, newMetaDto, apiStatusHelper) + updateAuthenticationStatus := c.updateAuthentication(oldApi, oldMetaDto, newApi, newMetaDto, apiStatusHelper) + + if updateIngressStatus.IsError() || updateAuthenticationStatus.IsError() { + return fmt.Errorf("error while processing update: %s/%s ver: %s", newApi.Namespace, newApi.Name, newApi.ResourceVersion) + } + return nil +} + +func (c *Controller) updateIngress(oldApi *kymaApi.Api, oldMetaDto meta.Dto, newApi *kymaApi.Api, newMetaDto meta.Dto, apiStatusHelper *ApiStatusHelper) kymaMeta.StatusCode { + + updaterAdapter := func(oldApi, newApi *kymaApi.Api) (*kymaMeta.GatewayResource, error) { + oldDto := toIngressDto(oldMetaDto, oldApi) + newDto := toIngressDto(newMetaDto, newApi) + return c.ingCtrl.Update(oldDto, newDto) + } + + return c.tmplUpdateResource(oldApi, newApi, &newApi.Status.IngressStatus, "Ingress", updaterAdapter, + func(status *kymaMeta.GatewayResourceStatus) { + apiStatusHelper.SetIngressStatus(status) + }) +} + +func (c *Controller) updateAuthentication(oldApi *kymaApi.Api, oldMetaDto meta.Dto, newApi *kymaApi.Api, newMetaDto meta.Dto, apiStatusHelper *ApiStatusHelper) kymaMeta.StatusCode { + + updaterAdapter := func(oldApi, newApi *kymaApi.Api) (*kymaMeta.GatewayResource, error) { + oldDto := toAuthenticationDto(oldMetaDto, oldApi) + newDto := toAuthenticationDto(newMetaDto, newApi) + return c.authentication.Update(oldDto, newDto) + } + + return c.tmplUpdateResource(oldApi, newApi, &newApi.Status.AuthenticationStatus, "Authentication", updaterAdapter, + func(status *kymaMeta.GatewayResourceStatus) { + apiStatusHelper.SetAuthenticationStatus(status) + }) +} + +func (c *Controller) tmplUpdateResource(oldApi *kymaApi.Api, newApi *kymaApi.Api, + resourceStatus *kymaMeta.GatewayResourceStatus, + resourceName string, + resourceUpdater func(oldApi, newApi *kymaApi.Api) (*kymaMeta.GatewayResource, error), + statusSetter func(status *kymaMeta.GatewayResourceStatus)) kymaMeta.StatusCode { + + if resourceStatus.IsDone() { + log.Debugf("%s has been already updated for: %s/%s ver: %s", resourceName, newApi.Namespace, newApi.Name, newApi.ResourceVersion) + return kymaMeta.Done + } + + log.Debugf("Updating %s for: %s/%s ver: %s", resourceName, newApi.Namespace, newApi.Name, newApi.ResourceVersion) + + updatedResource, updateErr := resourceUpdater(oldApi, newApi) + + if updateErr != nil { + + log.Errorf("Error while updating %s for: %s/%s ver: %s. Root cause: %s", resourceName, newApi.Namespace, newApi.Name, newApi.ResourceVersion, updateErr) + + // if there was the error: update previous status with the error + statusSetter(&kymaMeta.GatewayResourceStatus{ + Code: kymaMeta.Error, + LastError: updateErr.Error(), + }) + return kymaMeta.Error + } + + log.Infof("%s updated for: %s/%s ver: %s", resourceName, newApi.Namespace, newApi.Name, newApi.ResourceVersion) + + status := &kymaMeta.GatewayResourceStatus{ + Code: kymaMeta.Done, + } + if updatedResource != nil { + status.Resource = *updatedResource + } + statusSetter(status) + return kymaMeta.Done +} + +func (c *Controller) onDelete(api *kymaApi.Api) error { + + log.Infof("DELETING: %+v", api) + + deleteResourceFailed := false + + metaDto := toMetaDto(api) + + log.Debugf("Deleting authentication for: %s/%s ver: %s", api.Namespace, api.Name, api.ResourceVersion) + if err := c.authentication.Delete(toAuthenticationDto(metaDto, api)); err != nil { + deleteResourceFailed = true + log.Errorf("Error while deleting authentication for: %s/%s ver: %s. Root cause: %s", api.Namespace, api.Name, api.ResourceVersion, err) + } + + log.Debugf("Deleting ingress for: %s/%s ver: %s", api.Namespace, api.Name, api.ResourceVersion) + if err := c.ingCtrl.Delete(toIngressDto(metaDto, api)); err != nil { + deleteResourceFailed = true + log.Errorf("Error while deleting ingress for: %s/%s ver: %s. Root cause: %s", api.Namespace, api.Name, api.ResourceVersion, err) + } + + if deleteResourceFailed { + return fmt.Errorf("error while processing delete: %s/%s ver: %s", api.Namespace, api.Name, api.ResourceVersion) + } + return nil +} + +func (c *Controller) handleErr(err error, event interface{}) { + + if err == nil { + c.queue.Forget(event) + return + } + + if c.queue.NumRequeues(event) < 5 { + + // Re-enqueue the key rate limited. Based on the rate limiter on the + // queue and the re-enqueue history, the key will be processed later again. + c.queue.AddRateLimited(event) + return + } + + c.queue.Forget(event) + runtime.HandleError(err) +} + +func (c *Controller) apiStatusHelperFor(api *kymaApi.Api) *ApiStatusHelper { + return NewApiStatusHelper(c.kymaInterface, api) +} + +func toIngressDto(metaDto meta.Dto, api *kymaApi.Api) *ingress.Dto { + return &ingress.Dto{ + MetaDto: metaDto, + Hostname: api.Spec.Hostname, + ServiceName: api.Spec.Service.Name, + ServicePort: api.Spec.Service.Port, + } +} + +func toAuthenticationDto(metaDto meta.Dto, api *kymaApi.Api) *authentication.Dto { + + if len(api.Spec.Authentication) == 0 { + return nil + } + + dto := &authentication.Dto{ + MetaDto: metaDto, + ServiceName: api.Spec.Service.Name, + Status: api.Status.AuthenticationStatus, + } + + dtoRules := make(authentication.Rules, len(api.Spec.Authentication)) + for _, authRule := range api.Spec.Authentication { + + if authRule.Type == kymaApi.JwtType { + + dtoRule := authentication.Rule{ + Type: authentication.JwtType, + Jwt: authentication.Jwt{ + Issuer: authRule.Jwt.Issuer, + JwksUri: authRule.Jwt.JwksUri, + }, + } + dtoRules = append(dtoRules, dtoRule) + } + } + dto.Rules = dtoRules + + return dto +} + +func toMetaDto(api *kymaApi.Api) meta.Dto { + return meta.Dto{ + Namespace: api.Namespace, + Name: api.Name, + Labels: stdLabelsFor(api), + } +} + +func stdLabelsFor(api *kymaApi.Api) map[string]string { + labels := make(map[string]string) + labels["createdBy"] = "api-controller" + labels["apiUid"] = string(api.UID) + labels["apiNamespace"] = api.Namespace + labels["apiName"] = api.Name + labels["apiVersion"] = api.ObjectMeta.ResourceVersion + return labels +} diff --git a/components/api-controller/pkg/controller/v1alpha2/crd.go b/components/api-controller/pkg/controller/v1alpha2/crd.go new file mode 100644 index 000000000000..18a8b07b5121 --- /dev/null +++ b/components/api-controller/pkg/controller/v1alpha2/crd.go @@ -0,0 +1,108 @@ +package v1alpha2 + +import ( + "fmt" + "strings" + + kymaApi "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2" + k8sApiExtensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + k8sMeta "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func Crd() *k8sApiExtensions.CustomResourceDefinition { + + kind := kymaApi.KindName + listKind := kymaApi.ListKindName + singular := strings.ToLower(kymaApi.KindName) + plural := singular + "s" + group := kymaApi.Group + version := kymaApi.Version + + return &k8sApiExtensions.CustomResourceDefinition{ + ObjectMeta: k8sMeta.ObjectMeta{ + Name: fmt.Sprintf("%s.%s", plural, group), + }, + Spec: k8sApiExtensions.CustomResourceDefinitionSpec{ + Group: group, + Version: version, + Scope: "Namespaced", + Names: k8sApiExtensions.CustomResourceDefinitionNames{ + Singular: singular, + Plural: plural, + Kind: kind, + ListKind: listKind, + }, + Validation: &k8sApiExtensions.CustomResourceValidation{ + OpenAPIV3Schema: &k8sApiExtensions.JSONSchemaProps{ + Properties: map[string]k8sApiExtensions.JSONSchemaProps{ + "spec": { + Required: []string{"service", "hostname"}, + Properties: map[string]k8sApiExtensions.JSONSchemaProps{ + "service": { + Type: "object", + Required: []string{"name", "port"}, + Properties: map[string]k8sApiExtensions.JSONSchemaProps{ + "name": { + Type: "string", + }, + "port": { + Type: "integer", + }, + }, + }, + "hostname": { + Type: "string", + Pattern: hostnamePattern, + MinLength: itoi64(3), + MaxLength: itoi64(256), + }, + "authentication": { + Type: "array", + Items: &k8sApiExtensions.JSONSchemaPropsOrArray{ + Schema: &k8sApiExtensions.JSONSchemaProps{ + Type: "object", + Required: []string{"type"}, + OneOf: []k8sApiExtensions.JSONSchemaProps{ + {Required: []string{"jwt"}}, + }, + Properties: map[string]k8sApiExtensions.JSONSchemaProps{ + "type": { + Type: "string", + }, + "jwt": { + Type: "object", + Required: []string{"issuer", "jwksUri"}, + Properties: map[string]k8sApiExtensions.JSONSchemaProps{ + "issuer": { + Type: "string", + Pattern: urlPattern, + }, + "jwksUri": { + Type: "string", + Pattern: urlPattern, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +const ( + hostnamePattern = `^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$` + urlPattern = `^(?:https?:\/\/)?(?:[^@\/\n]+@)?(?:www\.)?([^:\/\n]+)` +) + +func itoi64(i int) *int64 { + + i64 := int64(i) + return &i64 +} diff --git a/components/api-controller/pkg/controller/v1alpha2/crd_integration_test.go b/components/api-controller/pkg/controller/v1alpha2/crd_integration_test.go new file mode 100644 index 000000000000..228f63489f7e --- /dev/null +++ b/components/api-controller/pkg/controller/v1alpha2/crd_integration_test.go @@ -0,0 +1,45 @@ +package v1alpha2 + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/kyma-project/kyma/components/api-controller/pkg/controller/crd" + apiExtensionsClient "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + "k8s.io/client-go/tools/clientcmd" +) + +func TestCrd_ShouldCreateCrd(t *testing.T) { + + if testing.Short() { + t.Skip("Skipping in short mode.") + return + } + + registrar, err := registrarFromDefaultConfig() + if err != nil { + t.Errorf("Error: %+v", err) + return + } + + registrar.Register(Crd()) +} + +func registrarFromDefaultConfig() (*crd.Registrar, error) { + + kubeConfigLocation := filepath.Join(os.Getenv("HOME"), ".kube", "config") + + kubeConfig, err := clientcmd.BuildConfigFromFlags("", kubeConfigLocation) + if err != nil { + return nil, fmt.Errorf("unable to load kube config. Root cause: %v", err) + } + + apiExtensionsClientSet, err2 := apiExtensionsClient.NewForConfig(kubeConfig) + if err2 != nil { + return nil, fmt.Errorf("unable to create API extensions client. Root cause: %v", err2) + } + + return crd.NewRegistrar(apiExtensionsClientSet), nil +} diff --git a/components/application-connector/.gitignore b/components/application-connector/.gitignore new file mode 100644 index 000000000000..d7477726b6bb --- /dev/null +++ b/components/application-connector/.gitignore @@ -0,0 +1,8 @@ +/.idea +*.iml + +/metadata + +*.log + +vendor diff --git a/components/application-connector/Dockerfile b/components/application-connector/Dockerfile new file mode 100644 index 000000000000..68cdd5115f42 --- /dev/null +++ b/components/application-connector/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.9-alpine as builder + +ARG DOCK_PKG_DIR=/go/src/github.com/kyma-project/kyma/components/application-connector + +RUN mkdir -p $DOCK_PKG_DIR + +COPY ./ $DOCK_PKG_DIR +WORKDIR $DOCK_PKG_DIR + +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o metadata ./cmd/metadata + +FROM alpine +LABEL source=git@github.com:kyma-project/kyma.git + +RUN apk update && apk add curl ngrep + +COPY --from=builder /go/src/github.com/kyma-project/kyma/components/application-connector/metadata . + +CMD ["/metadata"] diff --git a/components/application-connector/Gopkg.lock b/components/application-connector/Gopkg.lock new file mode 100644 index 000000000000..d69f3710c16b --- /dev/null +++ b/components/application-connector/Gopkg.lock @@ -0,0 +1,452 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/asaskevich/govalidator" + packages = ["."] + revision = "ccb8e960c48f04d6935e72476ae4a51028f9e22f" + version = "v9" + +[[projects]] + name = "github.com/davecgh/go-spew" + packages = ["spew"] + revision = "346938d642f2ec3594ed81d874461961cd0faa76" + version = "v1.1.0" + +[[projects]] + branch = "master" + name = "github.com/dustin/go-humanize" + packages = ["."] + revision = "02af3965c54e8cacf948b97fef38925c4120652c" + +[[projects]] + name = "github.com/ghodss/yaml" + packages = ["."] + revision = "0ca9ea5df5451ffdf184b4428c902747c2c11cd7" + version = "v1.0.0" + +[[projects]] + name = "github.com/go-ini/ini" + packages = ["."] + revision = "06f5f3d67269ccec1fe5fe4134ba6e982984f7f5" + version = "v1.37.0" + +[[projects]] + name = "github.com/gogo/protobuf" + packages = [ + "proto", + "sortkeys" + ] + revision = "1adfc126b41513cc696b209667c8656ea7aac67c" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "github.com/golang/glog" + packages = ["."] + revision = "23def4e6c14b4da8ac2ed8007337bc5eb5007998" + +[[projects]] + name = "github.com/golang/protobuf" + packages = [ + "proto", + "ptypes", + "ptypes/any", + "ptypes/duration", + "ptypes/timestamp" + ] + revision = "b4deda0973fb4c70b50d226b1af49f3da59f5265" + version = "v1.1.0" + +[[projects]] + branch = "master" + name = "github.com/google/gofuzz" + packages = ["."] + revision = "24818f796faf91cd76ec7bddd72458fbced7a6c1" + +[[projects]] + name = "github.com/googleapis/gnostic" + packages = [ + "OpenAPIv2", + "compiler", + "extensions" + ] + revision = "7c663266750e7d82587642f65e60bc4083f1f84e" + version = "v0.2.0" + +[[projects]] + name = "github.com/gorilla/context" + packages = ["."] + revision = "08b5f424b9271eedf6f9f0ce86cb9396ed337a42" + version = "v1.1.1" + +[[projects]] + name = "github.com/gorilla/mux" + packages = ["."] + revision = "e3702bed27f0d39777b0b37b664b6280e8ef8fbf" + version = "v1.6.2" + +[[projects]] + branch = "master" + name = "github.com/hashicorp/golang-lru" + packages = [ + ".", + "simplelru" + ] + revision = "0fb14efe8c47ae851c0034ed7a448854d3d34cf3" + +[[projects]] + name = "github.com/json-iterator/go" + packages = ["."] + revision = "ab8a2e0c74be9d3be70b3184d9acc634935ded82" + version = "1.1.4" + +[[projects]] + branch = "master" + name = "github.com/kyma-project/kyma" + packages = [ + "components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1", + "components/remote-environment-broker/pkg/client/clientset/versioned", + "components/remote-environment-broker/pkg/client/clientset/versioned/scheme", + "components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1" + ] + revision = "98ffd39a35793983f4d291b743e192daa0701d3b" + +[[projects]] + name = "github.com/minio/minio-go" + packages = [ + ".", + "pkg/credentials", + "pkg/encrypt", + "pkg/policy", + "pkg/s3signer", + "pkg/s3utils", + "pkg/set" + ] + revision = "66252c2a3c15f7b90cc8493d497a04ac3b6e3606" + version = "5.0.0" + +[[projects]] + branch = "master" + name = "github.com/mitchellh/go-homedir" + packages = ["."] + revision = "3864e76763d94a6df2f9960b16a20a33da9f9a66" + +[[projects]] + name = "github.com/modern-go/concurrent" + packages = ["."] + revision = "bacd9c7ef1dd9b15be4a9909b8ac7a4e313eec94" + version = "1.0.3" + +[[projects]] + name = "github.com/modern-go/reflect2" + packages = ["."] + revision = "4b7aa43c6742a2c18fdef89dd197aaae7dac7ccd" + version = "1.0.1" + +[[projects]] + name = "github.com/pmezard/go-difflib" + packages = ["difflib"] + revision = "792786c7400a136282c1664665ae0a8db921c6c2" + version = "v1.0.0" + +[[projects]] + name = "github.com/satori/go.uuid" + packages = ["."] + revision = "f58768cc1a7a7e77a3bd49e98cdd21419399b6a3" + version = "v1.2.0" + +[[projects]] + name = "github.com/sirupsen/logrus" + packages = ["."] + revision = "c155da19408a8799da419ed3eeb0cb5db0ad5dbc" + version = "v1.0.5" + +[[projects]] + name = "github.com/spf13/pflag" + packages = ["."] + revision = "583c0c0531f06d5278b7d917446061adc344b5cd" + version = "v1.0.1" + +[[projects]] + name = "github.com/stretchr/objx" + packages = ["."] + revision = "477a77ecc69700c7cdeb1fa9e129548e1c1c393c" + version = "v0.1.1" + +[[projects]] + name = "github.com/stretchr/testify" + packages = [ + "assert", + "mock", + "require" + ] + revision = "f35b8ab0b5a2cef36673838d662e249dd9c94686" + version = "v1.2.2" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = [ + "argon2", + "blake2b", + "ssh/terminal" + ] + revision = "a49355c7e3f8fe157a85be2f77e6e269a0f89602" + +[[projects]] + branch = "release-branch.go1.10" + name = "golang.org/x/net" + packages = [ + "context", + "http2", + "http2/hpack", + "idna", + "lex/httplex" + ] + revision = "0ed95abb35c445290478a5348a7b38bb154135fd" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = [ + "cpu", + "unix", + "windows" + ] + revision = "1b2967e3c290b7c545b3db0deeda16e9be4f98a2" + +[[projects]] + name = "golang.org/x/text" + packages = [ + "collate", + "collate/build", + "internal/colltab", + "internal/gen", + "internal/tag", + "internal/triegen", + "internal/ucd", + "language", + "secure/bidirule", + "transform", + "unicode/bidi", + "unicode/cldr", + "unicode/norm", + "unicode/rangetable" + ] + revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" + version = "v0.3.0" + +[[projects]] + branch = "master" + name = "golang.org/x/time" + packages = ["rate"] + revision = "fbb02b2291d28baffd63558aa44b4b56f178d650" + +[[projects]] + branch = "master" + name = "golang.org/x/tools" + packages = [ + "go/ast/astutil", + "imports", + "internal/fastwalk" + ] + revision = "e2be0f7276f6ea2d8290dea1ffd89c98f6ac0b9e" + +[[projects]] + name = "gopkg.in/inf.v0" + packages = ["."] + revision = "d2d2541c53f18d2a059457998ce2876cc8e67cbf" + version = "v0.9.1" + +[[projects]] + name = "gopkg.in/yaml.v2" + packages = ["."] + revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183" + version = "v2.2.1" + +[[projects]] + name = "k8s.io/api" + packages = [ + "admissionregistration/v1alpha1", + "admissionregistration/v1beta1", + "apps/v1", + "apps/v1beta1", + "apps/v1beta2", + "authentication/v1", + "authentication/v1beta1", + "authorization/v1", + "authorization/v1beta1", + "autoscaling/v1", + "autoscaling/v2beta1", + "batch/v1", + "batch/v1beta1", + "batch/v2alpha1", + "certificates/v1beta1", + "core/v1", + "events/v1beta1", + "extensions/v1beta1", + "networking/v1", + "policy/v1beta1", + "rbac/v1", + "rbac/v1alpha1", + "rbac/v1beta1", + "scheduling/v1alpha1", + "settings/v1alpha1", + "storage/v1", + "storage/v1alpha1", + "storage/v1beta1" + ] + revision = "73d903622b7391f3312dcbac6483fed484e185f8" + version = "kubernetes-1.10.0" + +[[projects]] + name = "k8s.io/apimachinery" + packages = [ + "pkg/api/errors", + "pkg/api/meta", + "pkg/api/resource", + "pkg/apimachinery", + "pkg/apimachinery/registered", + "pkg/apis/meta/internalversion", + "pkg/apis/meta/v1", + "pkg/apis/meta/v1/unstructured", + "pkg/apis/meta/v1beta1", + "pkg/conversion", + "pkg/conversion/queryparams", + "pkg/fields", + "pkg/labels", + "pkg/runtime", + "pkg/runtime/schema", + "pkg/runtime/serializer", + "pkg/runtime/serializer/json", + "pkg/runtime/serializer/protobuf", + "pkg/runtime/serializer/recognizer", + "pkg/runtime/serializer/streaming", + "pkg/runtime/serializer/versioning", + "pkg/selection", + "pkg/types", + "pkg/util/cache", + "pkg/util/clock", + "pkg/util/diff", + "pkg/util/errors", + "pkg/util/framer", + "pkg/util/intstr", + "pkg/util/json", + "pkg/util/net", + "pkg/util/runtime", + "pkg/util/sets", + "pkg/util/validation", + "pkg/util/validation/field", + "pkg/util/wait", + "pkg/util/yaml", + "pkg/version", + "pkg/watch", + "third_party/forked/golang/reflect" + ] + revision = "302974c03f7e50f16561ba237db776ab93594ef6" + version = "kubernetes-1.10.0" + +[[projects]] + name = "k8s.io/client-go" + packages = [ + "discovery", + "discovery/fake", + "kubernetes", + "kubernetes/scheme", + "kubernetes/typed/admissionregistration/v1alpha1", + "kubernetes/typed/admissionregistration/v1beta1", + "kubernetes/typed/apps/v1", + "kubernetes/typed/apps/v1beta1", + "kubernetes/typed/apps/v1beta2", + "kubernetes/typed/authentication/v1", + "kubernetes/typed/authentication/v1beta1", + "kubernetes/typed/authorization/v1", + "kubernetes/typed/authorization/v1beta1", + "kubernetes/typed/autoscaling/v1", + "kubernetes/typed/autoscaling/v2beta1", + "kubernetes/typed/batch/v1", + "kubernetes/typed/batch/v1beta1", + "kubernetes/typed/batch/v2alpha1", + "kubernetes/typed/certificates/v1beta1", + "kubernetes/typed/core/v1", + "kubernetes/typed/events/v1beta1", + "kubernetes/typed/extensions/v1beta1", + "kubernetes/typed/networking/v1", + "kubernetes/typed/policy/v1beta1", + "kubernetes/typed/rbac/v1", + "kubernetes/typed/rbac/v1alpha1", + "kubernetes/typed/rbac/v1beta1", + "kubernetes/typed/scheduling/v1alpha1", + "kubernetes/typed/settings/v1alpha1", + "kubernetes/typed/storage/v1", + "kubernetes/typed/storage/v1alpha1", + "kubernetes/typed/storage/v1beta1", + "pkg/apis/clientauthentication", + "pkg/apis/clientauthentication/v1alpha1", + "pkg/version", + "plugin/pkg/client/auth/exec", + "rest", + "rest/watch", + "testing", + "tools/cache", + "tools/clientcmd/api", + "tools/metrics", + "tools/pager", + "tools/reference", + "transport", + "util/buffer", + "util/cert", + "util/flowcontrol", + "util/integer", + "util/retry" + ] + revision = "989be4278f353e42f26c416c53757d16fcff77db" + version = "kubernetes-1.10.1" + +[[projects]] + branch = "release-1.10" + name = "k8s.io/code-generator" + packages = [ + "cmd/client-gen", + "cmd/client-gen/args", + "cmd/client-gen/generators", + "cmd/client-gen/generators/fake", + "cmd/client-gen/generators/scheme", + "cmd/client-gen/generators/util", + "cmd/client-gen/path", + "cmd/client-gen/types", + "cmd/deepcopy-gen", + "cmd/deepcopy-gen/args", + "cmd/defaulter-gen", + "cmd/defaulter-gen/args", + "cmd/informer-gen", + "cmd/informer-gen/args", + "cmd/informer-gen/generators", + "cmd/lister-gen", + "cmd/lister-gen/args", + "cmd/lister-gen/generators", + "pkg/util" + ] + revision = "9de8e796a74d16d2a285165727d04c185ebca6dc" + +[[projects]] + name = "k8s.io/gengo" + packages = [ + "args", + "examples/deepcopy-gen/generators", + "examples/defaulter-gen/generators", + "examples/set-gen/sets", + "generator", + "namer", + "parser", + "types" + ] + revision = "01a732e01d00cb9a81bb0ca050d3e6d2b947927b" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "aaf725a6da61303bc63d2e62b6f67816e8d2b75e3413e5eb479527917f6a26f9" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/components/application-connector/Gopkg.toml b/components/application-connector/Gopkg.toml new file mode 100644 index 000000000000..d7f81ee34a34 --- /dev/null +++ b/components/application-connector/Gopkg.toml @@ -0,0 +1,62 @@ +required = [ + "k8s.io/code-generator/cmd/defaulter-gen", + "k8s.io/code-generator/cmd/deepcopy-gen", + "k8s.io/code-generator/cmd/client-gen", + "k8s.io/code-generator/cmd/lister-gen", + "k8s.io/code-generator/cmd/informer-gen", + "k8s.io/apimachinery/pkg/apimachinery/registered", +] + +[prune] + non-go = true + go-tests = true + unused-packages = true + +[[constraint]] + name = "github.com/sirupsen/logrus" + version = "1.0.5" + +[[constraint]] + name = "github.com/gorilla/mux" + version = "1.6.1" + +[[constraint]] + name = "k8s.io/client-go" + version = "kubernetes-1.10.1" + +[[constraint]] + name = "github.com/satori/go.uuid" + version = "1.2.0" + +[[constraint]] + name = "k8s.io/code-generator" + branch = "release-1.10" + +[[override]] + name = "k8s.io/api" + version = "kubernetes-1.10.1" + +[[override]] + name = "k8s.io/apimachinery" + version = "kubernetes-1.10.1" + +[[override]] + name = "k8s.io/gengo" + revision = "01a732e01d00cb9a81bb0ca050d3e6d2b947927b" + +[[override]] + name = "k8s.io/kubernetes" + branch = "release-1.10" + + +[[constraint]] + name = "github.com/minio/minio-go" + version = "5.0.0" + +[[override]] + name = "github.com/docker/distribution" + revision = "edc3ab29cdff8694dd6feb85cfeb4b5f1b38ed9c" + +[[constraint]] + name = "github.com/kyma-project/kyma" + branch = "master" diff --git a/components/application-connector/Jenkinsfile b/components/application-connector/Jenkinsfile new file mode 100755 index 000000000000..164f5e535198 --- /dev/null +++ b/components/application-connector/Jenkinsfile @@ -0,0 +1,100 @@ +#!/usr/bin/env groovy +def label = "kyma-${UUID.randomUUID().toString()}" +def application = 'application-connector' +def isMaster = params.GIT_BRANCH == 'master' + +def dockerPushRoot = isMaster + ? "${env.DOCKER_REGISTRY}" + : "${env.DOCKER_REGISTRY}snapshot/" + +def dockerImageTag = isMaster + ? params.APP_VERSION + : params.GIT_BRANCH + +echo """ +******************************** +Job started with the following parameters: +DOCKER_REGISTRY=${env.DOCKER_REGISTRY} +DOCKER_CREDENTIALS=${env.DOCKER_CREDENTIALS} +GIT_REVISION=${params.GIT_REVISION} +GIT_BRANCH=${params.GIT_BRANCH} +APP_VERSION=${params.APP_VERSION} +APP_FOLDER=${env.APP_FOLDER} +******************************** +""" + +podTemplate(label: label) { + node(label) { + try { + timestamps { + timeout(time:20, unit:"MINUTES") { + ansiColor('xterm') { + stage("setup") { + checkout scm + + if(dockerImageTag == ""){ + error("No version for docker tag defined, please set APP_VERSION parameter for master branch or GIT_BRANCH parameter for any branch") + } + + withCredentials([usernamePassword(credentialsId: env.DOCKER_CREDENTIALS, passwordVariable: 'pwd', usernameVariable: 'uname')]) { + sh "docker login -u $uname -p '$pwd' $env.DOCKER_REGISTRY" + } + } + + stage("install dependencies $application") { + execute("dep ensure -v") + } + + stage("code quality") { + execute("gometalinter --vendor --deadline=2m --disable-all " + + "--enable=vet " + + "--exclude=vendor " + + "./...") + } + + stage("build app") { + execute("CGO_ENABLED=0 go build ./cmd/metadata") + } + + stage("test app") { + execute("go test ./...") + } + + if (isMaster) { + stage("IP scan $application (Sourceclear)"){ + withCredentials([string(credentialsId: 'SRCCLR_API_TOKEN', variable: 'SRCCLR_API_TOKEN')]) { + execute("make scan","SRCCLR_API_TOKEN=$SRCCLR_API_TOKEN") + } + } + } + + stage("build image $application") { + dir(env.APP_FOLDER){ + sh "docker build -t $application:latest ." + } + } + + + stage("push image $application") { + sh "docker tag ${application}:latest ${dockerPushRoot}${application}:${dockerImageTag}" + sh "docker push ${dockerPushRoot}${application}:${dockerImageTag}" + } + } + } + } + } catch (ex) { + echo "Got exception: ${ex}" + currentBuild.result = "FAILURE" + def body = "${currentBuild.currentResult} ${env.JOB_NAME}${env.BUILD_DISPLAY_NAME}: on branch: ${params.GIT_BRANCH}. See details: ${env.BUILD_URL}" + emailext body: body, recipientProviders: [[$class: 'DevelopersRecipientProvider'], [$class: 'CulpritsRecipientProvider'], [$class: 'RequesterRecipientProvider']], subject: "${currentBuild.currentResult}: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'" + } + } +} + +def execute(command, envs = '') { + def buildpack = 'golang-buildpack:0.0.8' + def repositoryName = 'kyma' + def envText = envs=='' ? '' : "--env $envs" + workDir = pwd() + sh "docker run --rm -v $workDir:/go/src/github.com/kyma-project/$repositoryName/ -w /go/src/github.com/kyma-project/$repositoryName/$env.APP_FOLDER $envText ${env.DOCKER_REGISTRY}$buildpack /bin/bash -c '$command'" +} diff --git a/components/application-connector/README.md b/components/application-connector/README.md new file mode 100644 index 000000000000..320ee0103cd9 --- /dev/null +++ b/components/application-connector/README.md @@ -0,0 +1,167 @@ +# Application Connector + +## Overview + +This is the repository for the Application Connector. It contains the following services: +- Metadata + +## Prerequisites + +The Application Connector requires Go 1.8 or higher. + +## Installation + +To install the Application Connector components, follow these steps: + +1. `git clone git@github.com/kyma-project/kyma/components/application-connector` +1. `cd application-connector` +1. `make build` + +## Usage + +This section explains how to use the Application Connector. + +### Start the Metadata +To start the Metadata, run this command: + +``` +./metadata +``` + +The Metadata has the following parameters: +- **proxyPort** - This port acts as a proxy for the calls from services and lambdas to an external solution. The default port is `8080`. +- **externalAPIPort** - This port exposes the Metadata API to an external solution. The default port is `8081`. +- **eventsTargetURL** - A URL to which you proxy the incoming events. The default URL is http://localhost:9000. +- **appName** - The name of the metadata instance. The default appName is `metadata`. +- **namespace** - Namespace where Metadata is deployed. The default namespace is `kyma-system`. +- **requestTimeout** - A time-out for requests sent through the Metadata. It is provided in seconds. The default time-out is `1`. +- **skipVerify** - A flag for skipping the verification of certificates for the proxy targets. The default value is `false`. +- **requestLogging** - A flag for logging incoming requests. The default value is `false`. + +### Sample call + +- Creating a new service + +```sh +curl -X POST http://localhost:32000/ec-default/v1/metadata/services \ + -d '{"name": "Some EC", + "provider": "kyma", + "description": "This is some EC!", + "api": { + "targetUrl": "https://ec.com/rest/v2/", + "credentials": { + "oauth": { + "url": "https://ec.com/authorizationserver/oauth/token", + "clientId": "CLIENT_ID", + "clientSecret": "CLIENT_SECRET" + } + }, + "spec": { + "apispec": "This is API spec..." + } + }, + "events": { + "spec": { + "eventsspec": "This is Events Spec..." + } + }, + "documentation": { + "displayName": "Documentation", + "description": "Description", + "type": "sometype", + "tags": ["tag1", "tag2"], + "docs": [ + { + "title": "Documentation title...", + "type": "type", + "source": "source" + } + ] + } +}' +``` + +- Fetching all services + +```console +curl http://localhost:32000/ec-default/v1/metadata/services +``` + +## Development + +This section explains the development process. + +### Rapid development with Telepresence + +Application Connector stores its state in the Kubernetes Custom Resource, therefore it's dependent on Kubernetes. You cannot mock the dependency. You cannot develop locally. Manual deployment on every change is a mundane task. + +You can, however, leverage [Telepresence](https://www.telepresence.io/). This works by replacing a container in a specified pod, opening up a new local shell or a pre-configured bash, and proxying the network traffic from the local shell through the pod. + +Although you are on your local machine, you can make calls such as `curl http://....svc.cluster.local:8081/v1/metadata/services`. When you run a server in this shell, other Kubernetes services can access it. + +1. [Install telepresence](https://www.telepresence.io/reference/install). +2. Run Kyma or use the cluster. In the case of Kyma, point your local kubectl to Kyma in Docker. +3. Check the Deployment name to swap and run: `telepresence --namespace --swap-deployment : --run-shell` +```bash +telepresence --namespace kyma-system --swap-deployment metadata:metadata --run-shell +``` +4. Every Kubernetes pod has `/var/run/secrets` mounted. The Kubernetes client uses it in the Application Connector services. It is hardcoded. By default, telepresence copies this directory. It stores the directory path in `$TELEPRESENCE_ROOT`, under telepresence shell. It unwinds to `/tmp/tmp...`. You need to move it to `/var/run/secrets`, where the service expects it. Create a symlink: + ```bash +sudo ln -s $TELEPRESENCE_ROOT/var/run/secrets /var/run/secrets +``` +5. Use the `make build` and then the `./metadata` commands, and now all the Kubernetes services that call metadata access this process. The process runs locally on your machine. Use the same command to run different Application Connector services like Proxy or Events. + +You can also run another shell to make calls to this service. To run this shell, swap the Remote Environment Broker Deployment, because Istio sidecar is already injected into this Deployment: +```bash +telepresence --namespace kyma-system --swap-deployment kyma-core-remote-environment-broker:reb --run-shell +``` + +### Generate mocks + +To generate a mock, follow these steps: + +1. Go to the directory where the interface is located. +2. Run this command: +```sh +mockery -name=Sender +``` + +### Tests + +This section outlines the testing details. + +#### Unit tests + +To run the unit tests, use the following command: + +``` +make test-unit +``` + +### Generate Kubernetes clients for custom resources + +1. Create a directory structure for each client, similar to the one in `pkg/apis`. For example, when generating a client for EgressRule in Istio, the directory structure looks like this: `pkg/apis/istio/v1alpha2`. +2. After creating the directories, define the following files: + - `doc.go` + - `register.go` + - `types.go` - define the custom structs that reflect the fields of the custom resource. + +See an example in `pkg/apis/istio/v1alpha2`. + +3. Go to the project root directory and run `./hack/update-codegen.sh`. The script generates a new client in `pkg/apis/client/clientset`. + + +### Contract between the Application Connector and the UI API Facade + +The UI API Facade must check the status of the Application Connector services instances that represents the Remote Environment. +In the current solution, the UI API Facade iterates through services to find those which match the criteria, and then uses the health endpoint to determine the status. +The UI API Facade has the following obligatory requirements: +- The Kubernetes service for each Application Connector service uses the `remoteEnvironment` key, with the value as the name of the remote environment. +- The Kubernetes service for each Application Connector service contains one port with the `ext-api-port` name. The system uses this port for the status check. +- Find the Kubernetes Application Connector service service in the `kyma-integration` Namespace. You can change its location in the `ui-api-layer` chart configuration. +- The `/v1/health` endpoint returns a status of `HTTP 200`. Any other status code indicates the service is not healthy. + + +### Contribution + +To learn how you can contribute to this project, see the [Contributing](/CONTRIBUTING.md) document. diff --git a/components/application-connector/cmd/metadata/metadata.go b/components/application-connector/cmd/metadata/metadata.go new file mode 100644 index 000000000000..ea4ca302f14e --- /dev/null +++ b/components/application-connector/cmd/metadata/metadata.go @@ -0,0 +1,189 @@ +package main + +import ( + "net/http" + "os" + "strconv" + "time" + + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + "github.com/kyma-project/kyma/components/application-connector/internal/externalapi" + "github.com/kyma-project/kyma/components/application-connector/internal/httptools" + "github.com/kyma-project/kyma/components/application-connector/internal/k8sconsts" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/accessservice" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/istio" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/minio" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/remoteenv" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/secrets" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/serviceapi" + metauuid "github.com/kyma-project/kyma/components/application-connector/internal/metadata/uuid" + istioclient "github.com/kyma-project/kyma/components/application-connector/pkg/client/clientset/versioned" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned" + "github.com/satori/go.uuid" + log "github.com/sirupsen/logrus" + "k8s.io/client-go/kubernetes" + restclient "k8s.io/client-go/rest" +) + +const ( + MinioAccessKeyIdEnv = "MINIO_ACCESSKEYID" + MinioSecretAccessKey = "MINIO_ACCESSKEYSECRET" +) + +func main() { + formatter := &log.TextFormatter{ + FullTimestamp: true, + } + log.SetFormatter(formatter) + + log.Info("Starting metadata.") + + options := parseArgs() + log.Infof("Options: %s", options) + + nameResolver := k8sconsts.NewNameResolver(options.namespace) + + serviceDefinitionService, err := newServiceDefinitionService( + options.minioURL, + options.namespace, + options.appName, + options.proxyPort, + nameResolver, + ) + + if err != nil { + log.Errorf("Unable to initialize: '%s'", err.Error()) + } + + externalHandler := newExternalHandler(serviceDefinitionService) + + if options.requestLogging { + externalHandler = httptools.RequestLogger("External handler: ", externalHandler) + } + + externalSrv := &http.Server{ + Addr: ":" + strconv.Itoa(options.externalAPIPort), + Handler: externalHandler, + ReadTimeout: time.Duration(options.requestTimeout) * time.Second, + WriteTimeout: time.Duration(options.requestTimeout) * time.Second, + } + + log.Info(externalSrv.ListenAndServe()) +} + +func newExternalHandler(serviceDefinitionService metadata.ServiceDefinitionService) http.Handler { + var metadataHandler externalapi.MetadataHandler + + if serviceDefinitionService != nil { + metadataHandler = externalapi.NewMetadataHandler(externalapi.NewServiceDetailsValidator(), serviceDefinitionService) + } else { + metadataHandler = externalapi.NewInvalidStateMetadataHandler("Service is not initialized properly.") + } + + return externalapi.NewHandler(metadataHandler) +} + +func newServiceDefinitionService(minioURL, namespace, appName string, proxyPort int, nameResolver k8sconsts.NameResolver) (metadata.ServiceDefinitionService, apperrors.AppError) { + k8sConfig, err := restclient.InClusterConfig() + if err != nil { + return nil, apperrors.Internal("failed to read k8s in-cluster configuration, %s", err) + } + + coreClientset, err := kubernetes.NewForConfig(k8sConfig) + if err != nil { + return nil, apperrors.Internal("failed to create k8s core client, %s", err) + } + + accessKeyId, secretAccessKey, err := readMinioAccessConfiguration() + if err != nil { + return nil, apperrors.Internal("failed to read minio configuration, %s", err.Error()) + } + + minioRepository, err := minio.NewMinioRepository(minioURL, accessKeyId, secretAccessKey) + if err != nil { + return nil, apperrors.Internal("failed to create minio repository, %s", err.Error()) + } + + minioService := minio.NewService(minioRepository) + + remoteEnvironmentServiceRepository, apperror := newRemoteEnvironmentRepository(k8sConfig, namespace) + if apperror != nil { + return nil, apperror + } + + istioService, apperror := newIstioService(k8sConfig, namespace) + if err != nil { + return nil, apperror + } + + accessServiceManager := newAccessServiceManager(coreClientset, namespace, appName, proxyPort) + secretsRepository := newSecretsRepository(coreClientset, namespace) + + uuidGenerator := metauuid.GeneratorFunc(func() string { + return uuid.NewV4().String() + }) + + serviceAPIService := serviceapi.NewService(nameResolver, accessServiceManager, secretsRepository, istioService) + + return metadata.NewServiceDefinitionService(uuidGenerator, serviceAPIService, remoteEnvironmentServiceRepository, minioService), nil +} + +func newRemoteEnvironmentRepository(config *restclient.Config, namespace string) (remoteenv.ServiceRepository, apperrors.AppError) { + remoteEnvironmentClientset, err := versioned.NewForConfig(config) + if err != nil { + return nil, apperrors.Internal("failed to create k8s remote environment client, %s", err) + } + + rei := remoteEnvironmentClientset.RemoteenvironmentV1alpha1().RemoteEnvironments(namespace) + + return remoteenv.NewServiceRepository(rei), nil +} + +func newAccessServiceManager(coreClientset *kubernetes.Clientset, namespace, appName string, proxyPort int) accessservice.AccessServiceManager { + si := coreClientset.CoreV1().Services(namespace) + + config := accessservice.AccessServiceManagerConfig{ + AppName: appName, + TargetPort: int32(proxyPort), + } + + return accessservice.NewAccessServiceManager(si, config) +} + +func newSecretsRepository(coreClientset *kubernetes.Clientset, namespace string) secrets.Repository { + sei := coreClientset.CoreV1().Secrets(namespace) + + return secrets.NewRepository(sei) +} + +func newIstioService(config *restclient.Config, namespace string) (istio.Service, apperrors.AppError) { + ic, err := istioclient.NewForConfig(config) + if err != nil { + return nil, apperrors.Internal("failed to create client for istio, %s", err) + } + + repository := istio.NewRepository( + ic.IstioV1alpha2().Rules(namespace), + ic.IstioV1alpha2().Checknothings(namespace), + ic.IstioV1alpha2().Deniers(namespace), + istio.RepositoryConfig{Namespace: namespace}, + ) + + return istio.NewService(repository), nil +} + +func readMinioAccessConfiguration() (string, string, apperrors.AppError) { + accessKeyId, foundId := os.LookupEnv(MinioAccessKeyIdEnv) + secretAccessKey, foundSecret := os.LookupEnv(MinioSecretAccessKey) + + if !foundId && !foundSecret { + return "", "", apperrors.Internal("%s and %s environment variables not set", MinioAccessKeyIdEnv, MinioSecretAccessKey) + } else if !foundId { + return "", "", apperrors.Internal("%s environment variable not set", MinioAccessKeyIdEnv) + } else if !foundSecret { + return "", "", apperrors.Internal("%s environment variable not set", MinioSecretAccessKey) + } + + return accessKeyId, secretAccessKey, nil +} diff --git a/components/application-connector/cmd/metadata/options.go b/components/application-connector/cmd/metadata/options.go new file mode 100644 index 000000000000..f8b77c09fecd --- /dev/null +++ b/components/application-connector/cmd/metadata/options.go @@ -0,0 +1,45 @@ +package main + +import ( + "flag" + "fmt" +) + +type options struct { + appName string + externalAPIPort int + proxyPort int + minioURL string + namespace string + requestTimeout int + requestLogging bool +} + +func parseArgs() *options { + appName := flag.String("appName", "gateway", "Name of the gateway, used by k8s deployments and services") + externalAPIPort := flag.Int("externalAPIPort", 8081, "External API port.") + proxyPort := flag.Int("proxyPort", 8080, "Proxy port.") + minioURL := flag.String("minioURL", "localhost:9000", "Target URL for events to be sent.") + namespace := flag.String("namespace", "kyma-system", "Namespace used by Gateway") + requestTimeout := flag.Int("requestTimeout", 1, "Timeout for services.") + requestLogging := flag.Bool("requestLogging", false, "Flag for logging incoming requests.") + + flag.Parse() + + return &options{ + appName: *appName, + externalAPIPort: *externalAPIPort, + proxyPort: *proxyPort, + minioURL: *minioURL, + namespace: *namespace, + requestTimeout: *requestTimeout, + requestLogging: *requestLogging, + } +} + +func (o *options) String() string { + return fmt.Sprintf("--appName=%s --externalAPIPort=%d --proxyPort=%d --minioURL=%s"+ + "--namespace=%s --requestTimeout=%d --requestLogging=%t", + o.appName, o.externalAPIPort, o.proxyPort, o.minioURL, + o.namespace, o.requestTimeout, o.requestLogging) +} diff --git a/components/application-connector/docs/api/metadata.yaml b/components/application-connector/docs/api/metadata.yaml new file mode 100644 index 000000000000..3e4de7efa35a --- /dev/null +++ b/components/application-connector/docs/api/metadata.yaml @@ -0,0 +1,339 @@ +openapi: '3.0.0' +info: + version: '1.0.0' + title: 'Kyma Metadata API' +tags: +- name: 'service metadata' + description: 'Service registering API.' +paths: + /v1/metadata/services: + post: + tags: + - 'service metadata' + summary: 'Registers a new service' + operationId: 'registerService' + requestBody: + description: 'Service object to be registered' + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceDetails' + responses: + '200': + description: 'Successful operation' + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceId' + '400': + description: 'Bad request' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + '500': + description: 'Internal server error' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + get: + tags: + - 'service metadata' + summary: 'Gets all registered services' + operationId: 'getServices' + responses: + '200': + description: 'Successful operation' + content: + application/json: + schema: + type: 'array' + items: + $ref: '#/components/schemas/Service' + '500': + description: 'Internal server error' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + /v1/metadata/services/{serviceId}: + get: + tags: + - 'service metadata' + summary: 'Gets a service by service ID' + operationId: 'getServiceByServiceId' + parameters: + - in: 'path' + name: 'serviceId' + description: 'ID of a service' + required: true + schema: + type: 'string' + format: 'uuid' + responses: + '200': + description: 'Successful operation' + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceDetails' + '404': + description: 'Service not found' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + '500': + description: 'Internal server error' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + put: + tags: + - 'service metadata' + summary: 'Updates a service by service ID' + operationId: 'updateService' + requestBody: + description: 'Service object to be stored' + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceDetails' + parameters: + - in: 'path' + name: 'serviceId' + description: 'ID of a service' + required: true + schema: + type: 'string' + format: 'uuid' + responses: + '200': + description: 'Successful operation' + '404': + description: 'Service not found' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + '500': + description: 'Internal server error' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + delete: + tags: + - 'service metadata' + summary: 'Deletes a service by service ID' + operationId: 'deleteServiceByServiceId' + parameters: + - in: 'path' + name: 'serviceId' + description: 'ID of a service' + required: true + schema: + type: 'string' + format: 'uuid' + responses: + '204': + description: 'Successful operation' + '404': + description: 'Service not found' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + '500': + description: 'Internal server error' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + /v1/health: + get: + summary: 'Returns health of a service' + operationId: 'getHealth' + tags: + - 'health' + responses: + '200': + description: 'The service is in a good health' +components: + schemas: + ServiceId: + type: 'object' + properties: + id: + type: 'string' + format: 'uuid' + ServiceDetails: + type: 'object' + properties: + provider: + type: 'string' + name: + type: 'string' + description: + type: 'string' + api: + $ref: '#/components/schemas/Api' + documentation: + $ref: '#/components/schemas/Documentation' + required: + - provider + - name + - description + Service: + type: 'object' + properties: + id: + type: 'string' + format: 'uuid' + provider: + type: 'string' + name: + type: 'string' + description: + type: 'string' + Api: + type: 'object' + properties: + targetUrl: + type: 'string' + format: 'uri' + credentials: + $ref: '#/components/schemas/ApiCredentials' + spec: + type: 'object' + description: 'OpenApi v2 swagger file: https://github.com/OAI/OpenAPI-Specification/blob/master/schemas/v2.0/schema.json' + required: + - targetUrl + Documentation: + type: 'object' + properties: + displayName: + type: 'string' + description: + type: 'string' + type: + type: 'string' + tags: + type: 'array' + items: + type: 'string' + docs: + type: 'array' + items: + $ref: '#/components/schemas/Document' + required: + - displayName + - description + - type + Document: + type: 'object' + properties: + title: + type: 'string' + type: + type: 'string' + source: + type: 'string' + required: + - title + - type + - source + ApiCredentials: + type: 'object' + properties: + oauth: + $ref: '#/components/schemas/OAuth' + required: + - oauth + OAuth: + type: 'object' + properties: + url: + type: 'string' + format: 'uri' + clientId: + type: 'string' + clientSecret: + type: 'string' + required: + - url + - clientId + - clientSecret + MetadataErrorResponse: + type: 'object' + properties: + code: + type: 'integer' + error: + type: 'string' + AnyValue: + nullable: false + description: Can be any value but null. + APIError: + type: object + description: API Error response body + properties: + status: + type: integer + description: >- + original HTTP error code, should be consistent with the response HTTP code + minimum: 100 + maximum: 599 + type: + type: string + description: >- + classification of the error type, lower case with underscore eg + validation_failure + pattern: '[a-z]+[a-z_]*[a-z]+' + message: + type: string + description: descriptive error message for debugging + moreInfo: + type: string + format: uri + description: link to documentation to investigate further and finding support + details: + type: array + description: list of error causes + items: + $ref: '#/components/schemas/APIErrorDetail' + required: + - status + - type + APIErrorDetail: + description: schema for specific error detail + type: object + properties: + field: + type: string + description: >- + a bean notation expression specifying the element in request + data causing the error, eg product.variants[3].name, this can + be empty if violation was not field specific + type: + type: string + description: >- + classification of the error detail type, lower case with + underscore eg missing_value, this value must be always + interpreted in context of the general error type. + pattern: '[a-z]+[a-z_]*[a-z]+' + message: + type: string + description: descriptive error detail message for debugging + moreInfo: + type: string + format: uri + description: >- + link to documentation to investigate further and finding + support for error detail + required: + - type diff --git a/components/application-connector/docs/instalation/Installation.md b/components/application-connector/docs/instalation/Installation.md new file mode 100644 index 000000000000..41c5468c5bb7 --- /dev/null +++ b/components/application-connector/docs/instalation/Installation.md @@ -0,0 +1,168 @@ +# Adding a new Application Connector to Kyma + +## Overview + +The Application Connector connects an external solution to Kyma. + +## Introduction + +Application Connector consists of: +- the [Remote Environment](https://github.com/kyma-project/kyma/components/kyma/blob/master/docs/remote-environment.md) +- the Gateway - A service responsible for registering available services (APIs, Events) and proxying calls to the registered solution. +- Ingress-Nginx - A controller that exposes multiple Application Connectors to the external world. + +By default, Kyma comes with two default Application Connectors preconfigured. A user can add more Application Connectors using Helm package manager. + +To add an Application Connector, download the `remote-environments.zip` package. Unpack it and place it in the project directory. + + +## Installation + +Use this command to install the Remote Environment: +``` bash +helm install --name remote-environment-name --set deployment.args.sourceType=commerce --set global.domainName=domain.cluster.com --set global.isLocalEnv=false --namespace kyma-integration ./remote-environments +``` + +To install locally on Minikube, provide the NodePort as shown in this example: +``` bash +helm install --name remote-environment-name --set deployment.args.sourceType=commerce --set global.domainName=domain.cluster.com --set global.isLocalEnv=true --set service.externalapi.nodePort=32001 --namespace kyma-integration ./remote-environments +``` + +The user can override the following parameters: + +- **sourceEnvironment** - The Event source environment name. +- **sourceType** - The Event source type. +- **sourceNamespace** - The organization that publishes the Event. + +## Working with helm + +Helm provides you with several useful commands: +- `helm list` - list existing helm releases +- `helm test [release-name]` - test a release +- `helm get [release-name]` - see the contents of `.yaml` files that make up the release +- `helm status [release-name]` - show the status of the named release +- `helm delete [release-name]` - delete the release from Kubernetes + +To review a complete list of helm commands, see https://docs.helm.sh/helm/ or use `helm --help` + + ## Check with kubectl + +Make sure everything runs with kubectl: +`kubectl get pods -n kyma-integration` +`kubectl get services -n kyma-integration` + +## Access the Application Connector + +The Ingress-Nginx controller exposes Kyma Gateways to the outside world using a public IP address/DNS name. The DNS name of Ingress is `gateway.[cluster-dns]`. For example, `gateway.servicemanager.cluster.kyma.cx`. + +Expose a particular Gateway service as the path of the Remote Environment. For example, if you want to reach the Gateway of the Remote Environment named `ec-dafault`, you need to use following URL: `gateway.servicemanager.cluster.kyma.cx/ec-default`. The communication requires a valid client certificate. Check the security documentation for further details. + +The following example shows how to get all ServiceClasses. + +``` console +http GET https://gateway.servicemanager.cluster.kyma.cx/ec-default/v1/metadata/services --cert=ec-default.pem +``` + +## Example + +This example shows how to add a new Application Connector running on Minikube. + +To integrate a new instance of `Marketing` marked as a `Production` environment, the example uses the following values: + +- **sourceEnvironment** = production +- **sourceType** = marketing +- **sourceNamespace** = organization.com + +Start with: + +``` bash +helm install --name hmc-prod --set deployment.args.sourceType=marketing --set deployment.args.sourceEnvironment=production --set global.isLocalEnv=true --set service.externalapi.nodePort=32002 --namespace kyma-integration ./remote-environments +``` + +The following output displays: +``` bash +NAME: hmc-prod +LAST DEPLOYED: Fri Apr 20 11:25:44 2018 +NAMESPACE: kyma-integration +STATUS: DEPLOYED + +RESOURCES: +==> v1/Role +NAME AGE +hmc-prod-gateway-role 0s + +==> v1/RoleBinding +NAME AGE +hmc-prod-gateway-rolebinding 0s + +==> v1/Service +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +hmc-prod-gateway-external-api NodePort 10.108.126.243 8081:32002/TCP 0s +hmc-prod-gateway-echo ClusterIP 10.100.94.12 8080/TCP 0s + +==> v1beta1/Deployment +NAME DESIRED CURRENT UP-TO-DATE AVAILABLE AGE +hmc-prod-gateway 1 1 1 0 0s + +==> v1alpha1/RemoteEnvironment +NAME AGE +hmc-prod 0s + +==> v1/Pod(related) +NAME READY STATUS RESTARTS AGE +hmc-prod-gateway-67469769c8-6lgjl 0/1 ContainerCreating 0 0s + + +NOTES: +------------------------------------------------------------------------------------------------------------------------ + +Thank you for installing Gateway helm chart for Kubernetes version 0.0.1. + +To learn more about the release, see: + + $ helm status hmc-prod + $ helm get hmc-prod + +------------------------------------------------------------------------------------------------------------------------ + +``` +Running `helm status hmc-prod` shows similar output with the most recent status of the release. + +Run `helm list`. See your release among the others: +``` bash +cluster-essentials 1 Wed Apr 18 07:50:01 2018 DEPLOYED kyma-cluster-essentials-0.0.1 kyma-system +ec-default 1 Wed Apr 18 07:57:50 2018 DEPLOYED gateway-0.0.1 kyma-integration +hmc-default 1 Wed Apr 18 07:57:36 2018 DEPLOYED gateway-0.0.1 kyma-integration +istio 1 Wed Apr 18 07:50:04 2018 DEPLOYED istio-0.5.0 istio-system +prometheus-operator 1 Wed Apr 18 07:51:50 2018 DEPLOYED prometheus-operator-0.17.0 kyma-system +hmc-prod 1 Fri Apr 20 11:25:44 2018 DEPLOYED gateway-0.0.1 kyma-integration +sf-core 2 Wed Apr 18 07:56:56 2018 DEPLOYED kyma-core-0.0.1 kyma-system +``` + +Use `kubectl` commands to see Kubernetes resources associated with your release. + +`kubectl get pods -n kyma-integration` +``` bash +NAME READY STATUS RESTARTS AGE +ec-default-gateway-5b77fdf7b5-rx64m 2/2 Running 3 2d +hmc-default-gateway-f88b58978-75dkb 2/2 Running 3 2d +hmc-prod-gateway-67469769c8-6lgjl 1/1 Running 0 1m +``` + +`kubectl get services -n kyma-integration` +``` bash +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +ec-default-gateway-echo ClusterIP 10.96.212.205 8080/TCP 2d +ec-default-gateway-external-api NodePort 10.101.245.196 8081:32000/TCP 2d +hmc-default-gateway-echo ClusterIP 10.101.68.223 8080/TCP 2d +hmc-default-gateway-external-api NodePort 10.96.215.1 8081:32001/TCP 2d +hmc-prod-gateway-echo ClusterIP 10.100.94.12 8080/TCP 1m +hmc-prod-gateway-external-api NodePort 10.108.126.243 8081:32002/TCP 1m +``` + +When you are done, delete the release with the following command: +`helm delete hmc-prod --purge` + +```bash +release "hmc-prod" deleted +``` diff --git a/components/application-connector/docs/instalation/remote-environments.zip b/components/application-connector/docs/instalation/remote-environments.zip new file mode 100644 index 0000000000000000000000000000000000000000..d915774d81cbd88ac52399a428ba321b0a252c81 GIT binary patch literal 6141 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 MlK=oLhq2%O2eE-XApigX literal 0 HcmV?d00001 diff --git a/components/application-connector/hack/custom-boilerplate.go.txt b/components/application-connector/hack/custom-boilerplate.go.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/components/application-connector/hack/generate-groups.sh b/components/application-connector/hack/generate-groups.sh new file mode 100755 index 000000000000..bf4890248c5d --- /dev/null +++ b/components/application-connector/hack/generate-groups.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +# This file was copied from k8s.io/code-generator project +# The only one modification was to specify path in `go install` execution (line 52). + +# Copyright 2017 The Kubernetes Authors. +# +# 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. + +set -o errexit +set -o nounset +set -o pipefail + +# generate-groups generates everything for a project with external types only, e.g. a project based +# on CustomResourceDefinitions. + +if [ "$#" -lt 4 ] || [ "${1}" == "--help" ]; then + cat < ... + + the generators comma separated to run (deepcopy,defaulter,client,lister,informer) or "all". + the output package name (e.g. github.com/example/project/pkg/generated). + the external types dir (e.g. github.com/example/api or github.com/example/project/pkg/apis). + the groups and their versions in the format "groupA:v1,v2 groupB:v1 groupC:v2", relative + to . + ... arbitrary flags passed to all generator binaries. + + +Examples: + $(basename $0) all github.com/example/project/pkg/client github.com/example/project/pkg/apis "foo:v1 bar:v1alpha1,v1beta1" + $(basename $0) deepcopy,client github.com/example/project/pkg/client github.com/example/project/pkg/apis "foo:v1 bar:v1alpha1,v1beta1" +EOF + exit 0 +fi + +GENS="$1" +OUTPUT_PKG="$2" +APIS_PKG="$3" +GROUPS_WITH_VERSIONS="$4" +shift 4 + +go install ./vendor/k8s.io/code-generator/cmd/{defaulter-gen,client-gen,lister-gen,informer-gen,deepcopy-gen} +function codegen::join() { local IFS="$1"; shift; echo "$*"; } + +# enumerate group versions +FQ_APIS=() # e.g. k8s.io/api/apps/v1 +for GVs in ${GROUPS_WITH_VERSIONS}; do + IFS=: read G Vs <<<"${GVs}" + + # enumerate versions + for V in ${Vs//,/ }; do + FQ_APIS+=(${APIS_PKG}/${G}/${V}) + done +done + +if [ "${GENS}" = "all" ] || grep -qw "deepcopy" <<<"${GENS}"; then + echo "Generating deepcopy funcs" + ${GOPATH}/bin/deepcopy-gen --input-dirs $(codegen::join , "${FQ_APIS[@]}") -O zz_generated.deepcopy --bounding-dirs ${APIS_PKG} "$@" +fi + +if [ "${GENS}" = "all" ] || grep -qw "client" <<<"${GENS}"; then + echo "Generating clientset for ${GROUPS_WITH_VERSIONS} at ${OUTPUT_PKG}/clientset" + ${GOPATH}/bin/client-gen --clientset-name versioned --input-base "" --input $(codegen::join , "${FQ_APIS[@]}") --clientset-path ${OUTPUT_PKG}/clientset "$@" +fi + +if [ "${GENS}" = "all" ] || grep -qw "lister" <<<"${GENS}"; then + echo "Generating listers for ${GROUPS_WITH_VERSIONS} at ${OUTPUT_PKG}/listers" + ${GOPATH}/bin/lister-gen --input-dirs $(codegen::join , "${FQ_APIS[@]}") --output-package ${OUTPUT_PKG}/listers "$@" +fi + +if [ "${GENS}" = "all" ] || grep -qw "informer" <<<"${GENS}"; then + echo "Generating informers for ${GROUPS_WITH_VERSIONS} at ${OUTPUT_PKG}/informers" + ${GOPATH}/bin/informer-gen \ + --input-dirs $(codegen::join , "${FQ_APIS[@]}") \ + --versioned-clientset-package ${OUTPUT_PKG}/clientset/versioned \ + --listers-package ${OUTPUT_PKG}/listers \ + --output-package ${OUTPUT_PKG}/informers \ + "$@" +fi diff --git a/components/application-connector/hack/update-codegen.sh b/components/application-connector/hack/update-codegen.sh new file mode 100755 index 000000000000..b13cab16524a --- /dev/null +++ b/components/application-connector/hack/update-codegen.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -o errexit +set -o nounset +set -o pipefail + +SCRIPT_ROOT=$(dirname ${BASH_SOURCE})/.. +CODEGEN_PKG=${CODEGEN_PKG:-$(cd ${SCRIPT_ROOT}; ls -d -1 ./vendor/k8s.io/code-generator 2>/dev/null || echo ${GOPATH}/src/k8s.io/code-generator)} +METADATA_ROOT_PKG="github.com/kyma-project/kyma/components/application-connector/pkg" + +./hack/generate-groups.sh all \ + ${METADATA_ROOT_PKG}/client ${METADATA_ROOT_PKG}/apis \ + istio:v1alpha2 \ + --go-header-file ${SCRIPT_ROOT}/hack/custom-boilerplate.go.txt diff --git a/components/application-connector/internal/apperrors/apperrors.go b/components/application-connector/internal/apperrors/apperrors.go new file mode 100644 index 000000000000..45d545d6f34c --- /dev/null +++ b/components/application-connector/internal/apperrors/apperrors.go @@ -0,0 +1,53 @@ +package apperrors + +import "fmt" + +const ( + CodeInternal = 1 + CodeNotFound = 2 + CodeAlreadyExists = 3 + CodeWrongInput = 4 + CodeUpstreamServerCallFailed = 5 +) + +type AppError interface { + Code() int + Error() string +} + +type appError struct { + code int + message string +} + +func errorf(code int, format string, a ...interface{}) AppError { + return appError{code: code, message: fmt.Sprintf(format, a...)} +} + +func Internal(format string, a ...interface{}) AppError { + return errorf(CodeInternal, format, a...) +} + +func NotFound(format string, a ...interface{}) AppError { + return errorf(CodeNotFound, format, a...) +} + +func AlreadyExists(format string, a ...interface{}) AppError { + return errorf(CodeAlreadyExists, format, a...) +} + +func WrongInput(format string, a ...interface{}) AppError { + return errorf(CodeWrongInput, format, a...) +} + +func UpstreamServerCallFailed(format string, a ...interface{}) AppError { + return errorf(CodeUpstreamServerCallFailed, format, a...) +} + +func (ae appError) Code() int { + return ae.code +} + +func (ae appError) Error() string { + return ae.message +} diff --git a/components/application-connector/internal/apperrors/apperrors_test.go b/components/application-connector/internal/apperrors/apperrors_test.go new file mode 100644 index 000000000000..0367732200f6 --- /dev/null +++ b/components/application-connector/internal/apperrors/apperrors_test.go @@ -0,0 +1,34 @@ +package apperrors + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAppError(t *testing.T) { + + t.Run("should create error with proper code", func(t *testing.T) { + assert.Equal(t, CodeInternal, Internal("error").Code()) + assert.Equal(t, CodeNotFound, NotFound("error").Code()) + assert.Equal(t, CodeAlreadyExists, AlreadyExists("error").Code()) + assert.Equal(t, CodeWrongInput, WrongInput("error").Code()) + assert.Equal(t, CodeUpstreamServerCallFailed, UpstreamServerCallFailed("error").Code()) + }) + + t.Run("should create error with simple message", func(t *testing.T) { + assert.Equal(t, "error", Internal("error").Error()) + assert.Equal(t, "error", NotFound("error").Error()) + assert.Equal(t, "error", AlreadyExists("error").Error()) + assert.Equal(t, "error", WrongInput("error").Error()) + assert.Equal(t, "error", UpstreamServerCallFailed("error").Error()) + }) + + t.Run("should create error with formatted message", func(t *testing.T) { + assert.Equal(t, "code: 1, error: bug", Internal("code: %d, error: %s", 1, "bug").Error()) + assert.Equal(t, "code: 1, error: bug", NotFound("code: %d, error: %s", 1, "bug").Error()) + assert.Equal(t, "code: 1, error: bug", AlreadyExists("code: %d, error: %s", 1, "bug").Error()) + assert.Equal(t, "code: 1, error: bug", WrongInput("code: %d, error: %s", 1, "bug").Error()) + assert.Equal(t, "code: 1, error: bug", UpstreamServerCallFailed("code: %d, error: %s", 1, "bug").Error()) + }) +} diff --git a/components/application-connector/internal/externalapi/errorhandler.go b/components/application-connector/internal/externalapi/errorhandler.go new file mode 100644 index 000000000000..e5b228c0eb63 --- /dev/null +++ b/components/application-connector/internal/externalapi/errorhandler.go @@ -0,0 +1,26 @@ +package externalapi + +import ( + "encoding/json" + "net/http" + + "github.com/kyma-project/kyma/components/application-connector/internal/httpconsts" + "github.com/kyma-project/kyma/components/application-connector/internal/httperrors" +) + +type ErrorHandler struct { + Message string + Code int +} + +func NewErrorHandler(code int, message string) *ErrorHandler { + return &ErrorHandler{Message: message, Code: code} +} + +func (eh *ErrorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + responseBody := httperrors.ErrorResponse{Code: eh.Code, Error: eh.Message} + + w.Header().Set(httpconsts.HeaderContentType, httpconsts.ContentTypeApplicationJson) + w.WriteHeader(eh.Code) + json.NewEncoder(w).Encode(responseBody) +} diff --git a/components/application-connector/internal/externalapi/errorhandler_test.go b/components/application-connector/internal/externalapi/errorhandler_test.go new file mode 100644 index 000000000000..211bfee26d67 --- /dev/null +++ b/components/application-connector/internal/externalapi/errorhandler_test.go @@ -0,0 +1,43 @@ +package externalapi + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/kyma-project/kyma/components/application-connector/internal/httperrors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestErrorHandler_ServeHTTP(t *testing.T) { + t.Run("Should always respond with given error and status code", func(t *testing.T) { + + r := mux.NewRouter() + + r.NotFoundHandler = NewErrorHandler(404, "Requested resource could not be found.") + ts := httptest.NewServer(r) + defer ts.Close() + + // when + res, err := http.Get(ts.URL + "/wrong/path") + + responseBody, err := ioutil.ReadAll(res.Body) + if err != nil { + assert.Fail(t, "Failure while reading response body.") + } + defer res.Body.Close() + + var errResponse httperrors.ErrorResponse + + json.Unmarshal(responseBody, &errResponse) + + // then + require.NoError(t, err) + assert.Equal(t, http.StatusNotFound, errResponse.Code) + assert.Equal(t, http.StatusNotFound, res.StatusCode) + }) +} diff --git a/components/application-connector/internal/externalapi/externalapi.go b/components/application-connector/internal/externalapi/externalapi.go new file mode 100644 index 000000000000..a3aecbe3226e --- /dev/null +++ b/components/application-connector/internal/externalapi/externalapi.go @@ -0,0 +1,33 @@ +package externalapi + +import ( + "net/http" + + "github.com/gorilla/mux" +) + +type MetadataHandler interface { + CreateService(w http.ResponseWriter, r *http.Request) + GetService(w http.ResponseWriter, r *http.Request) + GetServices(w http.ResponseWriter, r *http.Request) + UpdateService(w http.ResponseWriter, r *http.Request) + DeleteService(w http.ResponseWriter, r *http.Request) +} + +func NewHandler(handler MetadataHandler) http.Handler { + router := mux.NewRouter() + + router.Path("/v1/health").Handler(NewHealthCheckHandler()).Methods(http.MethodGet) + + metadataRouter := router.PathPrefix("/{remoteEnvironment}/v1/metadata").Subrouter() + metadataRouter.HandleFunc("/services", handler.CreateService).Methods(http.MethodPost) + metadataRouter.HandleFunc("/services", handler.GetServices).Methods(http.MethodGet) + metadataRouter.HandleFunc("/services/{serviceId}", handler.GetService).Methods(http.MethodGet) + metadataRouter.HandleFunc("/services/{serviceId}", handler.UpdateService).Methods(http.MethodPut) + metadataRouter.HandleFunc("/services/{serviceId}", handler.DeleteService).Methods(http.MethodDelete) + + router.NotFoundHandler = NewErrorHandler(404, "Requested resource could not be found.") + router.MethodNotAllowedHandler = NewErrorHandler(405, "Method not allowed.") + + return router +} diff --git a/components/application-connector/internal/externalapi/healthcheckhandler.go b/components/application-connector/internal/externalapi/healthcheckhandler.go new file mode 100644 index 000000000000..3a7ea2cc324f --- /dev/null +++ b/components/application-connector/internal/externalapi/healthcheckhandler.go @@ -0,0 +1,12 @@ +package externalapi + +import ( + "net/http" +) + +// NewHealthCheckHandler creates handler for performing health check +func NewHealthCheckHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) +} diff --git a/components/application-connector/internal/externalapi/healthcheckhandler_test.go b/components/application-connector/internal/externalapi/healthcheckhandler_test.go new file mode 100644 index 000000000000..80d250f72350 --- /dev/null +++ b/components/application-connector/internal/externalapi/healthcheckhandler_test.go @@ -0,0 +1,27 @@ +package externalapi + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHealthCheckHandler_HandleRequest(t *testing.T) { + t.Run("should always respond with 200 status code", func(t *testing.T) { + // given + req, err := http.NewRequest(http.MethodGet, "/v1/health", nil) + require.NoError(t, err) + rr := httptest.NewRecorder() + + handler := NewHealthCheckHandler() + + // when + handler.ServeHTTP(rr, req) + + // then + assert.Equal(t, http.StatusOK, rr.Code) + }) +} diff --git a/components/application-connector/internal/externalapi/invalidstatehandler.go b/components/application-connector/internal/externalapi/invalidstatehandler.go new file mode 100644 index 000000000000..89a30928b6a8 --- /dev/null +++ b/components/application-connector/internal/externalapi/invalidstatehandler.go @@ -0,0 +1,65 @@ +package externalapi + +import ( + "encoding/json" + "net/http" + + "github.com/kyma-project/kyma/components/application-connector/internal/httpconsts" + "github.com/kyma-project/kyma/components/application-connector/internal/httperrors" + "github.com/kyma-project/kyma/components/application-connector/internal/httptools" +) + +type invalidStateHandler struct { + Message string +} + +func NewInvalidStateMetadataHandler(message string) MetadataHandler { + return &invalidStateHandler{Message: message} +} + +func (ish *invalidStateHandler) CreateService(w http.ResponseWriter, r *http.Request) { + contextLogger := httptools.ContextLogger(r) + httptools.DumpRequestToLog(r, contextLogger) + + ish.HandleRequest(w, r) +} + +func (ish *invalidStateHandler) GetService(w http.ResponseWriter, r *http.Request) { + contextLogger := httptools.ContextLoggerWithId(r) + httptools.DumpRequestToLog(r, contextLogger) + + ish.HandleRequest(w, r) +} + +func (ish *invalidStateHandler) GetServices(w http.ResponseWriter, r *http.Request) { + contextLogger := httptools.ContextLogger(r) + httptools.DumpRequestToLog(r, contextLogger) + + ish.HandleRequest(w, r) +} + +func (ish *invalidStateHandler) UpdateService(w http.ResponseWriter, r *http.Request) { + contextLogger := httptools.ContextLoggerWithId(r) + httptools.DumpRequestToLog(r, contextLogger) + + ish.HandleRequest(w, r) +} + +func (ish *invalidStateHandler) DeleteService(w http.ResponseWriter, r *http.Request) { + contextLogger := httptools.ContextLoggerWithId(r) + httptools.DumpRequestToLog(r, contextLogger) + + ish.HandleRequest(w, r) +} + +func (ish *invalidStateHandler) HandleRequest(w http.ResponseWriter, r *http.Request) { + contextLogger := httptools.ContextLogger(r) + contextLogger.Errorf("Error handling request: %s.", ish.Message) + + statusCode := http.StatusInternalServerError + responseBody := httperrors.ErrorResponse{Code: statusCode, Error: ish.Message} + + w.Header().Set(httpconsts.HeaderContentType, httpconsts.ContentTypeApplicationJson) + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(responseBody) +} diff --git a/components/application-connector/internal/externalapi/invalidstatehandler_test.go b/components/application-connector/internal/externalapi/invalidstatehandler_test.go new file mode 100644 index 000000000000..6e939ba7d400 --- /dev/null +++ b/components/application-connector/internal/externalapi/invalidstatehandler_test.go @@ -0,0 +1,51 @@ +package externalapi + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/kyma-project/kyma/components/application-connector/internal/httperrors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInvalidStateHandler_HandleRequest(t *testing.T) { + + t.Run("Should respond with 500 status code and given error message", func(t *testing.T) { + // given + ifh := invalidStateHandler{"initialization error"} + + req, err := http.NewRequest(http.MethodGet, "/re/v1/metadata/services/1234", nil) + require.NoError(t, err) + + req = mux.SetURLVars(req, map[string]string{"remoteEnvironment": "re", "serviceId": "1234"}) + rr := httptest.NewRecorder() + + // when + ifh.GetService(rr, req) + + // then + assert.Equal(t, http.StatusInternalServerError, rr.Code) + + response := unmarshallResponse(t, rr.Body) + assert.Contains(t, response.Error, "initialization error") + assert.Equal(t, http.StatusInternalServerError, response.Code) + }) +} + +func unmarshallResponse(t *testing.T, body *bytes.Buffer) httperrors.ErrorResponse { + responseBody, err := ioutil.ReadAll(body) + if err != nil { + assert.Fail(t, "Failure while reading response body.") + } + + var errResponse httperrors.ErrorResponse + json.Unmarshal(responseBody, &errResponse) + + return errResponse +} diff --git a/components/application-connector/internal/externalapi/metadatahandler.go b/components/application-connector/internal/externalapi/metadatahandler.go new file mode 100644 index 000000000000..9ba6da8a62ea --- /dev/null +++ b/components/application-connector/internal/externalapi/metadatahandler.go @@ -0,0 +1,188 @@ +package externalapi + +import ( + "bytes" + "encoding/json" + "io" + "io/ioutil" + "net/http" + + "github.com/gorilla/mux" + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + "github.com/kyma-project/kyma/components/application-connector/internal/httpconsts" + "github.com/kyma-project/kyma/components/application-connector/internal/httperrors" + "github.com/kyma-project/kyma/components/application-connector/internal/httptools" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata" +) + +type metadataHandler struct { + validator ServiceDetailsValidator + ServiceDefinitionService metadata.ServiceDefinitionService +} + +func NewMetadataHandler(validator ServiceDetailsValidator, serviceDefinitionService metadata.ServiceDefinitionService) MetadataHandler { + return &metadataHandler{ + validator: validator, + ServiceDefinitionService: serviceDefinitionService, + } +} + +func (mh *metadataHandler) CreateService(w http.ResponseWriter, r *http.Request) { + contextLogger := httptools.ContextLogger(r) + httptools.DumpRequestToLog(r, contextLogger) + + serviceDefinition, apperr := mh.prepareServiceDefinition(r.Body) + if apperr != nil { + contextLogger.Errorf("Error creating new service: %s.", apperr.Error()) + handleErrors(w, apperr) + return + } + + serviceId, apperr := mh.ServiceDefinitionService.Create(mux.Vars(r)["remoteEnvironment"], &serviceDefinition) + if apperr != nil { + contextLogger.Errorf("Error creating new service: %s.", apperr.Error()) + handleErrors(w, apperr) + return + } + + responseBody := CreateServiceResponse{ID: serviceId} + respondWithBody(w, http.StatusOK, responseBody) + + contextLogger.Infof("Service with ID %s created successfully.", serviceId) +} + +func (mh *metadataHandler) GetService(w http.ResponseWriter, r *http.Request) { + contextLogger := httptools.ContextLoggerWithId(r) + httptools.DumpRequestToLog(r, contextLogger) + + service, apperr := mh.ServiceDefinitionService.GetByID(mux.Vars(r)["remoteEnvironment"], mux.Vars(r)["serviceId"]) + if apperr != nil { + contextLogger.Errorf("Error getting service: %s.", apperr.Error()) + handleErrors(w, apperr) + return + } + + responseBody, apperr := serviceDefinitionToServiceDetails(service) + if apperr != nil { + contextLogger.Errorf("Error getting service: %s.", apperr.Error()) + handleErrors(w, apperr) + return + } + + respondWithBody(w, http.StatusOK, responseBody) + contextLogger.Info("Service read successfully.") +} + +func (mh *metadataHandler) GetServices(w http.ResponseWriter, r *http.Request) { + contextLogger := httptools.ContextLogger(r) + httptools.DumpRequestToLog(r, contextLogger) + + services, apperr := mh.ServiceDefinitionService.GetAll(mux.Vars(r)["remoteEnvironment"]) + if apperr != nil { + contextLogger.Errorf("Error getting services: %s.", apperr.Error()) + handleErrors(w, apperr) + return + } + + responseBody := make([]Service, 0) + for _, element := range services { + responseBody = append(responseBody, serviceDefinitionToService(element)) + } + + respondWithBody(w, http.StatusOK, responseBody) + contextLogger.Info("Services read successfully.") +} + +func (mh *metadataHandler) UpdateService(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + contextLogger := httptools.ContextLoggerWithId(r) + httptools.DumpRequestToLog(r, contextLogger) + + serviceDefinition, apperr := mh.prepareServiceDefinition(r.Body) + if apperr != nil { + contextLogger.Errorf("Error updating service: %s.", apperr.Error()) + handleErrors(w, apperr) + return + } + + apperr = mh.ServiceDefinitionService.Update(vars["remoteEnvironment"], vars["serviceId"], &serviceDefinition) + if apperr != nil { + contextLogger.Errorf("Error updating service: %s.", apperr.Error()) + handleErrors(w, apperr) + return + } + + responseBody, apperr := serviceDefinitionToServiceDetails(serviceDefinition) + if apperr != nil { + contextLogger.Errorf("Error updating service: %s.", apperr.Error()) + handleErrors(w, apperr) + return + } + + respondWithBody(w, http.StatusOK, responseBody) + contextLogger.Info("Service updated successfully.") +} + +func (mh *metadataHandler) DeleteService(w http.ResponseWriter, r *http.Request) { + contextLogger := httptools.ContextLoggerWithId(r) + httptools.DumpRequestToLog(r, contextLogger) + + vars := mux.Vars(r) + + apperr := mh.ServiceDefinitionService.Delete(vars["remoteEnvironment"], vars["serviceId"]) + if apperr != nil { + contextLogger.Errorf("Error deleting service: %s.", apperr.Error()) + handleErrors(w, apperr) + return + } + + respond(w, http.StatusNoContent) + contextLogger.Infof("Service deleted successfully.") +} + +func (mh *metadataHandler) prepareServiceDefinition(body io.ReadCloser) (metadata.ServiceDefinition, apperrors.AppError) { + b, err := ioutil.ReadAll(body) + if err != nil { + return metadata.ServiceDefinition{}, apperrors.WrongInput("failed to read request body, %s", err) + } + defer body.Close() + + var serviceDetails ServiceDetails + err = json.Unmarshal(b, &serviceDetails) + if err != nil { + return metadata.ServiceDefinition{}, apperrors.WrongInput("failed to unmarshal request body, %s", err.Error()) + } + + appErr := mh.validator.Validate(serviceDetails) + if appErr != nil { + return metadata.ServiceDefinition{}, apperrors.WrongInput("failed to validate request body, %s", appErr.Error()) + } + + return serviceDetailsToServiceDefinition(serviceDetails) +} + +func handleErrors(w http.ResponseWriter, apperr apperrors.AppError) { + statusCode, responseBody := httperrors.AppErrorToResponse(apperr) + + respond(w, statusCode) + json.NewEncoder(w).Encode(responseBody) +} + +func respond(w http.ResponseWriter, statusCode int) { + w.Header().Set(httpconsts.HeaderContentType, httpconsts.ContentTypeApplicationJson) + w.WriteHeader(statusCode) +} + +func respondWithBody(w http.ResponseWriter, statusCode int, responseBody interface{}) { + respond(w, statusCode) + json.NewEncoder(w).Encode(responseBody) +} + +func compact(src []byte) []byte { + buffer := new(bytes.Buffer) + err := json.Compact(buffer, src) + if err != nil { + return src + } + return buffer.Bytes() +} diff --git a/components/application-connector/internal/externalapi/metadatahandler_test.go b/components/application-connector/internal/externalapi/metadatahandler_test.go new file mode 100644 index 000000000000..6c57ec29d432 --- /dev/null +++ b/components/application-connector/internal/externalapi/metadatahandler_test.go @@ -0,0 +1,758 @@ +package externalapi + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + "github.com/kyma-project/kyma/components/application-connector/internal/httperrors" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata" + metadataMock "github.com/kyma-project/kyma/components/application-connector/internal/metadata/mocks" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/serviceapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type testSpec struct { + Name string +} + +var ( + apiRawSpec = compact([]byte("{\"name\":\"api\"}")) + eventsRawSpec = compact([]byte("{\"name\":\"events\"}")) + documentationRaw = compact([]byte("{\"displayName\":\"documentation name\",\"description\":\"documentation description\",\"type\":\"documentation type\",\"docs\":[{\"title\":\"doc title\",\"type\":\"doc type\",\"source\":\"doc source\"}]}")) + apiSpec = testSpec{Name: "api"} + eventsSpec = testSpec{Name: "events"} +) + +func TestMetadataHandler_CreateService(t *testing.T) { + t.Run("should create a service", func(t *testing.T) { + + // given + serviceDetails := ServiceDetails{ + Name: "service name", + Provider: "service provider", + Description: "service description", + Api: &API{ + TargetUrl: "http://service.com", + Credentials: &Credentials{ + Oauth: Oauth{ + URL: "http://oauth.com", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + Spec: apiRawSpec, + }, + Events: &Events{ + Spec: eventsRawSpec, + }, + Documentation: &Documentation{ + DisplayName: "documentation name", + Description: "documentation description", + Type: "documentation type", + Docs: []DocsObject{{Title: "doc title", Type: "doc type", Source: "doc source"}}, + }, + } + + serviceDefinition := &metadata.ServiceDefinition{ + Name: "service name", + Provider: "service provider", + Description: "service description", + Api: &serviceapi.API{ + TargetUrl: "http://service.com", + Credentials: &serviceapi.Credentials{ + Oauth: serviceapi.Oauth{ + URL: "http://oauth.com", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + Spec: apiRawSpec, + }, + Events: &metadata.Events{ + Spec: eventsRawSpec, + }, + Documentation: documentationRaw, + } + + validator := ServiceDetailsValidatorFunc(func(sd ServiceDetails) apperrors.AppError { + return nil + }) + serviceDefinitionService := &metadataMock.ServiceDefinitionService{} + serviceDefinitionService.On("Create", "re", serviceDefinition).Return("1", nil) + + metadataHandler := NewMetadataHandler(validator, serviceDefinitionService) + + serviceDetailsData, err := json.Marshal(serviceDetails) + require.NoError(t, err) + + req, err := http.NewRequest(http.MethodPost, "/re/v1/metadata/services", bytes.NewReader(serviceDetailsData)) + require.NoError(t, err) + + req = mux.SetURLVars(req, map[string]string{"remoteEnvironment": "re"}) + rr := httptest.NewRecorder() + + // when + metadataHandler.CreateService(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var postResponse CreateServiceResponse + json.Unmarshal(responseBody, &postResponse) + + require.NoError(t, err) + assert.Equal(t, "1", postResponse.ID) + assert.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("should create a service with API without credentials", func(t *testing.T) { + + // given + serviceDetails := ServiceDetails{ + Name: "service name", + Provider: "service provider", + Description: "service description", + Api: &API{ + TargetUrl: "http://service.com", + }, + } + + serviceDefinition := &metadata.ServiceDefinition{ + Name: "service name", + Provider: "service provider", + Description: "service description", + Api: &serviceapi.API{ + TargetUrl: "http://service.com", + }, + } + + validator := ServiceDetailsValidatorFunc(func(sd ServiceDetails) apperrors.AppError { + return nil + }) + serviceDefinitionService := &metadataMock.ServiceDefinitionService{} + serviceDefinitionService.On("Create", "re", serviceDefinition).Return("1", nil) + + metadataHandler := NewMetadataHandler(validator, serviceDefinitionService) + + serviceDetailsData, err := json.Marshal(serviceDetails) + require.NoError(t, err) + + req, err := http.NewRequest(http.MethodPost, "/re/v1/metadata/services", bytes.NewReader(serviceDetailsData)) + require.NoError(t, err) + + req = mux.SetURLVars(req, map[string]string{"remoteEnvironment": "re"}) + rr := httptest.NewRecorder() + + // when + metadataHandler.CreateService(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var postResponse CreateServiceResponse + json.Unmarshal(responseBody, &postResponse) + + require.NoError(t, err) + assert.Equal(t, "1", postResponse.ID) + assert.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("should return 400 when validation fails", func(t *testing.T) { + + // given + serviceDetails := ServiceDetails{} + + validator := ServiceDetailsValidatorFunc(func(sd ServiceDetails) apperrors.AppError { + return apperrors.WrongInput("failed") + }) + serviceDefinitionService := &metadataMock.ServiceDefinitionService{} + + metadataHandler := NewMetadataHandler(validator, serviceDefinitionService) + + serviceDetailsData, err := json.Marshal(serviceDetails) + require.NoError(t, err) + + req, err := http.NewRequest(http.MethodPost, "/re/v1/metadata/services", bytes.NewReader(serviceDetailsData)) + require.NoError(t, err) + + req = mux.SetURLVars(req, map[string]string{"remoteEnvironment": "re"}) + rr := httptest.NewRecorder() + + // when + metadataHandler.CreateService(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var errorResponse httperrors.ErrorResponse + json.Unmarshal(responseBody, &errorResponse) + + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, errorResponse.Code) + assert.Equal(t, http.StatusBadRequest, rr.Code) + serviceDefinitionService.AssertNotCalled(t, "Create", "re", mock.AnythingOfType("*metadata.ServiceDefinition")) + }) + + t.Run("should handle internal errors", func(t *testing.T) { + + // given + serviceDetails := ServiceDetails{ + Name: "service name", + Provider: "service provider", + Description: "service description", + Api: &API{ + TargetUrl: "http://service.com", + }, + } + + validator := ServiceDetailsValidatorFunc(func(sd ServiceDetails) apperrors.AppError { + return nil + }) + serviceDefinitionService := &metadataMock.ServiceDefinitionService{} + serviceDefinitionService.On("Create", "re", mock.AnythingOfType("*metadata.ServiceDefinition")).Return( + "", apperrors.Internal("")) + + metadataHandler := NewMetadataHandler(validator, serviceDefinitionService) + + serviceDetailsData, err := json.Marshal(serviceDetails) + require.NoError(t, err) + + req, err := http.NewRequest(http.MethodPost, "/re/v1/metadata/services", bytes.NewReader(serviceDetailsData)) + require.NoError(t, err) + + req = mux.SetURLVars(req, map[string]string{"remoteEnvironment": "re"}) + rr := httptest.NewRecorder() + + // when + metadataHandler.CreateService(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var errorResponse httperrors.ErrorResponse + json.Unmarshal(responseBody, &errorResponse) + + require.NoError(t, err) + assert.Equal(t, http.StatusInternalServerError, errorResponse.Code) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + }) +} + +func TestMetadataHandler_GetService(t *testing.T) { + t.Run("should return requested service", func(t *testing.T) { + // given + serviceDefinition := metadata.ServiceDefinition{ + Name: "service name", + Provider: "service provider", + Description: "service description", + Api: &serviceapi.API{ + TargetUrl: "http://service.com", + Credentials: &serviceapi.Credentials{ + Oauth: serviceapi.Oauth{ + URL: "http://oauth.com", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + Spec: apiRawSpec, + }, + Events: &metadata.Events{ + Spec: eventsRawSpec, + }, + Documentation: documentationRaw, + } + + serviceDefinitionService := &metadataMock.ServiceDefinitionService{} + serviceDefinitionService.On("GetByID", "re", "123456").Return(serviceDefinition, nil) + + metadataHandler := NewMetadataHandler(nil, serviceDefinitionService) + + req, err := http.NewRequest(http.MethodGet, "/re/v1/metadata/services/123456", nil) + require.NoError(t, err) + + req = mux.SetURLVars(req, map[string]string{"remoteEnvironment": "re", "serviceId": "123456"}) + rr := httptest.NewRecorder() + + // when + metadataHandler.GetService(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var serviceDetails ServiceDetails + json.Unmarshal(responseBody, &serviceDetails) + + require.NoError(t, err) + serviceDefinitionService.AssertCalled(t, "GetByID", "re", "123456") + assert.Equal(t, "service name", serviceDetails.Name) + assert.Equal(t, "service provider", serviceDetails.Provider) + assert.Equal(t, "service description", serviceDetails.Description) + assert.Equal(t, "http://service.com", serviceDetails.Api.TargetUrl) + assert.Equal(t, "http://oauth.com", serviceDetails.Api.Credentials.Oauth.URL) + assert.Equal(t, stars, serviceDetails.Api.Credentials.Oauth.ClientID) + assert.Equal(t, stars, serviceDetails.Api.Credentials.Oauth.ClientSecret) + assert.Equal(t, apiSpec, raw2Json(t, serviceDetails.Api.Spec)) + assert.Equal(t, eventsSpec, raw2Json(t, serviceDetails.Events.Spec)) + assert.Equal(t, "documentation name", serviceDetails.Documentation.DisplayName) + assert.Equal(t, "documentation description", serviceDetails.Documentation.Description) + assert.Equal(t, "documentation type", serviceDetails.Documentation.Type) + assert.Len(t, serviceDetails.Documentation.Docs, 1) + assert.Equal(t, DocsObject{Title: "doc title", Type: "doc type", Source: "doc source"}, serviceDetails.Documentation.Docs[0]) + + assert.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("should return requested service only with API", func(t *testing.T) { + // given + serviceDefinition := metadata.ServiceDefinition{ + Name: "service name", + Provider: "service provider", + Description: "service description", + Api: &serviceapi.API{ + TargetUrl: "http://service.com", + Credentials: &serviceapi.Credentials{ + Oauth: serviceapi.Oauth{ + URL: "http://oauth.com", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + Spec: apiRawSpec, + }, + } + + serviceDefinitionService := &metadataMock.ServiceDefinitionService{} + serviceDefinitionService.On("GetByID", "re", "123456").Return(serviceDefinition, nil) + + metadataHandler := NewMetadataHandler(nil, serviceDefinitionService) + + req, err := http.NewRequest(http.MethodGet, "/re/v1/metadata/services/123456", nil) + require.NoError(t, err) + + req = mux.SetURLVars(req, map[string]string{"remoteEnvironment": "re", "serviceId": "123456"}) + rr := httptest.NewRecorder() + + // when + metadataHandler.GetService(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var serviceDetails ServiceDetails + json.Unmarshal(responseBody, &serviceDetails) + + require.NoError(t, err) + serviceDefinitionService.AssertCalled(t, "GetByID", "re", "123456") + assert.Equal(t, "service name", serviceDetails.Name) + assert.Equal(t, "service provider", serviceDetails.Provider) + assert.Equal(t, "service description", serviceDetails.Description) + assert.Equal(t, "http://service.com", serviceDetails.Api.TargetUrl) + assert.Equal(t, "http://oauth.com", serviceDetails.Api.Credentials.Oauth.URL) + assert.Equal(t, stars, serviceDetails.Api.Credentials.Oauth.ClientID) + assert.Equal(t, stars, serviceDetails.Api.Credentials.Oauth.ClientSecret) + assert.Equal(t, apiSpec, raw2Json(t, serviceDetails.Api.Spec)) + assert.Nil(t, serviceDetails.Events) + assert.Nil(t, serviceDetails.Documentation) + assert.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("should return requested service only with Events", func(t *testing.T) { + // given + serviceDefinition := metadata.ServiceDefinition{ + Name: "service name", + Provider: "service provider", + Description: "service description", + Events: &metadata.Events{ + Spec: eventsRawSpec, + }, + } + + serviceDefinitionService := &metadataMock.ServiceDefinitionService{} + serviceDefinitionService.On("GetByID", "re", "123456").Return(serviceDefinition, nil) + + metadataHandler := NewMetadataHandler(nil, serviceDefinitionService) + + req, err := http.NewRequest(http.MethodGet, "/re/v1/metadata/services/123456", nil) + require.NoError(t, err) + + req = mux.SetURLVars(req, map[string]string{"remoteEnvironment": "re", "serviceId": "123456"}) + rr := httptest.NewRecorder() + + // when + metadataHandler.GetService(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var serviceDetails ServiceDetails + json.Unmarshal(responseBody, &serviceDetails) + + require.NoError(t, err) + serviceDefinitionService.AssertCalled(t, "GetByID", "re", "123456") + assert.Equal(t, "service name", serviceDetails.Name) + assert.Equal(t, "service provider", serviceDetails.Provider) + assert.Equal(t, "service description", serviceDetails.Description) + assert.Nil(t, serviceDetails.Api) + assert.Equal(t, eventsSpec, raw2Json(t, serviceDetails.Events.Spec)) + assert.Nil(t, serviceDetails.Documentation) + assert.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("should return 404 when service was not found", func(t *testing.T) { + + // given + serviceDefinitionService := &metadataMock.ServiceDefinitionService{} + serviceDefinitionService.On("GetByID", "re", "654321").Return( + metadata.ServiceDefinition{}, + apperrors.NotFound("Service with ID %d not found", 654321), + ) + + metadataHandler := NewMetadataHandler(nil, serviceDefinitionService) + + req, err := http.NewRequest(http.MethodGet, "/re/v1/metadata/services/654321", nil) + require.NoError(t, err) + + req = mux.SetURLVars(req, map[string]string{"remoteEnvironment": "re", "serviceId": "654321"}) + rr := httptest.NewRecorder() + + // when + metadataHandler.GetService(rr, req) + + // then + require.NoError(t, err) + serviceDefinitionService.AssertCalled(t, "GetByID", "re", "654321") + assert.Equal(t, http.StatusNotFound, rr.Code) + }) +} + +func TestMetadataHandler_GetServices(t *testing.T) { + t.Run("should return list of available services", func(t *testing.T) { + // given + serviceDefinitionService := &metadataMock.ServiceDefinitionService{} + serviceDefinitionService.On("GetAll", "re").Return([]metadata.ServiceDefinition{{ + Name: "service name", + Provider: "service provider", + Description: "service description", + }}, nil) + + metadataHandler := NewMetadataHandler(nil, serviceDefinitionService) + + req, err := http.NewRequest(http.MethodGet, "/re/v1/metadata/services", nil) + require.NoError(t, err) + req = mux.SetURLVars(req, map[string]string{"remoteEnvironment": "re"}) + rr := httptest.NewRecorder() + + // when + metadataHandler.GetServices(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var services []Service + json.Unmarshal(responseBody, &services) + + require.NoError(t, err) + serviceDefinitionService.AssertExpectations(t) + assert.Len(t, services, 1) + assert.Equal(t, "service name", services[0].Name) + assert.Equal(t, "service provider", services[0].Provider) + assert.Equal(t, "service description", services[0].Description) + assert.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("should empty list when no services found", func(t *testing.T) { + // given + var empty []metadata.ServiceDefinition + serviceDefinitionService := &metadataMock.ServiceDefinitionService{} + serviceDefinitionService.On("GetAll", "re").Return(empty, nil) + + metadataHandler := NewMetadataHandler(nil, serviceDefinitionService) + + req, err := http.NewRequest(http.MethodGet, "/re/v1/metadata/services", nil) + require.NoError(t, err) + + req = mux.SetURLVars(req, map[string]string{"remoteEnvironment": "re"}) + rr := httptest.NewRecorder() + + // when + metadataHandler.GetServices(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var services []Service + json.Unmarshal(responseBody, &services) + + require.NoError(t, err) + serviceDefinitionService.AssertExpectations(t) + assert.Len(t, services, 0) + assert.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("should handle internal errors", func(t *testing.T) { + // given + serviceDefinitionService := &metadataMock.ServiceDefinitionService{} + serviceDefinitionService.On("GetAll", "re").Return(nil, apperrors.Internal("")) + + metadataHandler := NewMetadataHandler(nil, serviceDefinitionService) + + req, err := http.NewRequest(http.MethodGet, "/re/v1/metadata/services", nil) + require.NoError(t, err) + + req = mux.SetURLVars(req, map[string]string{"remoteEnvironment": "re"}) + rr := httptest.NewRecorder() + + // when + metadataHandler.GetServices(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var errorResponse httperrors.ErrorResponse + json.Unmarshal(responseBody, &errorResponse) + + require.NoError(t, err) + assert.Equal(t, http.StatusInternalServerError, errorResponse.Code) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + serviceDefinitionService.AssertExpectations(t) + }) +} + +func TestMetadataHandler_UpdateService(t *testing.T) { + t.Run("should update a service", func(t *testing.T) { + // given + serviceDetails := ServiceDetails{ + Name: "service name", + Provider: "service provider", + Description: "service description", + Api: &API{ + TargetUrl: "http://service.com", + Credentials: &Credentials{ + Oauth: Oauth{ + URL: "http://oauth.com", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + Spec: apiRawSpec, + }, + Events: &Events{ + Spec: eventsRawSpec, + }, + Documentation: &Documentation{ + DisplayName: "documentation name", + Description: "documentation description", + Type: "documentation type", + Docs: []DocsObject{{Title: "doc title", Type: "doc type", Source: "doc source"}}, + }, + } + + serviceDefinition := &metadata.ServiceDefinition{ + Name: "service name", + Provider: "service provider", + Description: "service description", + Api: &serviceapi.API{ + TargetUrl: "http://service.com", + Credentials: &serviceapi.Credentials{ + Oauth: serviceapi.Oauth{ + URL: "http://oauth.com", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + Spec: apiRawSpec, + }, + Events: &metadata.Events{ + Spec: eventsRawSpec, + }, + Documentation: documentationRaw, + } + + validator := ServiceDetailsValidatorFunc(func(sd ServiceDetails) apperrors.AppError { + return nil + }) + serviceDefinitionService := &metadataMock.ServiceDefinitionService{} + serviceDefinitionService.On("Update", "re", "1234", serviceDefinition).Return(nil) + + metadataHandler := NewMetadataHandler(validator, serviceDefinitionService) + + serviceDetailsData, err := json.Marshal(serviceDetails) + require.NoError(t, err) + + req, err := http.NewRequest(http.MethodPut, "/re/v1/metadata/services/1234", bytes.NewReader(serviceDetailsData)) + require.NoError(t, err) + + req = mux.SetURLVars(req, map[string]string{"remoteEnvironment": "re", "serviceId": "1234"}) + rr := httptest.NewRecorder() + + // when + metadataHandler.UpdateService(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var serviceDetailsResponse ServiceDetails + json.Unmarshal(responseBody, &serviceDetailsResponse) + + assert.Equal(t, "service name", serviceDetailsResponse.Name) + assert.Equal(t, "service provider", serviceDetailsResponse.Provider) + assert.Equal(t, "service description", serviceDetailsResponse.Description) + assert.Equal(t, "http://service.com", serviceDetailsResponse.Api.TargetUrl) + assert.Equal(t, "http://oauth.com", serviceDetailsResponse.Api.Credentials.Oauth.URL) + assert.Equal(t, stars, serviceDetailsResponse.Api.Credentials.Oauth.ClientID) + assert.Equal(t, stars, serviceDetailsResponse.Api.Credentials.Oauth.ClientSecret) + assert.Equal(t, apiSpec, raw2Json(t, serviceDetailsResponse.Api.Spec)) + assert.Equal(t, eventsSpec, raw2Json(t, serviceDetailsResponse.Events.Spec)) + assert.Equal(t, "documentation name", serviceDetailsResponse.Documentation.DisplayName) + assert.Equal(t, "documentation description", serviceDetailsResponse.Documentation.Description) + assert.Equal(t, "documentation type", serviceDetailsResponse.Documentation.Type) + assert.Len(t, serviceDetailsResponse.Documentation.Docs, 1) + assert.Equal(t, DocsObject{Title: "doc title", Type: "doc type", Source: "doc source"}, serviceDetailsResponse.Documentation.Docs[0]) + + assert.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("should return 400 when validation fails", func(t *testing.T) { + // given + serviceDetails := ServiceDetails{} + + validator := ServiceDetailsValidatorFunc(func(sd ServiceDetails) apperrors.AppError { + return apperrors.WrongInput("failed") + }) + serviceDefinitionService := &metadataMock.ServiceDefinitionService{} + + metadataHandler := NewMetadataHandler(validator, serviceDefinitionService) + + serviceDetailsData, err := json.Marshal(serviceDetails) + require.NoError(t, err) + + req, err := http.NewRequest(http.MethodPut, "/re/v1/metadata/services/1234", bytes.NewReader(serviceDetailsData)) + require.NoError(t, err) + + req = mux.SetURLVars(req, map[string]string{"remoteEnvironment": "re", "serviceId": "1234"}) + rr := httptest.NewRecorder() + + // when + metadataHandler.UpdateService(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var errorResponse httperrors.ErrorResponse + json.Unmarshal(responseBody, &errorResponse) + + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, errorResponse.Code) + assert.Equal(t, http.StatusBadRequest, rr.Code) + serviceDefinitionService.AssertNotCalled(t, "Update", mock.Anything, mock.Anything) + }) + + t.Run("should handle internal errors", func(t *testing.T) { + // given + serviceDetails := ServiceDetails{ + Name: "service name", + Provider: "service provider", + Description: "service description", + } + + validator := ServiceDetailsValidatorFunc(func(sd ServiceDetails) apperrors.AppError { + return nil + }) + serviceDefinitionService := &metadataMock.ServiceDefinitionService{} + serviceDefinitionService.On("Update", "re", "1234", mock.Anything).Return(apperrors.Internal("")) + + metadataHandler := NewMetadataHandler(validator, serviceDefinitionService) + + serviceDetailsData, err := json.Marshal(serviceDetails) + require.NoError(t, err) + + req, err := http.NewRequest(http.MethodPut, "/re/v1/metadata/services/1234", bytes.NewReader(serviceDetailsData)) + require.NoError(t, err) + + req = mux.SetURLVars(req, map[string]string{"remoteEnvironment": "re", "serviceId": "1234"}) + rr := httptest.NewRecorder() + + // when + metadataHandler.UpdateService(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var errorResponse httperrors.ErrorResponse + json.Unmarshal(responseBody, &errorResponse) + + require.NoError(t, err) + assert.Equal(t, http.StatusInternalServerError, errorResponse.Code) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + }) +} + +func TestMetadataHandler_DeleteService(t *testing.T) { + t.Run("should delete service", func(t *testing.T) { + // given + serviceDefinitionService := &metadataMock.ServiceDefinitionService{} + serviceDefinitionService.On("Delete", "re", "1234").Return(nil) + + metadataHandler := NewMetadataHandler(nil, serviceDefinitionService) + + req, err := http.NewRequest(http.MethodDelete, "/re/v1/metadata/services/1234", nil) + require.NoError(t, err) + + req = mux.SetURLVars(req, map[string]string{"remoteEnvironment": "re", "serviceId": "1234"}) + rr := httptest.NewRecorder() + + // when + metadataHandler.DeleteService(rr, req) + + // then + assert.Equal(t, http.StatusNoContent, rr.Code) + }) + + t.Run("should handle errors when deleting service", func(t *testing.T) { + // given + serviceDefinitionService := &metadataMock.ServiceDefinitionService{} + serviceDefinitionService.On("Delete", "re", "1234").Return(apperrors.Internal("error")) + + metadataHandler := NewMetadataHandler(nil, serviceDefinitionService) + + req, err := http.NewRequest(http.MethodDelete, "/re/v1/metadata/services/1234", nil) + require.NoError(t, err) + + req = mux.SetURLVars(req, map[string]string{"remoteEnvironment": "re", "serviceId": "1234"}) + rr := httptest.NewRecorder() + + // when + metadataHandler.DeleteService(rr, req) + + // then + assert.Equal(t, http.StatusInternalServerError, rr.Code) + }) +} + +func raw2Json(t *testing.T, rawMsg json.RawMessage) testSpec { + spec := testSpec{} + err := json.Unmarshal(rawMsg, &spec) + require.NoError(t, err) + return spec +} diff --git a/components/application-connector/internal/externalapi/model.go b/components/application-connector/internal/externalapi/model.go new file mode 100644 index 000000000000..c85ad0b66340 --- /dev/null +++ b/components/application-connector/internal/externalapi/model.go @@ -0,0 +1,157 @@ +package externalapi + +import ( + "encoding/json" + + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/serviceapi" +) + +type Service struct { + ID string `json:"id"` + Provider string `json:"provider"` + Name string `json:"name"` + Description string `json:"description"` +} + +type ServiceDetails struct { + Provider string `json:"provider" valid:"required~Provider field cannot be empty."` + Name string `json:"name" valid:"required~Name field cannot be empty."` + Description string `json:"description" valid:"required~Description field cannot be empty."` + Api *API `json:"api,omitempty"` + Events *Events `json:"events,omitempty"` + Documentation *Documentation `json:"documentation,omitempty"` +} + +type CreateServiceResponse struct { + ID string `json:"id"` +} + +type API struct { + TargetUrl string `json:"targetUrl" valid:"url,required~targetUrl field cannot be empty."` + Credentials *Credentials `json:"credentials,omitempty"` + Spec json.RawMessage `json:"spec,omitempty"` +} + +type Credentials struct { + Oauth Oauth `json:"oauth" valid:"required~oauth field cannot be empty"` +} + +type Oauth struct { + URL string `json:"url" valid:"url,required~oauth url field cannot be empty"` + ClientID string `json:"clientId" valid:"required~oauth clientId field cannot be empty"` + ClientSecret string `json:"clientSecret" valid:"required~oauth clientSecret cannot be empty"` +} + +type Events struct { + Spec json.RawMessage `json:"spec" valid:"required~spec cannot be empty"` +} + +type Documentation struct { + DisplayName string `json:"displayName" valid:"required~displayName field cannot be empty in documentation"` + Description string `json:"description" valid:"required~description field cannot be empty in documentation"` + Type string `json:"type" valid:"required~type field cannot be empty in documentation"` + Tags []string `json:"tags,omitempty"` + Docs []DocsObject `json:"docs,omitempty"` +} + +type DocsObject struct { + Title string `json:"title"` + Type string `json:"type"` + Source string `json:"source"` +} + +const stars = "********" + +func serviceDefinitionToService(serviceDefinition metadata.ServiceDefinition) Service { + return Service{ + ID: serviceDefinition.ID, + Name: serviceDefinition.Name, + Provider: serviceDefinition.Provider, + Description: serviceDefinition.Description, + } +} + +func serviceDefinitionToServiceDetails(serviceDefinition metadata.ServiceDefinition) (ServiceDetails, apperrors.AppError) { + serviceDetails := ServiceDetails{ + Provider: serviceDefinition.Provider, + Name: serviceDefinition.Name, + Description: serviceDefinition.Description, + } + + if serviceDefinition.Api != nil { + serviceDetails.Api = &API{ + TargetUrl: serviceDefinition.Api.TargetUrl, + Spec: serviceDefinition.Api.Spec, + } + + if serviceDefinition.Api.Credentials != nil { + serviceDetails.Api.Credentials = &Credentials{ + Oauth: Oauth{ + ClientID: stars, + ClientSecret: stars, + URL: serviceDefinition.Api.Credentials.Oauth.URL, + }, + } + } + } + + if serviceDefinition.Events != nil { + serviceDetails.Events = &Events{ + Spec: serviceDefinition.Events.Spec, + } + } + + if serviceDefinition.Documentation != nil { + err := json.Unmarshal(serviceDefinition.Documentation, &serviceDetails.Documentation) + if err != nil { + return serviceDetails, apperrors.Internal("failed to unmarshal documentation, '%s'", err) + } + + } + + return serviceDetails, nil +} + +func serviceDetailsToServiceDefinition(serviceDetails ServiceDetails) (metadata.ServiceDefinition, apperrors.AppError) { + serviceDefinition := metadata.ServiceDefinition{ + Provider: serviceDetails.Provider, + Name: serviceDetails.Name, + Description: serviceDetails.Description, + } + + if serviceDetails.Api != nil { + serviceDefinition.Api = &serviceapi.API{ + TargetUrl: serviceDetails.Api.TargetUrl, + } + if serviceDetails.Api.Credentials != nil { + serviceDefinition.Api.Credentials = &serviceapi.Credentials{ + Oauth: serviceapi.Oauth{ + ClientID: serviceDetails.Api.Credentials.Oauth.ClientID, + ClientSecret: serviceDetails.Api.Credentials.Oauth.ClientSecret, + URL: serviceDetails.Api.Credentials.Oauth.URL, + }, + } + } + if serviceDetails.Api.Spec != nil { + serviceDefinition.Api.Spec = compact(serviceDetails.Api.Spec) + } + } + + if serviceDetails.Events != nil && serviceDetails.Events.Spec != nil { + serviceDefinition.Events = &metadata.Events{ + Spec: compact(serviceDetails.Events.Spec), + } + } + + if serviceDetails.Documentation != nil { + marshalled, err := json.Marshal(&serviceDetails.Documentation) + if err != nil { + return serviceDefinition, apperrors.WrongInput("failed to marshal documentation, '%s'", err) + } + serviceDefinition.Documentation = marshalled + } + + return serviceDefinition, nil +} diff --git a/components/application-connector/internal/externalapi/servicedetailsvalidation.go b/components/application-connector/internal/externalapi/servicedetailsvalidation.go new file mode 100644 index 000000000000..5de9e5d19930 --- /dev/null +++ b/components/application-connector/internal/externalapi/servicedetailsvalidation.go @@ -0,0 +1,71 @@ +package externalapi + +import ( + "encoding/json" + + "github.com/asaskevich/govalidator" + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" +) + +type ServiceDetailsValidator interface { + Validate(details ServiceDetails) apperrors.AppError +} + +type ServiceDetailsValidatorFunc func(details ServiceDetails) apperrors.AppError + +func (f ServiceDetailsValidatorFunc) Validate(details ServiceDetails) apperrors.AppError { + return f(details) +} + +func NewServiceDetailsValidator() ServiceDetailsValidator { + return ServiceDetailsValidatorFunc(func(details ServiceDetails) apperrors.AppError { + _, err := govalidator.ValidateStruct(details) + if err != nil { + return apperrors.WrongInput("incorrect service definition: %s", err.Error()) + } + + if details.Api == nil && details.Events == nil { + return apperrors.WrongInput( + "incorrect service definition: at least one of 'api' and 'events' attributes must be provided") + } + + apperr := validateApiSpec(details.Api) + if apperr != nil { + return apperr + } + + apperr = validateEventsSpec(details.Events) + if apperr != nil { + return apperr + } + + return nil + }) +} + +func validateApiSpec(api *API) apperrors.AppError { + if api != nil && api.Spec != nil { + err := validateSpec(api.Spec) + if err != nil { + return apperrors.WrongInput("api.spec field should be a json object") + } + } + + return nil +} + +func validateEventsSpec(events *Events) apperrors.AppError { + if events != nil && events.Spec != nil { + err := validateSpec(events.Spec) + if err != nil { + return apperrors.WrongInput("events.spec field should be a json object") + } + } + + return nil +} + +func validateSpec(rawMessage json.RawMessage) error { + var m map[string]*json.RawMessage + return json.Unmarshal(rawMessage, &m) +} diff --git a/components/application-connector/internal/externalapi/servicedetailsvalidation_test.go b/components/application-connector/internal/externalapi/servicedetailsvalidation_test.go new file mode 100644 index 000000000000..07c01ed37ac1 --- /dev/null +++ b/components/application-connector/internal/externalapi/servicedetailsvalidation_test.go @@ -0,0 +1,347 @@ +package externalapi + +import ( + "testing" + + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + "github.com/stretchr/testify/assert" +) + +func TestServiceDetailsValidator(t *testing.T) { + t.Run("should accept service details with API", func(t *testing.T) { + // given + serviceDetails := ServiceDetails{ + Name: "name", + Provider: "provider", + Description: "description", + Api: &API{ + TargetUrl: "http://target.com", + }, + } + + validator := NewServiceDetailsValidator() + + // when + err := validator.Validate(serviceDetails) + + // then + assert.NoError(t, err) + }) + + t.Run("should accept service details with events", func(t *testing.T) { + // given + serviceDetails := ServiceDetails{ + Name: "name", + Provider: "provider", + Description: "description", + Events: &Events{ + Spec: eventsRawSpec, + }, + } + + validator := NewServiceDetailsValidator() + + // when + err := validator.Validate(serviceDetails) + + // then + assert.NoError(t, err) + }) + + t.Run("should accept service details with API and events", func(t *testing.T) { + // given + serviceDetails := ServiceDetails{ + Name: "name", + Provider: "provider", + Description: "description", + Api: &API{ + TargetUrl: "http://target.com", + }, + Events: &Events{ + Spec: eventsRawSpec, + }, + } + + validator := NewServiceDetailsValidator() + + // when + err := validator.Validate(serviceDetails) + + // then + assert.NoError(t, err) + }) + + t.Run("should not accept service details without API and Events", func(t *testing.T) { + // given + serviceDetails := ServiceDetails{ + Name: "name", + Provider: "provider", + Description: "description", + } + + validator := NewServiceDetailsValidator() + + // when + err := validator.Validate(serviceDetails) + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeWrongInput, err.Code()) + }) + + t.Run("should not accept service details without name", func(t *testing.T) { + // given + serviceDetails := ServiceDetails{ + Provider: "provider", + Description: "description", + Api: &API{ + TargetUrl: "http://target.com", + }, + } + + validator := NewServiceDetailsValidator() + + // when + err := validator.Validate(serviceDetails) + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeWrongInput, err.Code()) + }) + + t.Run("should not accept service details without provider", func(t *testing.T) { + // given + serviceDetails := ServiceDetails{ + Name: "name", + Description: "description", + Api: &API{ + TargetUrl: "http://target.com", + }, + } + + validator := NewServiceDetailsValidator() + + // when + err := validator.Validate(serviceDetails) + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeWrongInput, err.Code()) + }) + + t.Run("should not accept service details without description", func(t *testing.T) { + // given + serviceDetails := ServiceDetails{ + Name: "name", + Provider: "provider", + Api: &API{ + TargetUrl: "http://target.com", + }, + } + + validator := NewServiceDetailsValidator() + + // when + err := validator.Validate(serviceDetails) + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeWrongInput, err.Code()) + }) +} + +func TestServiceDetailsValidator_API(t *testing.T) { + t.Run("should not accept API without targetUrl", func(t *testing.T) { + // given + serviceDetails := ServiceDetails{ + Name: "name", + Provider: "provider", + Description: "description", + Api: &API{}, + } + + validator := NewServiceDetailsValidator() + + // when + err := validator.Validate(serviceDetails) + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeWrongInput, err.Code()) + }) + + t.Run("should not accept empty API credentials", func(t *testing.T) { + // given + serviceDetails := ServiceDetails{ + Name: "name", + Provider: "provider", + Description: "description", + Api: &API{ + TargetUrl: "http://target.com", + Credentials: &Credentials{}, + }, + } + + validator := NewServiceDetailsValidator() + + // when + err := validator.Validate(serviceDetails) + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeWrongInput, err.Code()) + }) + + t.Run("should not accept API spec other than json object", func(t *testing.T) { + // given + serviceDetails := ServiceDetails{ + Name: "name", + Provider: "provider", + Description: "description", + Api: &API{ + TargetUrl: "http://target.com", + Spec: []byte("\"{\\\"wrong_string_json_object\\\":true}\""), + }, + } + + validator := NewServiceDetailsValidator() + + // when + err := validator.Validate(serviceDetails) + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeWrongInput, err.Code()) + }) +} + +func TestServiceDetailsValidator_API_OAuth(t *testing.T) { + t.Run("should accept OAuth credentials", func(t *testing.T) { + // given + serviceDetails := ServiceDetails{ + Name: "name", + Provider: "provider", + Description: "description", + Api: &API{ + TargetUrl: "http://target.com", + Credentials: &Credentials{ + Oauth: Oauth{ + URL: "http://test.com/token", + ClientID: "client", + ClientSecret: "secret", + }, + }, + }, + } + + validator := NewServiceDetailsValidator() + + // when + err := validator.Validate(serviceDetails) + + // then + assert.NoError(t, err) + }) + + t.Run("should not accept OAuth credentials with empty oauth", func(t *testing.T) { + // given + serviceDetails := ServiceDetails{ + Name: "name", + Provider: "provider", + Description: "description", + Api: &API{ + TargetUrl: "http://target.com", + Credentials: &Credentials{ + Oauth: Oauth{}, + }, + }, + } + + validator := NewServiceDetailsValidator() + + // when + err := validator.Validate(serviceDetails) + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeWrongInput, err.Code()) + }) + + t.Run("should not accept OAuth credentials with incomplete oauth", func(t *testing.T) { + // given + serviceDetails := ServiceDetails{ + Name: "name", + Provider: "provider", + Description: "description", + Api: &API{ + TargetUrl: "http://target.com", + Credentials: &Credentials{ + Oauth: Oauth{ + URL: "http://test.com/token", + ClientID: "client", + }, + }, + }, + } + + validator := NewServiceDetailsValidator() + + // when + err := validator.Validate(serviceDetails) + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeWrongInput, err.Code()) + }) + + t.Run("should not accept OAuth credentials with wrong oauth url", func(t *testing.T) { + // given + serviceDetails := ServiceDetails{ + Name: "name", + Provider: "provider", + Description: "description", + Api: &API{ + TargetUrl: "http://target.com", + Credentials: &Credentials{ + Oauth: Oauth{ + URL: "test_com/token", + ClientID: "client", + ClientSecret: "secret", + }, + }, + }, + } + + validator := NewServiceDetailsValidator() + + // when + err := validator.Validate(serviceDetails) + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeWrongInput, err.Code()) + }) +} + +func TestServiceDetailsValidator_Events(t *testing.T) { + t.Run("should not accept events spec other than json object", func(t *testing.T) { + // given + serviceDetails := ServiceDetails{ + Name: "name", + Provider: "provider", + Description: "description", + Events: &Events{ + Spec: []byte("\"{\\\"wrong_string_json_object\\\":true}\""), + }, + } + + validator := NewServiceDetailsValidator() + + // when + err := validator.Validate(serviceDetails) + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeWrongInput, err.Code()) + }) +} diff --git a/components/application-connector/internal/httpconsts/httpconsts.go b/components/application-connector/internal/httpconsts/httpconsts.go new file mode 100644 index 000000000000..15c15bc07eb2 --- /dev/null +++ b/components/application-connector/internal/httpconsts/httpconsts.go @@ -0,0 +1,14 @@ +package httpconsts + +const ( + HeaderXForwardedFor = "X-Forwarded-For" + HeaderConnection = "Connection" + HeaderContentType = "Content-Type" + HeaderAuthorization = "Authorization" + HeaderOccToken = "Occ-Token" +) + +const ( + ContentTypeApplicationJson = "application/json;charset=UTF-8" + ContentTypeApplicationURLEncoded = "application/x-www-form-urlencoded" +) diff --git a/components/application-connector/internal/httperrors/httperrors.go b/components/application-connector/internal/httperrors/httperrors.go new file mode 100644 index 000000000000..2e4da14e63bc --- /dev/null +++ b/components/application-connector/internal/httperrors/httperrors.go @@ -0,0 +1,34 @@ +package httperrors + +import ( + "net/http" + + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" +) + +type ErrorResponse struct { + Code int `json:"code"` + Error string `json:"error"` +} + +func errorCodeToHttpStatus(code int) int { + switch code { + case apperrors.CodeInternal: + return http.StatusInternalServerError + case apperrors.CodeNotFound: + return http.StatusNotFound + case apperrors.CodeAlreadyExists: + return http.StatusConflict + case apperrors.CodeWrongInput: + return http.StatusBadRequest + case apperrors.CodeUpstreamServerCallFailed: + return http.StatusBadGateway + default: + return http.StatusInternalServerError + } +} + +func AppErrorToResponse(appError apperrors.AppError) (status int, body ErrorResponse) { + httpCode := errorCodeToHttpStatus(appError.Code()) + return httpCode, ErrorResponse{httpCode, appError.Error()} +} diff --git a/components/application-connector/internal/httptools/logging.go b/components/application-connector/internal/httptools/logging.go new file mode 100644 index 000000000000..16963b407e89 --- /dev/null +++ b/components/application-connector/internal/httptools/logging.go @@ -0,0 +1,66 @@ +package httptools + +import ( + "net/http" + "net/http/httputil" + "time" + + "github.com/gorilla/mux" + log "github.com/sirupsen/logrus" +) + +func RequestLogger(label string, h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + lw := newLoggingResponseWriter(w) + + h.ServeHTTP(lw, r) + + method := r.Method + fullPath := r.RequestURI + if fullPath == "" { + fullPath = r.URL.RequestURI() + } + proto := r.Proto + responseCode := lw.status + duration := time.Since(lw.start).Nanoseconds() / int64(time.Millisecond) + + log.Infof("%s: %s %s %s %d %d", label, method, fullPath, proto, responseCode, duration) + }) +} + +type loggingResponseWriter struct { + http.ResponseWriter + status int + start time.Time +} + +func newLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter { + return &loggingResponseWriter{ResponseWriter: w, start: time.Now()} +} + +func (w *loggingResponseWriter) WriteHeader(statusCode int) { + w.ResponseWriter.WriteHeader(statusCode) + w.status = statusCode +} + +func ContextLogger(r *http.Request) *log.Entry { + return log.WithField("remote environment", mux.Vars(r)["remoteEnvironment"]) +} + +func ContextLoggerWithId(r *http.Request) *log.Entry { + reName := mux.Vars(r)["remoteEnvironment"] + serviceId := mux.Vars(r)["serviceId"] + + fields := map[string]interface{}{"remote environment": reName, "service ID": serviceId} + + return log.WithFields(fields) +} + +func DumpRequestToLog(r *http.Request, logger *log.Entry) { + b, err := httputil.DumpRequest(r, false) + if err != nil { + logger.Errorf("Failed to log request.") + return + } + logger.Infof("%s", b) +} diff --git a/components/application-connector/internal/k8sconsts/k8sconsts.go b/components/application-connector/internal/k8sconsts/k8sconsts.go new file mode 100644 index 000000000000..931cc291ab48 --- /dev/null +++ b/components/application-connector/internal/k8sconsts/k8sconsts.go @@ -0,0 +1,7 @@ +package k8sconsts + +const ( + LabelRemoteEnvironment = "re" + LabelServiceId = "serviceId" + LabelApp = "app" +) diff --git a/components/application-connector/internal/k8sconsts/mocks/NameResolver.go b/components/application-connector/internal/k8sconsts/mocks/NameResolver.go new file mode 100644 index 000000000000..544a152bb6ae --- /dev/null +++ b/components/application-connector/internal/k8sconsts/mocks/NameResolver.go @@ -0,0 +1,51 @@ +// Code generated by mockery v1.0.0 +package mocks + +import mock "github.com/stretchr/testify/mock" + +// NameResolver is an autogenerated mock type for the NameResolver type +type NameResolver struct { + mock.Mock +} + +// ExtractServiceId provides a mock function with given fields: remoteEnvironment, host +func (_m *NameResolver) ExtractServiceId(remoteEnvironment string, host string) string { + ret := _m.Called(remoteEnvironment, host) + + var r0 string + if rf, ok := ret.Get(0).(func(string, string) string); ok { + r0 = rf(remoteEnvironment, host) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// GetMetadataUrl provides a mock function with given fields: remoteEnvironment, id +func (_m *NameResolver) GetMetadataUrl(remoteEnvironment string, id string) string { + ret := _m.Called(remoteEnvironment, id) + + var r0 string + if rf, ok := ret.Get(0).(func(string, string) string); ok { + r0 = rf(remoteEnvironment, id) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// GetResourceName provides a mock function with given fields: remoteEnvironment, id +func (_m *NameResolver) GetResourceName(remoteEnvironment string, id string) string { + ret := _m.Called(remoteEnvironment, id) + + var r0 string + if rf, ok := ret.Get(0).(func(string, string) string); ok { + r0 = rf(remoteEnvironment, id) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} diff --git a/components/application-connector/internal/k8sconsts/nameresolver.go b/components/application-connector/internal/k8sconsts/nameresolver.go new file mode 100644 index 000000000000..ea5c822a3cfd --- /dev/null +++ b/components/application-connector/internal/k8sconsts/nameresolver.go @@ -0,0 +1,70 @@ +package k8sconsts + +import ( + "fmt" + "strings" +) + +const ( + resourceNamePrefixFormat = "re-%s-" + metadataUrlFormat = "http://%s.%s.svc.cluster.local" + + maxResourceNameLength = 63 // Kubernetes limit for services + uuidLength = 36 // UUID has 36 characters +) + +// NameResolver provides names for Kubernetes resources +type NameResolver interface { + // GetResourceName returns resource name with given ID + GetResourceName(remoteEnvironment, id string) string + // GetMetadataUrl return gateway url with given ID + GetMetadataUrl(remoteEnvironment, id string) string + // ExtractServiceId extracts service ID from given host + ExtractServiceId(remoteEnvironment, host string) string +} + +type nameResolver struct { + namespace string +} + +// NewNameResolver creates NameResolver that uses remote environment name and namespace. +func NewNameResolver(namespace string) NameResolver { + return nameResolver{ + namespace: namespace, + } +} + +// GetResourceName returns resource name with given ID +func (resolver nameResolver) GetResourceName(remoteEnvironment, id string) string { + return getResourceNamePrefix(remoteEnvironment) + id +} + +// GetMetadataUrl return gateway url with given ID +func (resolver nameResolver) GetMetadataUrl(remoteEnvironment, id string) string { + return fmt.Sprintf(metadataUrlFormat, resolver.GetResourceName(remoteEnvironment, id), resolver.namespace) +} + +// ExtractServiceId extracts service ID from given host +func (resolver nameResolver) ExtractServiceId(remoteEnvironment, host string) string { + resourceName := strings.Split(host, ".")[0] + return strings.TrimPrefix(resourceName, getResourceNamePrefix(remoteEnvironment)) +} + +func getResourceNamePrefix(remoteEnvironment string) string { + truncatedRemoteEnvironment := truncateRemoteEnvironment(remoteEnvironment) + return fmt.Sprintf(resourceNamePrefixFormat, truncatedRemoteEnvironment) +} + +func truncateRemoteEnvironment(remoteEnvironment string) string { + maxResourceNamePrefixLength := maxResourceNameLength - uuidLength + testResourceNamePrefix := fmt.Sprintf(resourceNamePrefixFormat, remoteEnvironment) + testResourceNamePrefixLength := len(testResourceNamePrefix) + + overflowLength := testResourceNamePrefixLength - maxResourceNamePrefixLength + + if overflowLength > 0 { + newRemoteEnvironmentLength := len(remoteEnvironment) - overflowLength + return remoteEnvironment[0:newRemoteEnvironmentLength] + } + return remoteEnvironment +} diff --git a/components/application-connector/internal/k8sconsts/nameresolver_test.go b/components/application-connector/internal/k8sconsts/nameresolver_test.go new file mode 100644 index 000000000000..b57ed9a2c125 --- /dev/null +++ b/components/application-connector/internal/k8sconsts/nameresolver_test.go @@ -0,0 +1,69 @@ +package k8sconsts + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNameResolver(t *testing.T) { + testCases := []struct { + remotEnv string + id string + resourceName string + metadataUrl string + host string + }{ + { + remotEnv: "short_remoteenv", + id: "c687e68a-9038-4f38-845b-9c61592e59e6", + resourceName: "re-short_remoteenv-c687e68a-9038-4f38-845b-9c61592e59e6", + metadataUrl: "http://re-short_remoteenv-c687e68a-9038-4f38-845b-9c61592e59e6.namespace.svc.cluster.local", + host: "re-short_remoteenv-c687e68a-9038-4f38-845b-9c61592e59e6.namespace.svc.cluster.local", + }, + { + remotEnv: "max_remoteenv_aaaaaaaaa", + id: "c687e68a-9038-4f38-845b-9c61592e59e6", + resourceName: "re-max_remoteenv_aaaaaaaaa-c687e68a-9038-4f38-845b-9c61592e59e6", + metadataUrl: "http://re-max_remoteenv_aaaaaaaaa-c687e68a-9038-4f38-845b-9c61592e59e6.namespace.svc.cluster.local", + host: "re-max_remoteenv_aaaaaaaaa-c687e68a-9038-4f38-845b-9c61592e59e6.namespace.svc.cluster.local", + }, + { + remotEnv: "toolong_remoteenv_aaaaaxxxx", + id: "c687e68a-9038-4f38-845b-9c61592e59e6", + resourceName: "re-toolong_remoteenv_aaaaa-c687e68a-9038-4f38-845b-9c61592e59e6", + metadataUrl: "http://re-toolong_remoteenv_aaaaa-c687e68a-9038-4f38-845b-9c61592e59e6.namespace.svc.cluster.local", + host: "re-toolong_remoteenv_aaaaa-c687e68a-9038-4f38-845b-9c61592e59e6.namespace.svc.cluster.local", + }, + } + + t.Run("should get resource name with truncated remote environment name if needed", func(t *testing.T) { + for _, testCase := range testCases { + resolver := NewNameResolver("namespace") + + result := resolver.GetResourceName(testCase.remotEnv, testCase.id) + + assert.Equal(t, testCase.resourceName, result) + } + }) + + t.Run("should get gateway url with truncated remote environment name if needed", func(t *testing.T) { + for _, testCase := range testCases { + resolver := NewNameResolver("namespace") + + result := resolver.GetMetadataUrl(testCase.remotEnv, testCase.id) + + assert.Equal(t, testCase.metadataUrl, result) + } + }) + + t.Run("should extract service ID from gateway host", func(t *testing.T) { + for _, testCase := range testCases { + resolver := NewNameResolver("namespace") + + result := resolver.ExtractServiceId(testCase.remotEnv, testCase.host) + + assert.Equal(t, testCase.id, result) + } + }) +} diff --git a/components/application-connector/internal/metadata/accessservice/accessservicemanager.go b/components/application-connector/internal/metadata/accessservice/accessservicemanager.go new file mode 100644 index 000000000000..6cbbb574c17c --- /dev/null +++ b/components/application-connector/internal/metadata/accessservice/accessservicemanager.go @@ -0,0 +1,93 @@ +package accessservice + +import ( + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + "github.com/kyma-project/kyma/components/application-connector/internal/k8sconsts" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +// ServiceInterface has methods to work with Service resources. +type ServiceInterface interface { + Create(*corev1.Service) (*corev1.Service, error) + Delete(name string, options *metav1.DeleteOptions) error +} + +type AccessServiceManager interface { + Create(remoteEnvironment, serviceId, serviceName string) apperrors.AppError + Upsert(remoteEnvironment, serviceId, serviceName string) apperrors.AppError + Delete(serviceName string) apperrors.AppError +} + +type AccessServiceManagerConfig struct { + AppName string + TargetPort int32 +} + +type accessServiceManager struct { + serviceInterface ServiceInterface + config AccessServiceManagerConfig +} + +func NewAccessServiceManager(serviceInterface ServiceInterface, config AccessServiceManagerConfig) AccessServiceManager { + return &accessServiceManager{ + serviceInterface: serviceInterface, + config: config, + } +} + +func (m *accessServiceManager) Create(remoteEnvironment, serviceId, serviceName string) apperrors.AppError { + _, err := m.create(remoteEnvironment, serviceId, serviceName) + if err != nil { + return apperrors.Internal("failed to create service, %s", err) + } + return nil +} + +func (m *accessServiceManager) Upsert(remoteEnvironment, serviceId, serviceName string) apperrors.AppError { + _, err := m.create(remoteEnvironment, serviceId, serviceName) + if err != nil { + if k8serrors.IsAlreadyExists(err) { + return nil + } + return apperrors.Internal("failed to upsert service, %s", err) + } + return nil +} + +func (m *accessServiceManager) Delete(serviceName string) apperrors.AppError { + err := m.serviceInterface.Delete(serviceName, &metav1.DeleteOptions{}) + if err != nil { + if !k8serrors.IsNotFound(err) { + return apperrors.Internal("failed to delete service: %s", err) + } + } + return nil +} + +func (m *accessServiceManager) create(remoteEnvironment, serviceId, serviceName string) (*corev1.Service, error) { + service := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Labels: map[string]string{ + k8sconsts.LabelRemoteEnvironment: remoteEnvironment, + k8sconsts.LabelServiceId: serviceId, + }, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{k8sconsts.LabelApp: m.config.AppName}, + Ports: []corev1.ServicePort{ + { + Name: "http", + Protocol: corev1.ProtocolTCP, + Port: 80, + TargetPort: intstr.IntOrString{Type: intstr.Int, IntVal: m.config.TargetPort}, + }, + }, + }, + } + + return m.serviceInterface.Create(&service) +} diff --git a/components/application-connector/internal/metadata/accessservice/accessservicemanager_test.go b/components/application-connector/internal/metadata/accessservice/accessservicemanager_test.go new file mode 100644 index 000000000000..70544c90eb06 --- /dev/null +++ b/components/application-connector/internal/metadata/accessservice/accessservicemanager_test.go @@ -0,0 +1,191 @@ +package accessservice + +import ( + "errors" + "testing" + + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + "github.com/kyma-project/kyma/components/application-connector/internal/k8sconsts" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/accessservice/mocks" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/intstr" +) + +var config = AccessServiceManagerConfig{ + AppName: "metadata", + TargetPort: 8081, +} + +func TestAccessServiceManager_Create(t *testing.T) { + + t.Run("should create new access service", func(t *testing.T) { + // given + expectedService := mockService("metadata", "ec-default", "uuid-1", "service-uuid1", 8081) + serviceInterface := new(mocks.ServiceInterface) + serviceInterface.On("Create", expectedService).Return(expectedService, nil) + + manager := NewAccessServiceManager(serviceInterface, config) + + // when + err := manager.Create("ec-default", "uuid-1", "service-uuid1") + + // then + assert.NoError(t, err) + serviceInterface.AssertExpectations(t) + }) + + t.Run("should handle errors", func(t *testing.T) { + // given + expectedService := mockService("metadata", "ec-default", "uuid-1", "service-uuid1", 8081) + serviceInterface := new(mocks.ServiceInterface) + serviceInterface.On("Create", expectedService).Return(nil, errors.New("some error")) + + manager := NewAccessServiceManager(serviceInterface, config) + + // when + err := manager.Create("ec-default", "uuid-1", "service-uuid1") + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.NotEmpty(t, err.Error()) + + serviceInterface.AssertExpectations(t) + }) +} + +func TestAccessServiceManager_Upsert(t *testing.T) { + + t.Run("should create access service if not exists", func(t *testing.T) { + // given + expectedService := mockService("metadata", "ec-default", "uuid-1", "service-uuid1", 8081) + serviceInterface := new(mocks.ServiceInterface) + serviceInterface.On("Create", expectedService).Return(expectedService, nil) + + manager := NewAccessServiceManager(serviceInterface, config) + + // when + err := manager.Upsert("ec-default", "uuid-1", "service-uuid1") + + // then + assert.NoError(t, err) + serviceInterface.AssertExpectations(t) + }) + + t.Run("should not fail if access service exists", func(t *testing.T) { + // given + expectedService := mockService("metadata", "ec-default", "uuid-1", "service-uuid1", 8081) + serviceInterface := new(mocks.ServiceInterface) + serviceInterface.On("Create", expectedService).Return(nil, k8serrors.NewAlreadyExists(schema.GroupResource{}, "")) + + manager := NewAccessServiceManager(serviceInterface, config) + + // when + err := manager.Upsert("ec-default", "uuid-1", "service-uuid1") + + // then + assert.NoError(t, err) + serviceInterface.AssertExpectations(t) + }) + + t.Run("should handle errors", func(t *testing.T) { + // given + expectedService := mockService("metadata", "ec-default", "uuid-1", "service-uuid1", 8081) + serviceInterface := new(mocks.ServiceInterface) + serviceInterface.On("Create", expectedService).Return(nil, errors.New("some error")) + + manager := NewAccessServiceManager(serviceInterface, config) + + // when + err := manager.Upsert("ec-default", "uuid-1", "service-uuid1") + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.NotEmpty(t, err.Error()) + + serviceInterface.AssertExpectations(t) + }) +} + +func TestAccessServiceManager_Delete(t *testing.T) { + + t.Run("should delete access service", func(t *testing.T) { + // given + serviceInterface := new(mocks.ServiceInterface) + serviceInterface.On("Delete", "test-service", &metav1.DeleteOptions{}).Return(nil) + + manager := NewAccessServiceManager(serviceInterface, config) + + // when + err := manager.Delete("test-service") + + // then + assert.NoError(t, err) + + serviceInterface.AssertExpectations(t) + }) + + t.Run("should handle errors", func(t *testing.T) { + // given + serviceInterface := new(mocks.ServiceInterface) + serviceInterface.On("Delete", "test-service", &metav1.DeleteOptions{}).Return(errors.New("some error")) + + manager := NewAccessServiceManager(serviceInterface, config) + + // when + err := manager.Delete("test-service") + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.NotEmpty(t, err.Error()) + + serviceInterface.AssertExpectations(t) + }) + + t.Run("should ignore not found error", func(t *testing.T) { + // given + serviceInterface := new(mocks.ServiceInterface) + serviceInterface.On("Delete", "test-service", &metav1.DeleteOptions{}).Return(k8serrors.NewNotFound(schema.GroupResource{}, "an error")) + + manager := NewAccessServiceManager(serviceInterface, config) + + // when + err := manager.Delete("test-service") + + // then + assert.NoError(t, err) + + serviceInterface.AssertExpectations(t) + }) +} + +func mockService(appName, remoteEnvironment, serviceId, serviceName string, targetPort int32) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Labels: map[string]string{ + k8sconsts.LabelRemoteEnvironment: remoteEnvironment, + k8sconsts.LabelServiceId: serviceId, + }, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{ + k8sconsts.LabelApp: appName, + }, + Ports: []corev1.ServicePort{ + { + Name: "http", + Protocol: corev1.ProtocolTCP, + Port: 80, + TargetPort: intstr.IntOrString{Type: intstr.Int, IntVal: targetPort}, + }, + }, + }, + } +} diff --git a/components/application-connector/internal/metadata/accessservice/mocks/AccessServiceManager.go b/components/application-connector/internal/metadata/accessservice/mocks/AccessServiceManager.go new file mode 100644 index 000000000000..86a1b70d4408 --- /dev/null +++ b/components/application-connector/internal/metadata/accessservice/mocks/AccessServiceManager.go @@ -0,0 +1,58 @@ +// Code generated by mockery v1.0.0 +package mocks + +import apperrors "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" +import mock "github.com/stretchr/testify/mock" + +// AccessServiceManager is an autogenerated mock type for the AccessServiceManager type +type AccessServiceManager struct { + mock.Mock +} + +// Create provides a mock function with given fields: remoteEnvironment, serviceId, serviceName +func (_m *AccessServiceManager) Create(remoteEnvironment string, serviceId string, serviceName string) apperrors.AppError { + ret := _m.Called(remoteEnvironment, serviceId, serviceName) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string, string, string) apperrors.AppError); ok { + r0 = rf(remoteEnvironment, serviceId, serviceName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} + +// Delete provides a mock function with given fields: serviceName +func (_m *AccessServiceManager) Delete(serviceName string) apperrors.AppError { + ret := _m.Called(serviceName) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string) apperrors.AppError); ok { + r0 = rf(serviceName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} + +// Upsert provides a mock function with given fields: remoteEnvironment, serviceId, serviceName +func (_m *AccessServiceManager) Upsert(remoteEnvironment string, serviceId string, serviceName string) apperrors.AppError { + ret := _m.Called(remoteEnvironment, serviceId, serviceName) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string, string, string) apperrors.AppError); ok { + r0 = rf(remoteEnvironment, serviceId, serviceName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} diff --git a/components/application-connector/internal/metadata/accessservice/mocks/ServiceInterface.go b/components/application-connector/internal/metadata/accessservice/mocks/ServiceInterface.go new file mode 100644 index 000000000000..186dbf451ff5 --- /dev/null +++ b/components/application-connector/internal/metadata/accessservice/mocks/ServiceInterface.go @@ -0,0 +1,48 @@ +// Code generated by mockery v1.0.0 +package mocks + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +import mock "github.com/stretchr/testify/mock" +import v1 "k8s.io/api/core/v1" + +// ServiceInterface is an autogenerated mock type for the ServiceInterface type +type ServiceInterface struct { + mock.Mock +} + +// Create provides a mock function with given fields: _a0 +func (_m *ServiceInterface) Create(_a0 *v1.Service) (*v1.Service, error) { + ret := _m.Called(_a0) + + var r0 *v1.Service + if rf, ok := ret.Get(0).(func(*v1.Service) *v1.Service); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1.Service) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*v1.Service) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: name, options +func (_m *ServiceInterface) Delete(name string, options *metav1.DeleteOptions) error { + ret := _m.Called(name, options) + + var r0 error + if rf, ok := ret.Get(0).(func(string, *metav1.DeleteOptions) error); ok { + r0 = rf(name, options) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/components/application-connector/internal/metadata/istio/mocks/ChecknothingInterface.go b/components/application-connector/internal/metadata/istio/mocks/ChecknothingInterface.go new file mode 100644 index 000000000000..786287606dec --- /dev/null +++ b/components/application-connector/internal/metadata/istio/mocks/ChecknothingInterface.go @@ -0,0 +1,48 @@ +// Code generated by mockery v1.0.0 +package mocks + +import mock "github.com/stretchr/testify/mock" +import v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +import v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/apis/istio/v1alpha2" + +// ChecknothingInterface is an autogenerated mock type for the ChecknothingInterface type +type ChecknothingInterface struct { + mock.Mock +} + +// Create provides a mock function with given fields: _a0 +func (_m *ChecknothingInterface) Create(_a0 *v1alpha2.Checknothing) (*v1alpha2.Checknothing, error) { + ret := _m.Called(_a0) + + var r0 *v1alpha2.Checknothing + if rf, ok := ret.Get(0).(func(*v1alpha2.Checknothing) *v1alpha2.Checknothing); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1alpha2.Checknothing) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*v1alpha2.Checknothing) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: name, options +func (_m *ChecknothingInterface) Delete(name string, options *v1.DeleteOptions) error { + ret := _m.Called(name, options) + + var r0 error + if rf, ok := ret.Get(0).(func(string, *v1.DeleteOptions) error); ok { + r0 = rf(name, options) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/components/application-connector/internal/metadata/istio/mocks/DenierInterface.go b/components/application-connector/internal/metadata/istio/mocks/DenierInterface.go new file mode 100644 index 000000000000..333e3c08c993 --- /dev/null +++ b/components/application-connector/internal/metadata/istio/mocks/DenierInterface.go @@ -0,0 +1,48 @@ +// Code generated by mockery v1.0.0 +package mocks + +import mock "github.com/stretchr/testify/mock" +import v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +import v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/apis/istio/v1alpha2" + +// DenierInterface is an autogenerated mock type for the DenierInterface type +type DenierInterface struct { + mock.Mock +} + +// Create provides a mock function with given fields: _a0 +func (_m *DenierInterface) Create(_a0 *v1alpha2.Denier) (*v1alpha2.Denier, error) { + ret := _m.Called(_a0) + + var r0 *v1alpha2.Denier + if rf, ok := ret.Get(0).(func(*v1alpha2.Denier) *v1alpha2.Denier); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1alpha2.Denier) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*v1alpha2.Denier) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: name, options +func (_m *DenierInterface) Delete(name string, options *v1.DeleteOptions) error { + ret := _m.Called(name, options) + + var r0 error + if rf, ok := ret.Get(0).(func(string, *v1.DeleteOptions) error); ok { + r0 = rf(name, options) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/components/application-connector/internal/metadata/istio/mocks/Repository.go b/components/application-connector/internal/metadata/istio/mocks/Repository.go new file mode 100644 index 000000000000..b4de375562f0 --- /dev/null +++ b/components/application-connector/internal/metadata/istio/mocks/Repository.go @@ -0,0 +1,155 @@ +// Code generated by mockery v1.0.0 +package mocks + +import apperrors "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + +import mock "github.com/stretchr/testify/mock" + +// Repository is an autogenerated mock type for the Repository type +type Repository struct { + mock.Mock +} + +// CreateCheckNothing provides a mock function with given fields: remoteEnvironment, serviceId, name +func (_m *Repository) CreateCheckNothing(remoteEnvironment string, serviceId string, name string) apperrors.AppError { + ret := _m.Called(remoteEnvironment, serviceId, name) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string, string, string) apperrors.AppError); ok { + r0 = rf(remoteEnvironment, serviceId, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} + +// CreateDenier provides a mock function with given fields: remoteEnvironment, serviceId, name +func (_m *Repository) CreateDenier(remoteEnvironment string, serviceId string, name string) apperrors.AppError { + ret := _m.Called(remoteEnvironment, serviceId, name) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string, string, string) apperrors.AppError); ok { + r0 = rf(remoteEnvironment, serviceId, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} + +// CreateRule provides a mock function with given fields: remoteEnvironment, serviceId, name +func (_m *Repository) CreateRule(remoteEnvironment string, serviceId string, name string) apperrors.AppError { + ret := _m.Called(remoteEnvironment, serviceId, name) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string, string, string) apperrors.AppError); ok { + r0 = rf(remoteEnvironment, serviceId, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} + +// DeleteCheckNothing provides a mock function with given fields: name +func (_m *Repository) DeleteCheckNothing(name string) apperrors.AppError { + ret := _m.Called(name) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string) apperrors.AppError); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} + +// DeleteDenier provides a mock function with given fields: name +func (_m *Repository) DeleteDenier(name string) apperrors.AppError { + ret := _m.Called(name) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string) apperrors.AppError); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} + +// DeleteRule provides a mock function with given fields: name +func (_m *Repository) DeleteRule(name string) apperrors.AppError { + ret := _m.Called(name) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string) apperrors.AppError); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} + +// UpsertCheckNothing provides a mock function with given fields: remoteEnvironment, serviceId, name +func (_m *Repository) UpsertCheckNothing(remoteEnvironment string, serviceId string, name string) apperrors.AppError { + ret := _m.Called(remoteEnvironment, serviceId, name) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string, string, string) apperrors.AppError); ok { + r0 = rf(remoteEnvironment, serviceId, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} + +// UpsertDenier provides a mock function with given fields: remoteEnvironment, serviceId, name +func (_m *Repository) UpsertDenier(remoteEnvironment string, serviceId string, name string) apperrors.AppError { + ret := _m.Called(remoteEnvironment, serviceId, name) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string, string, string) apperrors.AppError); ok { + r0 = rf(remoteEnvironment, serviceId, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} + +// UpsertRule provides a mock function with given fields: remoteEnvironment, serviceId, name +func (_m *Repository) UpsertRule(remoteEnvironment string, serviceId string, name string) apperrors.AppError { + ret := _m.Called(remoteEnvironment, serviceId, name) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string, string, string) apperrors.AppError); ok { + r0 = rf(remoteEnvironment, serviceId, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} diff --git a/components/application-connector/internal/metadata/istio/mocks/RuleInterface.go b/components/application-connector/internal/metadata/istio/mocks/RuleInterface.go new file mode 100644 index 000000000000..219261608d9d --- /dev/null +++ b/components/application-connector/internal/metadata/istio/mocks/RuleInterface.go @@ -0,0 +1,48 @@ +// Code generated by mockery v1.0.0 +package mocks + +import mock "github.com/stretchr/testify/mock" +import v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +import v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/apis/istio/v1alpha2" + +// RuleInterface is an autogenerated mock type for the RuleInterface type +type RuleInterface struct { + mock.Mock +} + +// Create provides a mock function with given fields: _a0 +func (_m *RuleInterface) Create(_a0 *v1alpha2.Rule) (*v1alpha2.Rule, error) { + ret := _m.Called(_a0) + + var r0 *v1alpha2.Rule + if rf, ok := ret.Get(0).(func(*v1alpha2.Rule) *v1alpha2.Rule); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1alpha2.Rule) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*v1alpha2.Rule) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: name, options +func (_m *RuleInterface) Delete(name string, options *v1.DeleteOptions) error { + ret := _m.Called(name, options) + + var r0 error + if rf, ok := ret.Get(0).(func(string, *v1.DeleteOptions) error); ok { + r0 = rf(name, options) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/components/application-connector/internal/metadata/istio/mocks/Service.go b/components/application-connector/internal/metadata/istio/mocks/Service.go new file mode 100644 index 000000000000..7e7d75467f1c --- /dev/null +++ b/components/application-connector/internal/metadata/istio/mocks/Service.go @@ -0,0 +1,59 @@ +// Code generated by mockery v1.0.0 +package mocks + +import apperrors "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + +import mock "github.com/stretchr/testify/mock" + +// Service is an autogenerated mock type for the Service type +type Service struct { + mock.Mock +} + +// Create provides a mock function with given fields: remoteEnvironment, serviceId, resourceName +func (_m *Service) Create(remoteEnvironment string, serviceId string, resourceName string) apperrors.AppError { + ret := _m.Called(remoteEnvironment, serviceId, resourceName) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string, string, string) apperrors.AppError); ok { + r0 = rf(remoteEnvironment, serviceId, resourceName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} + +// Delete provides a mock function with given fields: resourceName +func (_m *Service) Delete(resourceName string) apperrors.AppError { + ret := _m.Called(resourceName) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string) apperrors.AppError); ok { + r0 = rf(resourceName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} + +// Upsert provides a mock function with given fields: remoteEnvironment, serviceId, resourceName +func (_m *Service) Upsert(remoteEnvironment string, serviceId string, resourceName string) apperrors.AppError { + ret := _m.Called(remoteEnvironment, serviceId, resourceName) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string, string, string) apperrors.AppError); ok { + r0 = rf(remoteEnvironment, serviceId, resourceName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} diff --git a/components/application-connector/internal/metadata/istio/repository.go b/components/application-connector/internal/metadata/istio/repository.go new file mode 100644 index 000000000000..2641fd31a2a0 --- /dev/null +++ b/components/application-connector/internal/metadata/istio/repository.go @@ -0,0 +1,227 @@ +package istio + +import ( + "fmt" + + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + "github.com/kyma-project/kyma/components/application-connector/internal/k8sconsts" + "github.com/kyma-project/kyma/components/application-connector/pkg/apis/istio/v1alpha2" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + matchTemplateFormat = `(destination.service == "%s.%s.svc.cluster.local") && (source.labels["%s"] != "true")` +) + +// RuleInterface allows to perform operations for Rules in kubernetes +type RuleInterface interface { + Create(*v1alpha2.Rule) (*v1alpha2.Rule, error) + Delete(name string, options *v1.DeleteOptions) error +} + +// ChecknothingInterface allows to perform operations for CheckNothings in kubernetes +type ChecknothingInterface interface { + Create(*v1alpha2.Checknothing) (*v1alpha2.Checknothing, error) + Delete(name string, options *v1.DeleteOptions) error +} + +// DenierInterface allows to perform operations for Deniers in kubernetes +type DenierInterface interface { + Create(*v1alpha2.Denier) (*v1alpha2.Denier, error) + Delete(name string, options *v1.DeleteOptions) error +} + +// Repository allows to perform various operations for Istio resources +type Repository interface { + // CreateDenier creates Denier + CreateDenier(remoteEnvironment, serviceId, name string) apperrors.AppError + // CreateCheckNothing creates CheckNothing + CreateCheckNothing(remoteEnvironment, serviceId, name string) apperrors.AppError + // CreateRule creates Rule + CreateRule(remoteEnvironment, serviceId, name string) apperrors.AppError + // UpserDenier creates or updates Denier + UpsertDenier(remoteEnvironment, serviceId, name string) apperrors.AppError + // UpsertCheckNothing creates or updates CheckNothing + UpsertCheckNothing(remoteEnvironment, serviceId, name string) apperrors.AppError + // UpsertRule creates or updates Rule + UpsertRule(remoteEnvironment, serviceId, name string) apperrors.AppError + // DeleteDenier deletes Denier + DeleteDenier(name string) apperrors.AppError + // DeleteCheckNothing deletes CheckNothing + DeleteCheckNothing(name string) apperrors.AppError + // DeleteRule deletes Rule + DeleteRule(name string) apperrors.AppError +} + +type RepositoryConfig struct { + Namespace string +} + +type repository struct { + ruleInterface RuleInterface + checknothingInterface ChecknothingInterface + denierInterface DenierInterface + config RepositoryConfig +} + +// NewRepository creates new repository with provided interfaces +func NewRepository(ruleInterface RuleInterface, checknothingInterface ChecknothingInterface, denierInterface DenierInterface, config RepositoryConfig) Repository { + return &repository{ + ruleInterface: ruleInterface, + checknothingInterface: checknothingInterface, + denierInterface: denierInterface, + config: config, + } +} + +// CreateDenier creates Denier +func (repo *repository) CreateDenier(remoteEnvironment, serviceId, name string) apperrors.AppError { + denier := repo.makeDenierObject(remoteEnvironment, serviceId, name) + + _, err := repo.denierInterface.Create(denier) + if err != nil { + return apperrors.Internal("failed to create denier with name %s, %s", name, err) + } + + return nil +} + +// CreateCheckNothing creates CheckNothing +func (repo *repository) CreateCheckNothing(remoteEnvironment, serviceId, name string) apperrors.AppError { + checkNothing := repo.makeCheckNothingObject(remoteEnvironment, serviceId, name) + + _, err := repo.checknothingInterface.Create(checkNothing) + if err != nil { + return apperrors.Internal("failed to create checknothing with name %s, %s", name, err) + } + return nil +} + +// CreateRule creates Rule +func (repo *repository) CreateRule(remoteEnvironment, serviceId, name string) apperrors.AppError { + rule := repo.makeRuleObject(remoteEnvironment, serviceId, name) + + _, err := repo.ruleInterface.Create(rule) + if err != nil { + return apperrors.Internal("failed to create rule with name %s, %s", name, err) + } + return nil +} + +// UpserDenier creates or updates Denier +func (repo *repository) UpsertDenier(remoteEnvironment, serviceId, name string) apperrors.AppError { + denier := repo.makeDenierObject(remoteEnvironment, serviceId, name) + + _, err := repo.denierInterface.Create(denier) + if err != nil && !k8serrors.IsAlreadyExists(err) { + return apperrors.Internal("failed to update %s denier, %s", name, err) + } + return nil +} + +// UpsertCheckNothing creates or updates CheckNothing +func (repo *repository) UpsertCheckNothing(remoteEnvironment, serviceId, name string) apperrors.AppError { + checkNothing := repo.makeCheckNothingObject(remoteEnvironment, serviceId, name) + + _, err := repo.checknothingInterface.Create(checkNothing) + if err != nil && !k8serrors.IsAlreadyExists(err) { + return apperrors.Internal("failed to update %s checknothing, %s", name, err) + } + return nil +} + +// UpsertRule creates or updates Rule +func (repo *repository) UpsertRule(remoteEnvironment, serviceId, name string) apperrors.AppError { + rule := repo.makeRuleObject(remoteEnvironment, serviceId, name) + + _, err := repo.ruleInterface.Create(rule) + if err != nil && !k8serrors.IsAlreadyExists(err) { + return apperrors.Internal("failed to update %s rule, %s", name, err) + } + return nil +} + +// DeleteDenier deletes Denier +func (repo *repository) DeleteDenier(name string) apperrors.AppError { + err := repo.denierInterface.Delete(name, nil) + if err != nil && !k8serrors.IsNotFound(err) { + return apperrors.Internal("failed to delete denier with name %s, %s", name, err) + } + return nil +} + +// DeleteCheckNothing deletes CheckNothing +func (repo *repository) DeleteCheckNothing(name string) apperrors.AppError { + err := repo.checknothingInterface.Delete(name, nil) + if err != nil && !k8serrors.IsNotFound(err) { + return apperrors.Internal("failed to delete checknothing with name %s, %s", name, err) + } + return nil +} + +// DeleteRule deletes Rule +func (repo *repository) DeleteRule(name string) apperrors.AppError { + err := repo.ruleInterface.Delete(name, nil) + if err != nil && !k8serrors.IsNotFound(err) { + return apperrors.Internal("failed to delete rule with name %s, %s", name, err) + } + return nil +} + +func (repo *repository) makeDenierObject(remoteEnvironment, serviceId, name string) *v1alpha2.Denier { + return &v1alpha2.Denier{ + ObjectMeta: v1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + k8sconsts.LabelRemoteEnvironment: remoteEnvironment, + k8sconsts.LabelServiceId: serviceId, + }, + }, + Spec: &v1alpha2.DenierSpec{ + Status: &v1alpha2.DenierStatus{ + Code: 7, + Message: "Not allowed", + }, + }, + } +} + +func (repo *repository) makeCheckNothingObject(remoteEnvironment, serviceId, name string) *v1alpha2.Checknothing { + return &v1alpha2.Checknothing{ + ObjectMeta: v1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + k8sconsts.LabelRemoteEnvironment: remoteEnvironment, + k8sconsts.LabelServiceId: serviceId, + }, + }, + } +} + +func (repo *repository) makeRuleObject(remoteEnvironment, serviceId, name string) *v1alpha2.Rule { + match := repo.matchExpression(name, repo.config.Namespace, name) + handlerName := name + ".denier" + instanceName := name + ".checknothing" + + return &v1alpha2.Rule{ + ObjectMeta: v1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + k8sconsts.LabelRemoteEnvironment: remoteEnvironment, + k8sconsts.LabelServiceId: serviceId, + }, + }, + Spec: &v1alpha2.RuleSpec{ + Match: match, + Actions: []v1alpha2.RuleAction{{ + Handler: handlerName, + Instances: []string{instanceName}, + }}, + }, + } +} + +func (repo *repository) matchExpression(serviceName, namespace, accessLabel string) string { + return fmt.Sprintf(matchTemplateFormat, serviceName, namespace, accessLabel) +} diff --git a/components/application-connector/internal/metadata/istio/repository_test.go b/components/application-connector/internal/metadata/istio/repository_test.go new file mode 100644 index 000000000000..5291e40eebb0 --- /dev/null +++ b/components/application-connector/internal/metadata/istio/repository_test.go @@ -0,0 +1,484 @@ +package istio + +import ( + "errors" + "testing" + + "github.com/kyma-project/kyma/components/application-connector/internal/k8sconsts" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/istio/mocks" + "github.com/kyma-project/kyma/components/application-connector/pkg/apis/istio/v1alpha2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var config = RepositoryConfig{Namespace: "testns"} + +func TestRepository_Create(t *testing.T) { + + t.Run("should create denier", func(t *testing.T) { + // given + expected := &v1alpha2.Denier{ + ObjectMeta: v1.ObjectMeta{ + Name: "re-test-uuid1", + Labels: map[string]string{ + k8sconsts.LabelRemoteEnvironment: "re", + k8sconsts.LabelServiceId: "sid", + }, + }, + Spec: &v1alpha2.DenierSpec{ + Status: &v1alpha2.DenierStatus{ + Code: 7, + Message: "Not allowed", + }, + }, + } + + denierInterface := new(mocks.DenierInterface) + denierInterface.On("Create", expected).Return(nil, nil) + + repository := NewRepository(nil, nil, denierInterface, config) + + // when + err := repository.CreateDenier("re", "sid", "re-test-uuid1") + + // then + assert.NoError(t, err) + denierInterface.AssertExpectations(t) + }) + + t.Run("should handle error when creating denier", func(t *testing.T) { + // given + denierInterface := new(mocks.DenierInterface) + denierInterface.On("Create", mock.AnythingOfType("*v1alpha2.Denier")). + Return(nil, errors.New("some error")) + + repository := NewRepository(nil, nil, denierInterface, config) + + // when + err := repository.CreateDenier("re", "sid", "re-test-uuid1") + + // then + assert.Error(t, err) + assert.Contains(t, err.Error(), "some error") + }) + + t.Run("should create checknothing", func(t *testing.T) { + // given + expected := &v1alpha2.Checknothing{ + ObjectMeta: v1.ObjectMeta{ + Name: "re-test-uuid1", + Labels: map[string]string{ + k8sconsts.LabelRemoteEnvironment: "re", + k8sconsts.LabelServiceId: "sid", + }, + }, + } + + checknothingInterface := new(mocks.ChecknothingInterface) + checknothingInterface.On("Create", expected).Return(nil, nil) + + repository := NewRepository(nil, checknothingInterface, nil, config) + + // when + err := repository.CreateCheckNothing("re", "sid", "re-test-uuid1") + + // then + assert.NoError(t, err) + checknothingInterface.AssertExpectations(t) + }) + + t.Run("should handle error when creating checknothing", func(t *testing.T) { + // given + checknothingInterface := new(mocks.ChecknothingInterface) + checknothingInterface.On("Create", mock.AnythingOfType("*v1alpha2.Checknothing")). + Return(nil, errors.New("some error")) + + repository := NewRepository(nil, checknothingInterface, nil, config) + + // when + err := repository.CreateCheckNothing("re", "sid", "re-test-uuid1") + + // then + assert.Error(t, err) + assert.Contains(t, err.Error(), "some error") + }) + + t.Run("should create rule", func(t *testing.T) { + // given + expected := &v1alpha2.Rule{ + ObjectMeta: v1.ObjectMeta{ + Name: "re-test-uuid1", + Labels: map[string]string{ + k8sconsts.LabelRemoteEnvironment: "re", + k8sconsts.LabelServiceId: "sid", + }, + }, + Spec: &v1alpha2.RuleSpec{ + Match: `(destination.service == "re-test-uuid1.testns.svc.cluster.local") && (source.labels["re-test-uuid1"] != "true")`, + Actions: []v1alpha2.RuleAction{{ + Handler: "re-test-uuid1.denier", + Instances: []string{"re-test-uuid1.checknothing"}, + }}, + }, + } + + ruleInterface := new(mocks.RuleInterface) + ruleInterface.On("Create", expected).Return(nil, nil) + + repository := NewRepository(ruleInterface, nil, nil, config) + + // when + err := repository.CreateRule("re", "sid", "re-test-uuid1") + + // then + assert.NoError(t, err) + ruleInterface.AssertExpectations(t) + }) + + t.Run("should handle error when creating rule", func(t *testing.T) { + // given + ruleInterface := new(mocks.RuleInterface) + ruleInterface.On("Create", mock.AnythingOfType("*v1alpha2.Rule")). + Return(nil, errors.New("some error")) + + repository := NewRepository(ruleInterface, nil, nil, config) + + // when + err := repository.CreateRule("re", "sid", "re-test-uuid1") + + // then + assert.Error(t, err) + assert.Contains(t, err.Error(), "some error") + }) +} + +func TestRepository_Upsert(t *testing.T) { + + t.Run("should upsert denier", func(t *testing.T) { + // given + expected := &v1alpha2.Denier{ + ObjectMeta: v1.ObjectMeta{ + Name: "re-test-uuid1", + Labels: map[string]string{ + k8sconsts.LabelRemoteEnvironment: "re", + k8sconsts.LabelServiceId: "sid", + }, + }, + Spec: &v1alpha2.DenierSpec{ + Status: &v1alpha2.DenierStatus{ + Code: 7, + Message: "Not allowed", + }, + }, + } + + denierInterface := new(mocks.DenierInterface) + denierInterface.On("Create", expected).Return(nil, nil) + + repository := NewRepository(nil, nil, denierInterface, config) + + // when + err := repository.UpsertDenier("re", "sid", "re-test-uuid1") + + // then + assert.NoError(t, err) + denierInterface.AssertExpectations(t) + }) + + t.Run("should handle already exists error when upserting denier", func(t *testing.T) { + // given + denierInterface := new(mocks.DenierInterface) + denierInterface.On("Create", mock.AnythingOfType("*v1alpha2.Denier")). + Return(nil, k8serrors.NewAlreadyExists(schema.GroupResource{}, "")) + + repository := NewRepository(nil, nil, denierInterface, config) + + // when + err := repository.UpsertDenier("re", "sid", "re-test-uuid1") + + // then + assert.NoError(t, err) + denierInterface.AssertExpectations(t) + }) + + t.Run("should handle error when upserting denier", func(t *testing.T) { + // given + denierInterface := new(mocks.DenierInterface) + denierInterface.On("Create", mock.AnythingOfType("*v1alpha2.Denier")). + Return(nil, errors.New("some error")) + + repository := NewRepository(nil, nil, denierInterface, config) + + // when + err := repository.UpsertDenier("re", "sid", "re-test-uuid1") + + // then + assert.Error(t, err) + assert.Contains(t, err.Error(), "some error") + }) + + t.Run("should upsert checknothing", func(t *testing.T) { + // given + expected := &v1alpha2.Checknothing{ + ObjectMeta: v1.ObjectMeta{ + Name: "re-test-uuid1", + Labels: map[string]string{ + k8sconsts.LabelRemoteEnvironment: "re", + k8sconsts.LabelServiceId: "sid", + }, + }, + } + + checknothingInterface := new(mocks.ChecknothingInterface) + checknothingInterface.On("Create", expected).Return(nil, nil) + + repository := NewRepository(nil, checknothingInterface, nil, config) + + // when + err := repository.UpsertCheckNothing("re", "sid", "re-test-uuid1") + + // then + assert.NoError(t, err) + checknothingInterface.AssertExpectations(t) + }) + + t.Run("should handle already exists error when upserting checknothing", func(t *testing.T) { + // given + checknothingInterface := new(mocks.ChecknothingInterface) + checknothingInterface.On("Create", mock.AnythingOfType("*v1alpha2.Checknothing")). + Return(nil, k8serrors.NewAlreadyExists(schema.GroupResource{}, "")) + + repository := NewRepository(nil, checknothingInterface, nil, config) + + // when + err := repository.UpsertCheckNothing("re", "sid", "re-test-uuid1") + + // then + assert.NoError(t, err) + checknothingInterface.AssertExpectations(t) + }) + + t.Run("should handle error when upserting checknothing", func(t *testing.T) { + // given + checknothingInterface := new(mocks.ChecknothingInterface) + checknothingInterface.On("Create", mock.AnythingOfType("*v1alpha2.Checknothing")). + Return(nil, errors.New("some error")) + + repository := NewRepository(nil, checknothingInterface, nil, config) + + // when + err := repository.UpsertCheckNothing("re", "sid", "re-test-uuid1") + + // then + assert.Error(t, err) + assert.Contains(t, err.Error(), "some error") + }) + + t.Run("should upsert rule", func(t *testing.T) { + // given + expected := &v1alpha2.Rule{ + ObjectMeta: v1.ObjectMeta{ + Name: "re-test-uuid1", + Labels: map[string]string{ + k8sconsts.LabelRemoteEnvironment: "re", + k8sconsts.LabelServiceId: "sid", + }, + }, + Spec: &v1alpha2.RuleSpec{ + Match: `(destination.service == "re-test-uuid1.testns.svc.cluster.local") && (source.labels["re-test-uuid1"] != "true")`, + Actions: []v1alpha2.RuleAction{{ + Handler: "re-test-uuid1.denier", + Instances: []string{"re-test-uuid1.checknothing"}, + }}, + }, + } + + ruleInterface := new(mocks.RuleInterface) + ruleInterface.On("Create", expected).Return(nil, nil) + + repository := NewRepository(ruleInterface, nil, nil, config) + + // when + err := repository.UpsertRule("re", "sid", "re-test-uuid1") + + // then + assert.NoError(t, err) + ruleInterface.AssertExpectations(t) + }) + + t.Run("should handle already exists error when upserting rule", func(t *testing.T) { + // given + ruleInterface := new(mocks.RuleInterface) + ruleInterface.On("Create", mock.AnythingOfType("*v1alpha2.Rule")). + Return(nil, k8serrors.NewAlreadyExists(schema.GroupResource{}, "")) + + repository := NewRepository(ruleInterface, nil, nil, config) + + // when + err := repository.UpsertRule("re", "sid", "re-test-uuid1") + + // then + assert.NoError(t, err) + ruleInterface.AssertExpectations(t) + }) + + t.Run("should handle error when upserting rule", func(t *testing.T) { + // given + ruleInterface := new(mocks.RuleInterface) + ruleInterface.On("Create", mock.AnythingOfType("*v1alpha2.Rule")). + Return(nil, errors.New("some error")) + + repository := NewRepository(ruleInterface, nil, nil, config) + + // when + err := repository.UpsertRule("re", "sid", "re-test-uuid1") + + // then + assert.Error(t, err) + assert.Contains(t, err.Error(), "some error") + }) +} + +func TestRepository_Delete(t *testing.T) { + + t.Run("should delete denier", func(t *testing.T) { + // given + denierInterface := new(mocks.DenierInterface) + denierInterface.On("Delete", "re-test-uuid1", (*v1.DeleteOptions)(nil)).Return(nil) + + repository := NewRepository(nil, nil, denierInterface, config) + + // when + err := repository.DeleteDenier("re-test-uuid1") + + // then + assert.NoError(t, err) + denierInterface.AssertExpectations(t) + }) + + t.Run("should handle error when deleting denier", func(t *testing.T) { + // given + denierInterface := new(mocks.DenierInterface) + denierInterface.On("Delete", "re-test-uuid1", (*v1.DeleteOptions)(nil)). + Return(errors.New("some error")) + + repository := NewRepository(nil, nil, denierInterface, config) + + // when + err := repository.DeleteDenier("re-test-uuid1") + + // then + assert.Error(t, err) + assert.Contains(t, err.Error(), "some error") + }) + + t.Run("should ignore not found error when deleting denier", func(t *testing.T) { + // given + denierInterface := new(mocks.DenierInterface) + denierInterface.On("Delete", "re-test-uuid1", (*v1.DeleteOptions)(nil)). + Return(k8serrors.NewNotFound(schema.GroupResource{}, "")) + + repository := NewRepository(nil, nil, denierInterface, config) + + // when + err := repository.DeleteDenier("re-test-uuid1") + + // then + assert.NoError(t, err) + }) + + t.Run("should delete checknothing", func(t *testing.T) { + // given + checknothingInterface := new(mocks.ChecknothingInterface) + checknothingInterface.On("Delete", "re-test-uuid1", (*v1.DeleteOptions)(nil)).Return(nil) + + repository := NewRepository(nil, checknothingInterface, nil, config) + + // when + err := repository.DeleteCheckNothing("re-test-uuid1") + + // then + assert.NoError(t, err) + checknothingInterface.AssertExpectations(t) + }) + + t.Run("should handle error when deleting checknothing", func(t *testing.T) { + // given + checknothingInterface := new(mocks.ChecknothingInterface) + checknothingInterface.On("Delete", "re-test-uuid1", (*v1.DeleteOptions)(nil)). + Return(errors.New("some error")) + + repository := NewRepository(nil, checknothingInterface, nil, config) + + // when + err := repository.DeleteCheckNothing("re-test-uuid1") + + // then + assert.Error(t, err) + assert.Contains(t, err.Error(), "some error") + }) + + t.Run("should ignore not found error when deleting checknothing", func(t *testing.T) { + // given + checknothingInterface := new(mocks.ChecknothingInterface) + checknothingInterface.On("Delete", "re-test-uuid1", (*v1.DeleteOptions)(nil)). + Return(k8serrors.NewNotFound(schema.GroupResource{}, "")) + + repository := NewRepository(nil, checknothingInterface, nil, config) + + // when + err := repository.DeleteCheckNothing("re-test-uuid1") + + // then + assert.NoError(t, err) + }) + + t.Run("should delete rule", func(t *testing.T) { + // given + ruleInterface := new(mocks.RuleInterface) + ruleInterface.On("Delete", "re-test-uuid1", (*v1.DeleteOptions)(nil)).Return(nil) + + repository := NewRepository(ruleInterface, nil, nil, config) + + // when + err := repository.DeleteRule("re-test-uuid1") + + // then + assert.NoError(t, err) + ruleInterface.AssertExpectations(t) + }) + + t.Run("should handle error when deleting rule", func(t *testing.T) { + // given + ruleInterface := new(mocks.RuleInterface) + ruleInterface.On("Delete", "re-test-uuid1", (*v1.DeleteOptions)(nil)). + Return(errors.New("some error")) + + repository := NewRepository(ruleInterface, nil, nil, config) + + // when + err := repository.DeleteRule("re-test-uuid1") + + // then + assert.Error(t, err) + assert.Contains(t, err.Error(), "some error") + }) + + t.Run("should ignore not found error when deleting rule", func(t *testing.T) { + // given + ruleInterface := new(mocks.RuleInterface) + ruleInterface.On("Delete", "re-test-uuid1", (*v1.DeleteOptions)(nil)). + Return(k8serrors.NewNotFound(schema.GroupResource{}, "")) + + repository := NewRepository(ruleInterface, nil, nil, config) + + // when + err := repository.DeleteRule("re-test-uuid1") + + // then + assert.NoError(t, err) + }) +} diff --git a/components/application-connector/internal/metadata/istio/service.go b/components/application-connector/internal/metadata/istio/service.go new file mode 100644 index 000000000000..d10fd4604b6e --- /dev/null +++ b/components/application-connector/internal/metadata/istio/service.go @@ -0,0 +1,87 @@ +// Package istio contains components for managing Istio resources (Deniers, DenyRules, CheckNothings, ...) +package istio + +import ( + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" +) + +// Service is responsible for creating Istio resources associated with deniers. +type Service interface { + // Create creates Istio resources associated with deniers. + Create(remoteEnvironment, serviceId, resourceName string) apperrors.AppError + + // Upsert updates or creates Istio resources associated with deniers. + Upsert(remoteEnvironment, serviceId, resourceName string) apperrors.AppError + + // Delete removes Istio resources associated with deniers. + Delete(resourceName string) apperrors.AppError +} + +type service struct { + repository Repository +} + +// NewService creates a new Service. +func NewService(repository Repository) Service { + return &service{repository: repository} +} + +// Create creates Istio resources associated with deniers. +func (s *service) Create(remoteEnvironment, serviceId, resourceName string) apperrors.AppError { + err := s.repository.CreateDenier(remoteEnvironment, serviceId, resourceName) + if err != nil { + return err + } + + err = s.repository.CreateCheckNothing(remoteEnvironment, serviceId, resourceName) + if err != nil { + return err + } + + err = s.repository.CreateRule(remoteEnvironment, serviceId, resourceName) + if err != nil { + return err + } + + return nil +} + +// Upsert updates or creates Istio resources associated with deniers. +func (s *service) Upsert(remoteEnvironment, serviceId, resourceName string) apperrors.AppError { + err := s.repository.UpsertDenier(remoteEnvironment, serviceId, resourceName) + if err != nil { + return err + } + + err = s.repository.UpsertCheckNothing(remoteEnvironment, serviceId, resourceName) + if err != nil { + return err + } + + err = s.repository.UpsertRule(remoteEnvironment, serviceId, resourceName) + if err != nil { + return err + } + + return nil +} + +// Delete removes Istio resources associated with deniers. +func (s *service) Delete(resourceName string) apperrors.AppError { + err := s.repository.DeleteDenier(resourceName) + if err != nil { + return err + } + + err = s.repository.DeleteCheckNothing(resourceName) + if err != nil { + return err + } + + err = s.repository.DeleteRule(resourceName) + if err != nil { + return err + } + + return nil +} diff --git a/components/application-connector/internal/metadata/istio/service_test.go b/components/application-connector/internal/metadata/istio/service_test.go new file mode 100644 index 000000000000..c99f503e32d4 --- /dev/null +++ b/components/application-connector/internal/metadata/istio/service_test.go @@ -0,0 +1,233 @@ +package istio + +import ( + "testing" + + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/istio/mocks" + "github.com/stretchr/testify/assert" +) + +func TestService_Create(t *testing.T) { + + t.Run("should create denier, checknothing, rule", func(t *testing.T) { + // given + repository := &mocks.Repository{} + + repository.On("CreateDenier", "re", "sid", "testsvc").Return(nil) + repository.On("CreateCheckNothing", "re", "sid", "testsvc").Return(nil) + repository.On("CreateRule", "re", "sid", "testsvc").Return(nil) + + service := NewService(repository) + + // when + err := service.Create("re", "sid", "testsvc") + + // then + assert.NoError(t, err) + repository.AssertExpectations(t) + }) + + t.Run("should handle errors when creating denier", func(t *testing.T) { + // given + repository := &mocks.Repository{} + + repository.On("CreateDenier", "re", "sid", "testsvc").Return(apperrors.Internal("")) + + service := NewService(repository) + + // when + err := service.Create("re", "sid", "testsvc") + + // then + assert.Error(t, err) + assert.Equal(t, err.Code(), apperrors.CodeInternal) + repository.AssertExpectations(t) + }) + + t.Run("should handle errors when creating checknothing", func(t *testing.T) { + // given + repository := &mocks.Repository{} + + repository.On("CreateDenier", "re", "sid", "testsvc").Return(nil) + repository.On("CreateCheckNothing", "re", "sid", "testsvc").Return(apperrors.Internal("")) + + service := NewService(repository) + + // when + err := service.Create("re", "sid", "testsvc") + + // then + assert.Error(t, err) + assert.Equal(t, err.Code(), apperrors.CodeInternal) + repository.AssertExpectations(t) + }) + + t.Run("should handle errors when creating rule", func(t *testing.T) { + // given + repository := &mocks.Repository{} + + repository.On("CreateDenier", "re", "sid", "testsvc").Return(nil) + repository.On("CreateCheckNothing", "re", "sid", "testsvc").Return(nil) + repository.On("CreateRule", "re", "sid", "testsvc").Return(apperrors.Internal("")) + + service := NewService(repository) + + // when + err := service.Create("re", "sid", "testsvc") + + // then + assert.Error(t, err) + assert.Equal(t, err.Code(), apperrors.CodeInternal) + repository.AssertExpectations(t) + }) +} + +func TestService_Upsert(t *testing.T) { + + t.Run("should upsert denier, checknothing, rule", func(t *testing.T) { + // given + repository := &mocks.Repository{} + + repository.On("UpsertDenier", "re", "sid", "testsvc").Return(nil) + repository.On("UpsertCheckNothing", "re", "sid", "testsvc").Return(nil) + repository.On("UpsertRule", "re", "sid", "testsvc").Return(nil) + + service := NewService(repository) + + // when + err := service.Upsert("re", "sid", "testsvc") + + // then + assert.NoError(t, err) + repository.AssertExpectations(t) + }) + + t.Run("should handle errors when upserting denier", func(t *testing.T) { + // given + repository := &mocks.Repository{} + + repository.On("UpsertDenier", "re", "sid", "testsvc").Return(apperrors.Internal("")) + + service := NewService(repository) + + // when + err := service.Upsert("re", "sid", "testsvc") + + // then + assert.Error(t, err) + assert.Equal(t, err.Code(), apperrors.CodeInternal) + repository.AssertExpectations(t) + }) + + t.Run("should handle errors when upserting checknothing", func(t *testing.T) { + // given + repository := &mocks.Repository{} + + repository.On("UpsertDenier", "re", "sid", "testsvc").Return(nil) + repository.On("UpsertCheckNothing", "re", "sid", "testsvc").Return(apperrors.Internal("")) + + service := NewService(repository) + + // when + err := service.Upsert("re", "sid", "testsvc") + + // then + assert.Error(t, err) + assert.Equal(t, err.Code(), apperrors.CodeInternal) + repository.AssertExpectations(t) + }) + + t.Run("should handle errors when upserting rule", func(t *testing.T) { + // given + repository := &mocks.Repository{} + + repository.On("UpsertDenier", "re", "sid", "testsvc").Return(nil) + repository.On("UpsertCheckNothing", "re", "sid", "testsvc").Return(nil) + repository.On("UpsertRule", "re", "sid", "testsvc").Return(apperrors.Internal("")) + + service := NewService(repository) + + // when + err := service.Upsert("re", "sid", "testsvc") + + // then + assert.Error(t, err) + assert.Equal(t, err.Code(), apperrors.CodeInternal) + repository.AssertExpectations(t) + }) +} + +func TestService_Delete(t *testing.T) { + + t.Run("should delete denier, checknothing, rule", func(t *testing.T) { + // given + repository := &mocks.Repository{} + + repository.On("DeleteDenier", "testsvc").Return(nil) + repository.On("DeleteCheckNothing", "testsvc").Return(nil) + repository.On("DeleteRule", "testsvc").Return(nil) + + service := NewService(repository) + + // when + err := service.Delete("testsvc") + + // then + assert.NoError(t, err) + repository.AssertExpectations(t) + }) + + t.Run("should handle errors when deleting denier", func(t *testing.T) { + // given + repository := &mocks.Repository{} + + repository.On("DeleteDenier", "testsvc").Return(apperrors.Internal("")) + + service := NewService(repository) + + // when + err := service.Delete("testsvc") + + // then + assert.Error(t, err) + repository.AssertExpectations(t) + }) + + t.Run("should handle errors when deleting checknothing", func(t *testing.T) { + + // given + repository := &mocks.Repository{} + + repository.On("DeleteDenier", "testsvc").Return(nil) + repository.On("DeleteCheckNothing", "testsvc").Return(apperrors.Internal("")) + + service := NewService(repository) + + // when + err := service.Delete("testsvc") + + // then + assert.Error(t, err) + repository.AssertExpectations(t) + }) + + t.Run("should handle errors when deleting rule", func(t *testing.T) { + // given + repository := &mocks.Repository{} + + repository.On("DeleteDenier", "testsvc").Return(nil) + repository.On("DeleteCheckNothing", "testsvc").Return(nil) + repository.On("DeleteRule", "testsvc").Return(apperrors.Internal("")) + + service := NewService(repository) + + // when + err := service.Delete("testsvc") + + // then + assert.Error(t, err) + repository.AssertExpectations(t) + }) + +} diff --git a/components/application-connector/internal/metadata/minio/mocks/Client.go b/components/application-connector/internal/metadata/minio/mocks/Client.go new file mode 100644 index 000000000000..101eac4515b1 --- /dev/null +++ b/components/application-connector/internal/metadata/minio/mocks/Client.go @@ -0,0 +1,106 @@ +// Code generated by mockery v1.0.0 +package mocks + +import context "context" +import io "io" + +import minio_go "github.com/minio/minio-go" +import mock "github.com/stretchr/testify/mock" + +// Client is an autogenerated mock type for the Client type +type Client struct { + mock.Mock +} + +// BucketExists provides a mock function with given fields: bucketName +func (_m *Client) BucketExists(bucketName string) (bool, error) { + ret := _m.Called(bucketName) + + var r0 bool + if rf, ok := ret.Get(0).(func(string) bool); ok { + r0 = rf(bucketName) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(bucketName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetObjectWithContext provides a mock function with given fields: ctx, bucketName, objectName, opts +func (_m *Client) GetObjectWithContext(ctx context.Context, bucketName string, objectName string, opts minio_go.GetObjectOptions) (*minio_go.Object, error) { + ret := _m.Called(ctx, bucketName, objectName, opts) + + var r0 *minio_go.Object + if rf, ok := ret.Get(0).(func(context.Context, string, string, minio_go.GetObjectOptions) *minio_go.Object); ok { + r0 = rf(ctx, bucketName, objectName, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*minio_go.Object) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, string, minio_go.GetObjectOptions) error); ok { + r1 = rf(ctx, bucketName, objectName, opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MakeBucket provides a mock function with given fields: bucketName, location +func (_m *Client) MakeBucket(bucketName string, location string) error { + ret := _m.Called(bucketName, location) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(bucketName, location) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// PutObjectWithContext provides a mock function with given fields: ctx, bucketName, objectName, reader, objectSize, opts +func (_m *Client) PutObjectWithContext(ctx context.Context, bucketName string, objectName string, reader io.Reader, objectSize int64, opts minio_go.PutObjectOptions) (int64, error) { + ret := _m.Called(ctx, bucketName, objectName, reader, objectSize, opts) + + var r0 int64 + if rf, ok := ret.Get(0).(func(context.Context, string, string, io.Reader, int64, minio_go.PutObjectOptions) int64); ok { + r0 = rf(ctx, bucketName, objectName, reader, objectSize, opts) + } else { + r0 = ret.Get(0).(int64) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, string, io.Reader, int64, minio_go.PutObjectOptions) error); ok { + r1 = rf(ctx, bucketName, objectName, reader, objectSize, opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// RemoveObject provides a mock function with given fields: bucketName, objectName +func (_m *Client) RemoveObject(bucketName string, objectName string) error { + ret := _m.Called(bucketName, objectName) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(bucketName, objectName) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/components/application-connector/internal/metadata/minio/mocks/Repository.go b/components/application-connector/internal/metadata/minio/mocks/Repository.go new file mode 100644 index 000000000000..391bb7835096 --- /dev/null +++ b/components/application-connector/internal/metadata/minio/mocks/Repository.go @@ -0,0 +1,68 @@ +// Code generated by mockery v1.0.0 +package mocks + +import apperrors "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + +import mock "github.com/stretchr/testify/mock" + +// Repository is an autogenerated mock type for the Repository type +type Repository struct { + mock.Mock +} + +// Get provides a mock function with given fields: bucketName, remotePath +func (_m *Repository) Get(bucketName string, remotePath string) ([]byte, apperrors.AppError) { + ret := _m.Called(bucketName, remotePath) + + var r0 []byte + if rf, ok := ret.Get(0).(func(string, string) []byte); ok { + r0 = rf(bucketName, remotePath) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + var r1 apperrors.AppError + if rf, ok := ret.Get(1).(func(string, string) apperrors.AppError); ok { + r1 = rf(bucketName, remotePath) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(apperrors.AppError) + } + } + + return r0, r1 +} + +// Put provides a mock function with given fields: bucketName, remotePath, resource +func (_m *Repository) Put(bucketName string, remotePath string, resource []byte) apperrors.AppError { + ret := _m.Called(bucketName, remotePath, resource) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string, string, []byte) apperrors.AppError); ok { + r0 = rf(bucketName, remotePath, resource) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} + +// Remove provides a mock function with given fields: bucketName, remotePath +func (_m *Repository) Remove(bucketName string, remotePath string) apperrors.AppError { + ret := _m.Called(bucketName, remotePath) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string, string) apperrors.AppError); ok { + r0 = rf(bucketName, remotePath) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} diff --git a/components/application-connector/internal/metadata/minio/mocks/Service.go b/components/application-connector/internal/metadata/minio/mocks/Service.go new file mode 100644 index 000000000000..62a61bcdd577 --- /dev/null +++ b/components/application-connector/internal/metadata/minio/mocks/Service.go @@ -0,0 +1,86 @@ +// Code generated by mockery v1.0.0 +package mocks + +import apperrors "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + +import mock "github.com/stretchr/testify/mock" + +// Service is an autogenerated mock type for the Service type +type Service struct { + mock.Mock +} + +// Get provides a mock function with given fields: id +func (_m *Service) Get(id string) ([]byte, []byte, []byte, apperrors.AppError) { + ret := _m.Called(id) + + var r0 []byte + if rf, ok := ret.Get(0).(func(string) []byte); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + var r1 []byte + if rf, ok := ret.Get(1).(func(string) []byte); ok { + r1 = rf(id) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]byte) + } + } + + var r2 []byte + if rf, ok := ret.Get(2).(func(string) []byte); ok { + r2 = rf(id) + } else { + if ret.Get(2) != nil { + r2 = ret.Get(2).([]byte) + } + } + + var r3 apperrors.AppError + if rf, ok := ret.Get(3).(func(string) apperrors.AppError); ok { + r3 = rf(id) + } else { + if ret.Get(3) != nil { + r3 = ret.Get(3).(apperrors.AppError) + } + } + + return r0, r1, r2, r3 +} + +// Put provides a mock function with given fields: id, documentation, apiSpec, eventsSpec +func (_m *Service) Put(id string, documentation []byte, apiSpec []byte, eventsSpec []byte) apperrors.AppError { + ret := _m.Called(id, documentation, apiSpec, eventsSpec) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string, []byte, []byte, []byte) apperrors.AppError); ok { + r0 = rf(id, documentation, apiSpec, eventsSpec) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} + +// Remove provides a mock function with given fields: id +func (_m *Service) Remove(id string) apperrors.AppError { + ret := _m.Called(id) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string) apperrors.AppError); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} diff --git a/components/application-connector/internal/metadata/minio/repository.go b/components/application-connector/internal/metadata/minio/repository.go new file mode 100644 index 000000000000..db27ed6c9c44 --- /dev/null +++ b/components/application-connector/internal/metadata/minio/repository.go @@ -0,0 +1,111 @@ +package minio + +import ( + "bytes" + "context" + "io" + "time" + + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + "github.com/minio/minio-go" +) + +const ( + timeoutDuration = time.Duration(5) + secureTraffic = false + bucketLocation = "us-east-1" +) + +type Client interface { + PutObjectWithContext(ctx context.Context, bucketName, objectName string, reader io.Reader, objectSize int64, opts minio.PutObjectOptions) (n int64, err error) + GetObjectWithContext(ctx context.Context, bucketName, objectName string, opts minio.GetObjectOptions) (*minio.Object, error) + RemoveObject(bucketName, objectName string) error + BucketExists(bucketName string) (found bool, err error) + MakeBucket(bucketName, location string) error +} + +type Repository interface { + Put(bucketName string, objectName string, resource []byte) apperrors.AppError + Get(bucketName string, objectName string) ([]byte, apperrors.AppError) + Remove(bucketName string, objectName string) apperrors.AppError +} + +type repository struct { + minioClient Client + timeout time.Duration + bucketLocation string +} + +func NewMinioRepository(endpoint string, accessKeyID string, secretAccessKey string) (Repository, apperrors.AppError) { + minioClient, err := minio.New(endpoint, accessKeyID, secretAccessKey, secureTraffic) + if err != nil { + return nil, apperrors.Internal("error while creating Minio client: %s", err) + } + + return &repository{minioClient: minioClient, timeout: timeoutDuration, bucketLocation: bucketLocation}, nil +} + +func (r *repository) Put(bucketName string, objectName string, resource []byte) apperrors.AppError { + contextWithTimeout, cancel := context.WithTimeout(context.Background(), r.timeout*time.Second) + defer cancel() + + reader := bytes.NewReader(resource) + + _, appErr := r.minioClient.PutObjectWithContext(contextWithTimeout, bucketName, objectName, reader, reader.Size(), minio.PutObjectOptions{}) + if appErr != nil { + return apperrors.Internal("error while uploading file to Minio: %s", appErr) + } + + return nil +} + +func (r *repository) Get(bucketName string, objectName string) ([]byte, apperrors.AppError) { + object, err, cancel := r.getObject(bucketName, objectName) + defer cancel() + if err != nil { + return nil, err + } + + data, err := readBytes(object) + if err != nil { + return nil, err + } + + return data, nil +} + +func (r *repository) Remove(bucketName string, objectName string) apperrors.AppError { + err := r.minioClient.RemoveObject(bucketName, objectName) + if err != nil { + return apperrors.Internal("failed to remove object %s", objectName, err) + } + + return nil +} + +func (r *repository) getObject(bucketName string, objectName string) (*minio.Object, apperrors.AppError, func()) { + contextWithTimeout, cancel := context.WithTimeout(context.Background(), r.timeout*time.Second) + + object, err := r.minioClient.GetObjectWithContext(contextWithTimeout, bucketName, objectName, minio.GetObjectOptions{}) + if err != nil { + return nil, apperrors.Internal("failed to get object %s: ", objectName, err), cancel + } + + return object, nil, cancel +} + +func readBytes(reader io.Reader) ([]byte, apperrors.AppError) { + buffer := bytes.Buffer{} + + _, err := buffer.ReadFrom(reader) + if err != nil { + errorResponse := err.(minio.ErrorResponse) + if errorResponse.Code == "NoSuchKey" { + return nil, nil + } + + return nil, apperrors.Internal("error while reading object bytes: %s", err) + } + + return buffer.Bytes(), nil +} diff --git a/components/application-connector/internal/metadata/minio/repository_test.go b/components/application-connector/internal/metadata/minio/repository_test.go new file mode 100644 index 000000000000..532ec05c5978 --- /dev/null +++ b/components/application-connector/internal/metadata/minio/repository_test.go @@ -0,0 +1,188 @@ +package minio + +import ( + "bytes" + "testing" + + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/minio/mocks" + "github.com/minio/minio-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type errorReader struct { +} + +func (errorReader) Read(p []byte) (n int, err error) { + return 0, minio.ErrorResponse{} +} + +func TestRepository_Create(t *testing.T) { + t.Run("should create Minio resource", func(t *testing.T) { + + // given + clientMock := &mocks.Client{} + clientMock.On( + "PutObjectWithContext", + mock.AnythingOfType("*context.timerCtx"), + "testBucket", + "testRemotePath", + mock.AnythingOfType("*bytes.Reader"), + int64(0), + minio.PutObjectOptions{}). + Return(int64(0), nil) + + minioRepository := repository{clientMock, 5, "us-east-1"} + + // when + err := minioRepository.Put("testBucket", "testRemotePath", make([]byte, 0)) + + // then + assert.Nil(t, err) + + clientMock.AssertExpectations(t) + }) + + t.Run("should return an error if creation failed", func(t *testing.T) { + + // given + clientMock := &mocks.Client{} + clientMock.On( + "PutObjectWithContext", + mock.AnythingOfType("*context.timerCtx"), + "testBucket", + "testRemotePath", + mock.AnythingOfType("*bytes.Reader"), + int64(0), + minio.PutObjectOptions{}). + Return(int64(0), apperrors.Internal("Error: %s", "error")) + + minioRepository := repository{clientMock, 5, "us-east-1"} + + // when + err := minioRepository.Put("testBucket", "testRemotePath", make([]byte, 0)) + + // then + assert.NotNil(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + + clientMock.AssertExpectations(t) + }) +} + +func TestRepository_Remove(t *testing.T) { + t.Run("should delete", func(t *testing.T) { + // given + clientMock := &mocks.Client{} + clientMock.On("RemoveObject", "content", "service-class/1111-2222/content.json").Return(nil) + + minioRepository := repository{clientMock, 5, "us-east-1"} + + // when + err := minioRepository.Remove("content", "service-class/1111-2222/content.json") + + // then + assert.NoError(t, err) + }) + + t.Run("should handle errors when deleting", func(t *testing.T) { + // given + clientMock := &mocks.Client{} + clientMock.On("RemoveObject", "content", "service-class/1111-2222/content.json").Return(apperrors.Internal("e")) + + minioRepository := repository{clientMock, 5, "us-east-1"} + + // when + err := minioRepository.Remove("content", "service-class/1111-2222/content.json") + + // then + assert.Error(t, err) + }) +} + +func TestRepository_getObject(t *testing.T) { + t.Run("should get Minio resource", func(t *testing.T) { + + // given + minioObject := &minio.Object{} + + clientMock := &mocks.Client{} + clientMock.On( + "GetObjectWithContext", + mock.AnythingOfType("*context.timerCtx"), + "testBucket", + "testRemotePath", + minio.GetObjectOptions{}). + Return(minioObject, nil) + + minioRepository := repository{clientMock, 5, "us-east-1"} + + // when + object, err, cancel := minioRepository.getObject("testBucket", "testRemotePath") + + // then + assert.Nil(t, err) + assert.NotNil(t, object) + + assert.NotNil(t, cancel) + + clientMock.AssertExpectations(t) + }) + + t.Run("should return an error if minio fails", func(t *testing.T) { + + // given + clientMock := &mocks.Client{} + clientMock.On( + "GetObjectWithContext", + mock.AnythingOfType("*context.timerCtx"), + "testBucket", + "testRemotePath", + minio.GetObjectOptions{}). + Return(nil, apperrors.Internal("Error: %s", "an error")) + + minioRepository := repository{clientMock, 5, "us-east-1"} + + // when + object, err, cancel := minioRepository.getObject("testBucket", "testRemotePath") + + // then + assert.NotNil(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + + assert.NotNil(t, cancel) + + assert.Nil(t, object) + + clientMock.AssertExpectations(t) + }) +} + +func TestRepository_readBytes(t *testing.T) { + t.Run("should return byte array from given io.Reader", func(t *testing.T) { + + // given + data := []byte("testData") + reader := bytes.NewReader(data) + + // when + dataRead, err := readBytes(reader) + + // then + assert.Equal(t, data, dataRead) + assert.Nil(t, err) + }) + + t.Run("should return error in case reading fails", func(t *testing.T) { + + // given + reader := errorReader{} + + // when + _, err := readBytes(reader) + + // then + assert.NotNil(t, err) + }) +} diff --git a/components/application-connector/internal/metadata/minio/service.go b/components/application-connector/internal/metadata/minio/service.go new file mode 100644 index 000000000000..8c00ce71b8ff --- /dev/null +++ b/components/application-connector/internal/metadata/minio/service.go @@ -0,0 +1,107 @@ +package minio + +import ( + "fmt" + + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" +) + +type Service interface { + Put(id string, documentation []byte, apiSpec []byte, eventsSpec []byte) apperrors.AppError + Get(id string) (documentation []byte, apiSpec []byte, eventsSpec []byte, apperr apperrors.AppError) + Remove(id string) apperrors.AppError +} + +type service struct { + repository Repository +} + +const ( + bucketName = "content" + typeName = "service-class" + + documentationFileName = "content.json" + apiSpecFileName = "apiSpec.json" + eventsSpecFileName = "asyncApiSpec.json" +) + +func NewService(repository Repository) Service { + return &service{ + repository: repository, + } +} + +func (s *service) Put(id string, documentation []byte, apiSpec []byte, eventsSpec []byte) apperrors.AppError { + apperr := s.Remove(id) + if apperr != nil { + return apperr + } + + apperr = s.create(id, documentationFileName, documentation) + if apperr != nil { + return apperr + } + + apperr = s.create(id, apiSpecFileName, apiSpec) + if apperr != nil { + return apperr + } + + apperr = s.create(id, eventsSpecFileName, eventsSpec) + if apperr != nil { + return apperr + } + + return nil +} + +func (s *service) Get(id string) ([]byte, []byte, []byte, apperrors.AppError) { + documentation, apperr := s.repository.Get(bucketName, makeFilePath(id, documentationFileName)) + if apperr != nil { + return nil, nil, nil, apperr + } + + apiSpec, apperr := s.repository.Get(bucketName, makeFilePath(id, apiSpecFileName)) + if apperr != nil { + return nil, nil, nil, apperr + } + + eventsSpec, apperr := s.repository.Get(bucketName, makeFilePath(id, eventsSpecFileName)) + if apperr != nil { + return nil, nil, nil, apperr + } + + return documentation, apiSpec, eventsSpec, nil +} + +func (s *service) Remove(id string) apperrors.AppError { + apperr := s.repository.Remove(bucketName, makeFilePath(id, documentationFileName)) + if apperr != nil { + return apperr + } + + apperr = s.repository.Remove(bucketName, makeFilePath(id, apiSpecFileName)) + if apperr != nil { + return apperr + } + + apperr = s.repository.Remove(bucketName, makeFilePath(id, eventsSpecFileName)) + if apperr != nil { + return apperr + } + + return nil +} + +func (s *service) create(id, fileName string, content []byte) apperrors.AppError { + if content != nil { + path := makeFilePath(id, fileName) + return s.repository.Put(bucketName, path, content) + } + + return nil +} + +func makeFilePath(id, fileName string) string { + return fmt.Sprintf("%s/%s/%s", typeName, id, fileName) +} diff --git a/components/application-connector/internal/metadata/minio/service_test.go b/components/application-connector/internal/metadata/minio/service_test.go new file mode 100644 index 000000000000..b91a25e1b0ab --- /dev/null +++ b/components/application-connector/internal/metadata/minio/service_test.go @@ -0,0 +1,301 @@ +package minio + +import ( + "testing" + + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/minio/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestMinioService_Create(t *testing.T) { + + documentation := []byte("{\"description\": \"Some docs blah blah blah\"}}") + apiSpec := []byte("{\"productsEndpoint\": \"Endpoint /products returns products.\"}}") + eventsSpec := []byte("{\"orderCreated\": \"Published when order is placed.\"}}") + + const bucketName = "content" + + t.Run("should create all specs", func(t *testing.T) { + // given + repositoryMock := &mocks.Repository{} + service := NewService(repositoryMock) + + repositoryMock.On("Remove", bucketName, mock.Anything).Return(nil) + repositoryMock.On("Put", bucketName, "service-class/1111-2222/content.json", documentation).Return(nil) + repositoryMock.On("Put", bucketName, "service-class/1111-2222/apiSpec.json", apiSpec).Return(nil) + repositoryMock.On("Put", bucketName, "service-class/1111-2222/asyncApiSpec.json", eventsSpec).Return(nil) + + // when + apperr := service.Put("1111-2222", documentation, apiSpec, eventsSpec) + + // then + require.NoError(t, apperr) + repositoryMock.AssertExpectations(t) + }) + + t.Run("should not insert documentation if empty", func(t *testing.T) { + // given + repositoryMock := &mocks.Repository{} + service := NewService(repositoryMock) + + repositoryMock.On("Remove", bucketName, mock.Anything).Return(nil) + repositoryMock.On("Put", bucketName, "service-class/1111-2222/apiSpec.json", apiSpec).Return(nil) + repositoryMock.On("Put", bucketName, "service-class/1111-2222/asyncApiSpec.json", eventsSpec).Return(nil) + + var emptyDocs []byte + + // when + apperr := service.Put("1111-2222", emptyDocs, apiSpec, eventsSpec) + + // then + require.NoError(t, apperr) + repositoryMock.AssertExpectations(t) + }) + + t.Run("should not insert api spec if empty", func(t *testing.T) { + // given + repositoryMock := &mocks.Repository{} + service := NewService(repositoryMock) + + repositoryMock.On("Remove", bucketName, mock.Anything).Return(nil) + repositoryMock.On("Put", bucketName, "service-class/1111-2222/content.json", documentation).Return(nil) + repositoryMock.On("Put", bucketName, "service-class/1111-2222/asyncApiSpec.json", eventsSpec).Return(nil) + + var emptyApiSpec []byte + + // when + apperr := service.Put("1111-2222", documentation, emptyApiSpec, eventsSpec) + + // then + require.NoError(t, apperr) + repositoryMock.AssertExpectations(t) + }) + + t.Run("should not insert events spec if empty", func(t *testing.T) { + // given + repositoryMock := &mocks.Repository{} + service := NewService(repositoryMock) + + repositoryMock.On("Remove", bucketName, mock.Anything).Return(nil) + repositoryMock.On("Put", bucketName, "service-class/1111-2222/content.json", documentation).Return(nil) + repositoryMock.On("Put", bucketName, "service-class/1111-2222/apiSpec.json", apiSpec).Return(nil) + + var emptyEventsSpec []byte + + // when + apperr := service.Put("1111-2222", documentation, apiSpec, emptyEventsSpec) + + // then + require.NoError(t, apperr) + repositoryMock.AssertExpectations(t) + }) + + t.Run("should handle errors when creating documentation", func(t *testing.T) { + // given + repositoryMock := &mocks.Repository{} + service := NewService(repositoryMock) + + repositoryMock.On("Remove", bucketName, mock.Anything).Return(nil) + repositoryMock.On("Put", bucketName, "service-class/1111-2222/content.json", documentation).Return(apperrors.Internal("", nil)) + + // when + apperr := service.Put("1111-2222", documentation, apiSpec, eventsSpec) + + // then + require.Error(t, apperr) + repositoryMock.AssertExpectations(t) + }) + + t.Run("should handle errors when creating api spec", func(t *testing.T) { + // given + repositoryMock := &mocks.Repository{} + service := NewService(repositoryMock) + + repositoryMock.On("Remove", bucketName, mock.Anything).Return(nil) + repositoryMock.On("Put", bucketName, "service-class/1111-2222/content.json", documentation).Return(nil) + repositoryMock.On("Put", bucketName, "service-class/1111-2222/apiSpec.json", apiSpec).Return(apperrors.Internal("", nil)) + + // when + apperr := service.Put("1111-2222", documentation, apiSpec, eventsSpec) + + // then + require.Error(t, apperr) + repositoryMock.AssertExpectations(t) + }) + + t.Run("should handle errors when creating events spec", func(t *testing.T) { + // given + repositoryMock := &mocks.Repository{} + service := NewService(repositoryMock) + + repositoryMock.On("Remove", bucketName, mock.Anything).Return(nil) + repositoryMock.On("Put", bucketName, "service-class/1111-2222/content.json", documentation).Return(nil) + repositoryMock.On("Put", bucketName, "service-class/1111-2222/apiSpec.json", apiSpec).Return(nil) + repositoryMock.On("Put", bucketName, "service-class/1111-2222/asyncApiSpec.json", eventsSpec).Return(apperrors.Internal("", nil)) + + // when + apperr := service.Put("1111-2222", documentation, apiSpec, eventsSpec) + + // then + require.Error(t, apperr) + repositoryMock.AssertExpectations(t) + }) + + t.Run("should handle errors when deleting before put", func(t *testing.T) { + // given + repositoryMock := &mocks.Repository{} + service := NewService(repositoryMock) + + repositoryMock.On("Remove", bucketName, mock.Anything).Return(apperrors.Internal("", nil)) + + // when + apperr := service.Put("1111-2222", documentation, apiSpec, eventsSpec) + + // then + require.Error(t, apperr) + repositoryMock.AssertExpectations(t) + }) + +} + +func TestMinioService_Get(t *testing.T) { + + expectedDocumentation := []byte("{\"description\": \"Some docs blah blah blah\"}}") + expectedApiSpec := []byte("{\"productsEndpoint\": \"Endpoint /products returns products.\"}}") + expectedEventsSpec := []byte("{\"orderCreated\": \"Published when order is placed.\"}}") + + t.Run("should get all specs", func(t *testing.T) { + // given + repositoryMock := &mocks.Repository{} + service := NewService(repositoryMock) + + repositoryMock.On("Get", bucketName, "service-class/1111-2222/content.json").Return(expectedDocumentation, nil) + repositoryMock.On("Get", bucketName, "service-class/1111-2222/apiSpec.json").Return(expectedApiSpec, nil) + repositoryMock.On("Get", bucketName, "service-class/1111-2222/asyncApiSpec.json").Return(expectedEventsSpec, nil) + + // when + documentation, apiSpec, eventsSpec, apperr := service.Get("1111-2222") + + // then + require.NoError(t, apperr) + assert.Equal(t, expectedDocumentation, documentation) + assert.Equal(t, expectedApiSpec, apiSpec) + assert.Equal(t, expectedEventsSpec, eventsSpec) + repositoryMock.AssertExpectations(t) + }) + + t.Run("should handle errors when getting documentation", func(t *testing.T) { + // given + repositoryMock := &mocks.Repository{} + service := NewService(repositoryMock) + + repositoryMock.On("Get", bucketName, "service-class/1111-2222/content.json").Return(nil, apperrors.Internal("", nil)) + + // when + _, _, _, apperr := service.Get("1111-2222") + + // then + require.Error(t, apperr) + repositoryMock.AssertExpectations(t) + }) + + t.Run("should handle errors when getting api spec", func(t *testing.T) { + // given + repositoryMock := &mocks.Repository{} + service := NewService(repositoryMock) + + repositoryMock.On("Get", bucketName, "service-class/1111-2222/content.json").Return(expectedDocumentation, nil) + repositoryMock.On("Get", bucketName, "service-class/1111-2222/apiSpec.json").Return(nil, apperrors.Internal("", nil)) + + // when + _, _, _, apperr := service.Get("1111-2222") + + // then + require.Error(t, apperr) + repositoryMock.AssertExpectations(t) + }) + + t.Run("should handle errors when getting events spec", func(t *testing.T) { + // given + repositoryMock := &mocks.Repository{} + service := NewService(repositoryMock) + + repositoryMock.On("Get", bucketName, "service-class/1111-2222/content.json").Return(expectedDocumentation, nil) + repositoryMock.On("Get", bucketName, "service-class/1111-2222/apiSpec.json").Return(expectedApiSpec, nil) + repositoryMock.On("Get", bucketName, "service-class/1111-2222/asyncApiSpec.json").Return(nil, apperrors.Internal("", nil)) + + // when + _, _, _, apperr := service.Get("1111-2222") + + // then + require.Error(t, apperr) + repositoryMock.AssertExpectations(t) + }) + +} + +func TestMinioService_Remove(t *testing.T) { + t.Run("should delete all specs", func(t *testing.T) { + repositoryMock := &mocks.Repository{} + service := NewService(repositoryMock) + + repositoryMock.On("Remove", bucketName, "service-class/1111-2222/content.json").Return(nil) + repositoryMock.On("Remove", bucketName, "service-class/1111-2222/apiSpec.json").Return(nil) + repositoryMock.On("Remove", bucketName, "service-class/1111-2222/asyncApiSpec.json").Return(nil) + + // when + apperr := service.Remove("1111-2222") + + // then + require.NoError(t, apperr) + repositoryMock.AssertExpectations(t) + }) + + t.Run("should handle failure when removing documentation", func(t *testing.T) { + repositoryMock := &mocks.Repository{} + service := NewService(repositoryMock) + + repositoryMock.On("Remove", bucketName, "service-class/1111-2222/content.json").Return(apperrors.Internal("")) + + // when + apperr := service.Remove("1111-2222") + + // then + require.Error(t, apperr) + repositoryMock.AssertExpectations(t) + }) + + t.Run("should handle failure when removing apiSpec", func(t *testing.T) { + repositoryMock := &mocks.Repository{} + service := NewService(repositoryMock) + + repositoryMock.On("Remove", bucketName, "service-class/1111-2222/content.json").Return(nil) + repositoryMock.On("Remove", bucketName, "service-class/1111-2222/apiSpec.json").Return(apperrors.Internal("")) + + // when + apperr := service.Remove("1111-2222") + + // then + require.Error(t, apperr) + repositoryMock.AssertExpectations(t) + }) + + t.Run("should handle failure when removing eventsSpec", func(t *testing.T) { + repositoryMock := &mocks.Repository{} + service := NewService(repositoryMock) + + repositoryMock.On("Remove", bucketName, "service-class/1111-2222/content.json").Return(nil) + repositoryMock.On("Remove", bucketName, "service-class/1111-2222/apiSpec.json").Return(nil) + repositoryMock.On("Remove", bucketName, "service-class/1111-2222/asyncApiSpec.json").Return(apperrors.Internal("")) + + // when + apperr := service.Remove("1111-2222") + + // then + require.Error(t, apperr) + repositoryMock.AssertExpectations(t) + }) +} diff --git a/components/application-connector/internal/metadata/mocks/ServiceDefinitionService.go b/components/application-connector/internal/metadata/mocks/ServiceDefinitionService.go new file mode 100644 index 000000000000..b7c4ab1072d5 --- /dev/null +++ b/components/application-connector/internal/metadata/mocks/ServiceDefinitionService.go @@ -0,0 +1,140 @@ +// Code generated by mockery v1.0.0 +package mocks + +import apperrors "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" +import metadata "github.com/kyma-project/kyma/components/application-connector/internal/metadata" +import mock "github.com/stretchr/testify/mock" +import serviceapi "github.com/kyma-project/kyma/components/application-connector/internal/metadata/serviceapi" + +// ServiceDefinitionService is an autogenerated mock type for the ServiceDefinitionService type +type ServiceDefinitionService struct { + mock.Mock +} + +// Create provides a mock function with given fields: remoteEnvironment, serviceDefinition +func (_m *ServiceDefinitionService) Create(remoteEnvironment string, serviceDefinition *metadata.ServiceDefinition) (string, apperrors.AppError) { + ret := _m.Called(remoteEnvironment, serviceDefinition) + + var r0 string + if rf, ok := ret.Get(0).(func(string, *metadata.ServiceDefinition) string); ok { + r0 = rf(remoteEnvironment, serviceDefinition) + } else { + r0 = ret.Get(0).(string) + } + + var r1 apperrors.AppError + if rf, ok := ret.Get(1).(func(string, *metadata.ServiceDefinition) apperrors.AppError); ok { + r1 = rf(remoteEnvironment, serviceDefinition) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(apperrors.AppError) + } + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: remoteEnvironment, id +func (_m *ServiceDefinitionService) Delete(remoteEnvironment string, id string) apperrors.AppError { + ret := _m.Called(remoteEnvironment, id) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string, string) apperrors.AppError); ok { + r0 = rf(remoteEnvironment, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} + +// GetAPI provides a mock function with given fields: remoteEnvironment, serviceId +func (_m *ServiceDefinitionService) GetAPI(remoteEnvironment string, serviceId string) (*serviceapi.API, apperrors.AppError) { + ret := _m.Called(remoteEnvironment, serviceId) + + var r0 *serviceapi.API + if rf, ok := ret.Get(0).(func(string, string) *serviceapi.API); ok { + r0 = rf(remoteEnvironment, serviceId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*serviceapi.API) + } + } + + var r1 apperrors.AppError + if rf, ok := ret.Get(1).(func(string, string) apperrors.AppError); ok { + r1 = rf(remoteEnvironment, serviceId) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(apperrors.AppError) + } + } + + return r0, r1 +} + +// GetAll provides a mock function with given fields: remoteEnvironment +func (_m *ServiceDefinitionService) GetAll(remoteEnvironment string) ([]metadata.ServiceDefinition, apperrors.AppError) { + ret := _m.Called(remoteEnvironment) + + var r0 []metadata.ServiceDefinition + if rf, ok := ret.Get(0).(func(string) []metadata.ServiceDefinition); ok { + r0 = rf(remoteEnvironment) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]metadata.ServiceDefinition) + } + } + + var r1 apperrors.AppError + if rf, ok := ret.Get(1).(func(string) apperrors.AppError); ok { + r1 = rf(remoteEnvironment) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(apperrors.AppError) + } + } + + return r0, r1 +} + +// GetByID provides a mock function with given fields: remoteEnvironment, id +func (_m *ServiceDefinitionService) GetByID(remoteEnvironment string, id string) (metadata.ServiceDefinition, apperrors.AppError) { + ret := _m.Called(remoteEnvironment, id) + + var r0 metadata.ServiceDefinition + if rf, ok := ret.Get(0).(func(string, string) metadata.ServiceDefinition); ok { + r0 = rf(remoteEnvironment, id) + } else { + r0 = ret.Get(0).(metadata.ServiceDefinition) + } + + var r1 apperrors.AppError + if rf, ok := ret.Get(1).(func(string, string) apperrors.AppError); ok { + r1 = rf(remoteEnvironment, id) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(apperrors.AppError) + } + } + + return r0, r1 +} + +// Update provides a mock function with given fields: remoteEnvironment, id, serviceDef +func (_m *ServiceDefinitionService) Update(remoteEnvironment string, id string, serviceDef *metadata.ServiceDefinition) apperrors.AppError { + ret := _m.Called(remoteEnvironment, id, serviceDef) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string, string, *metadata.ServiceDefinition) apperrors.AppError); ok { + r0 = rf(remoteEnvironment, id, serviceDef) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} diff --git a/components/application-connector/internal/metadata/model.go b/components/application-connector/internal/metadata/model.go new file mode 100644 index 000000000000..df60728c183c --- /dev/null +++ b/components/application-connector/internal/metadata/model.go @@ -0,0 +1,27 @@ +package metadata + +import "github.com/kyma-project/kyma/components/application-connector/internal/metadata/serviceapi" + +// ServiceDefinition is an internal representation of a service. +type ServiceDefinition struct { + // ID of service + ID string + // Name of a service + Name string + // Provider of a service + Provider string + // Description of a service + Description string + // Api of a service + Api *serviceapi.API + // Events of a service + Events *Events + // Documentation of service + Documentation []byte +} + +// Events contains specification for events. +type Events struct { + // Spec contains data of events specification. + Spec []byte +} diff --git a/components/application-connector/internal/metadata/remoteenv/mocks/RemoteEnvironmentManager.go b/components/application-connector/internal/metadata/remoteenv/mocks/RemoteEnvironmentManager.go new file mode 100644 index 000000000000..fb38122720a4 --- /dev/null +++ b/components/application-connector/internal/metadata/remoteenv/mocks/RemoteEnvironmentManager.go @@ -0,0 +1,58 @@ +// Code generated by mockery v1.0.0 +package mocks + +import mock "github.com/stretchr/testify/mock" + +import v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +import v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + +// RemoteEnvironmentManager is an autogenerated mock type for the RemoteEnvironmentManager type +type RemoteEnvironmentManager struct { + mock.Mock +} + +// Get provides a mock function with given fields: name, options +func (_m *RemoteEnvironmentManager) Get(name string, options v1.GetOptions) (*v1alpha1.RemoteEnvironment, error) { + ret := _m.Called(name, options) + + var r0 *v1alpha1.RemoteEnvironment + if rf, ok := ret.Get(0).(func(string, v1.GetOptions) *v1alpha1.RemoteEnvironment); ok { + r0 = rf(name, options) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1alpha1.RemoteEnvironment) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, v1.GetOptions) error); ok { + r1 = rf(name, options) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Update provides a mock function with given fields: _a0 +func (_m *RemoteEnvironmentManager) Update(_a0 *v1alpha1.RemoteEnvironment) (*v1alpha1.RemoteEnvironment, error) { + ret := _m.Called(_a0) + + var r0 *v1alpha1.RemoteEnvironment + if rf, ok := ret.Get(0).(func(*v1alpha1.RemoteEnvironment) *v1alpha1.RemoteEnvironment); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1alpha1.RemoteEnvironment) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*v1alpha1.RemoteEnvironment) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/application-connector/internal/metadata/remoteenv/mocks/ServiceRepository.go b/components/application-connector/internal/metadata/remoteenv/mocks/ServiceRepository.go new file mode 100644 index 000000000000..e5cc70f41488 --- /dev/null +++ b/components/application-connector/internal/metadata/remoteenv/mocks/ServiceRepository.go @@ -0,0 +1,107 @@ +// Code generated by mockery v1.0.0 +package mocks + +import apperrors "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" +import mock "github.com/stretchr/testify/mock" +import remoteenv "github.com/kyma-project/kyma/components/application-connector/internal/metadata/remoteenv" + +// ServiceRepository is an autogenerated mock type for the ServiceRepository type +type ServiceRepository struct { + mock.Mock +} + +// Create provides a mock function with given fields: remoteEnvironment, service +func (_m *ServiceRepository) Create(remoteEnvironment string, service remoteenv.Service) apperrors.AppError { + ret := _m.Called(remoteEnvironment, service) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string, remoteenv.Service) apperrors.AppError); ok { + r0 = rf(remoteEnvironment, service) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} + +// Delete provides a mock function with given fields: remoteEnvironment, id +func (_m *ServiceRepository) Delete(remoteEnvironment string, id string) apperrors.AppError { + ret := _m.Called(remoteEnvironment, id) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string, string) apperrors.AppError); ok { + r0 = rf(remoteEnvironment, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} + +// Get provides a mock function with given fields: remoteEnvironment, id +func (_m *ServiceRepository) Get(remoteEnvironment string, id string) (remoteenv.Service, apperrors.AppError) { + ret := _m.Called(remoteEnvironment, id) + + var r0 remoteenv.Service + if rf, ok := ret.Get(0).(func(string, string) remoteenv.Service); ok { + r0 = rf(remoteEnvironment, id) + } else { + r0 = ret.Get(0).(remoteenv.Service) + } + + var r1 apperrors.AppError + if rf, ok := ret.Get(1).(func(string, string) apperrors.AppError); ok { + r1 = rf(remoteEnvironment, id) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(apperrors.AppError) + } + } + + return r0, r1 +} + +// GetAll provides a mock function with given fields: remoteEnvironment +func (_m *ServiceRepository) GetAll(remoteEnvironment string) ([]remoteenv.Service, apperrors.AppError) { + ret := _m.Called(remoteEnvironment) + + var r0 []remoteenv.Service + if rf, ok := ret.Get(0).(func(string) []remoteenv.Service); ok { + r0 = rf(remoteEnvironment) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]remoteenv.Service) + } + } + + var r1 apperrors.AppError + if rf, ok := ret.Get(1).(func(string) apperrors.AppError); ok { + r1 = rf(remoteEnvironment) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(apperrors.AppError) + } + } + + return r0, r1 +} + +// Update provides a mock function with given fields: remoteEnvironment, service +func (_m *ServiceRepository) Update(remoteEnvironment string, service remoteenv.Service) apperrors.AppError { + ret := _m.Called(remoteEnvironment, service) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string, remoteenv.Service) apperrors.AppError); ok { + r0 = rf(remoteEnvironment, service) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} diff --git a/components/application-connector/internal/metadata/remoteenv/repository.go b/components/application-connector/internal/metadata/remoteenv/repository.go new file mode 100644 index 000000000000..46ce9a7805ca --- /dev/null +++ b/components/application-connector/internal/metadata/remoteenv/repository.go @@ -0,0 +1,182 @@ +// Package remoteenv contains components for accessing/modifying Remote Environment CRD +package remoteenv + +import ( + "fmt" + + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + specAPIType = "API" + specEventsType = "Events" +) + +// Manager contains operations for managing Remote Environment CRD +type Manager interface { + Update(*v1alpha1.RemoteEnvironment) (*v1alpha1.RemoteEnvironment, error) + Get(name string, options v1.GetOptions) (*v1alpha1.RemoteEnvironment, error) +} + +type repository struct { + reManager Manager +} + +// ServiceAPI stores information needed to call an API +type ServiceAPI struct { + GatewayURL string + AccessLabel string + TargetUrl string + OauthUrl string + CredentialsSecretName string +} + +// Service represents a service stored in Remote Environment RE +type Service struct { + // Mapped to id in Remote Environment CRD + ID string + // Mapped to displayName in Remote Environment CRD + DisplayName string + // Mapped to longDescription in Remote Environment CRD + LongDescription string + // Mapped to providerDisplayName in Remote Environment CRD + ProviderDisplayName string + // Mapped to tags in Remote Environment CRD + Tags []string + // Mapped to type property under entries element (type: API) + API *ServiceAPI + // Mapped to type property under entries element (type: Events) + Events bool +} + +// ServiceRepository contains operations for managing services stored in Remote Environment CRD +type ServiceRepository interface { + Create(remoteEnvironment string, service Service) apperrors.AppError + Get(remoteEnvironment, id string) (Service, apperrors.AppError) + GetAll(remoteEnvironment string) ([]Service, apperrors.AppError) + Update(remoteEnvironment string, service Service) apperrors.AppError + Delete(remoteEnvironment, id string) apperrors.AppError +} + +// NewServiceRepository creates a new RemoteEnvironmentServiceRepository +func NewServiceRepository(reManager Manager) ServiceRepository { + return &repository{reManager: reManager} +} + +// Create adds a new Service in Remote Environment +func (r *repository) Create(remoteEnvironment string, service Service) apperrors.AppError { + re, err := r.getRemoteEnvironment(remoteEnvironment) + if err != nil { + return err + } + + err = ensureServiceNotExists(service.ID, re) + if err != nil { + return err + } + + re.Spec.Services = append(re.Spec.Services, convertToK8sType(service)) + + _, e := r.reManager.Update(re) + if e != nil { + return apperrors.Internal(fmt.Sprintf("failed to create service: %s", e.Error())) + } + + return nil +} + +// Get reads Service from Remote Environment by service id +func (r *repository) Get(remoteEnvironment, id string) (Service, apperrors.AppError) { + re, err := r.getRemoteEnvironment(remoteEnvironment) + if err != nil { + return Service{}, err + } + + for _, service := range re.Spec.Services { + if service.ID == id { + return convertFromK8sType(service) + } + } + + return Service{}, apperrors.NotFound(fmt.Sprintf("Service with ID %s not found", id)) +} + +// GetAll gets slice of services defined in Remote Environment +func (r *repository) GetAll(remoteEnvironment string) ([]Service, apperrors.AppError) { + re, err := r.getRemoteEnvironment(remoteEnvironment) + if err != nil { + return nil, err + } + + services := make([]Service, len(re.Spec.Services)) + for i, service := range re.Spec.Services { + s, err := convertFromK8sType(service) + if err != nil { + return nil, err + } + services[i] = s + } + + return services, nil +} + +// Update updates a given service defined in Remote Environment +func (r *repository) Update(remoteEnvironment string, service Service) apperrors.AppError { + re, err := r.getRemoteEnvironment(remoteEnvironment) + if err != nil { + return err + } + + err = ensureServiceExists(service.ID, re) + if err != nil { + return err + } + + replaceService(service.ID, re, convertToK8sType(service)) + + _, e := r.reManager.Update(re) + if e != nil { + return apperrors.Internal(fmt.Sprintf("failed to update service: %s", e.Error())) + } + + return nil +} + +// Delete deletes a given service defined in Remote Environment +func (r *repository) Delete(remoteEnvironment, id string) apperrors.AppError { + re, err := r.getRemoteEnvironment(remoteEnvironment) + if err != nil { + return err + } + + if !serviceExists(id, re) { + return nil + } + + removeService(id, re) + + _, e := r.reManager.Update(re) + if e != nil { + return apperrors.Internal(fmt.Sprintf("failed to delete service: %s", e.Error())) + } + + return nil +} + +func (r *repository) getRemoteEnvironment(remoteEnvironment string) (*v1alpha1.RemoteEnvironment, apperrors.AppError) { + re, err := r.reManager.Get(remoteEnvironment, v1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + message := fmt.Sprintf("Remote environment: %s not found.", remoteEnvironment) + return nil, apperrors.Internal(message) + } + + message := fmt.Sprintf("failed to get remote environment '%s' : %s", remoteEnvironment, err.Error()) + return nil, apperrors.Internal(message) + } + + return re, nil +} diff --git a/components/application-connector/internal/metadata/remoteenv/repository_test.go b/components/application-connector/internal/metadata/remoteenv/repository_test.go new file mode 100644 index 000000000000..fb7ae0ebbef8 --- /dev/null +++ b/components/application-connector/internal/metadata/remoteenv/repository_test.go @@ -0,0 +1,507 @@ +package remoteenv_test + +import ( + "errors" + "testing" + + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/remoteenv" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/remoteenv/mocks" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestGetServices(t *testing.T) { + t.Run("should get all services", func(t *testing.T) { + // given + remoteEnvironment := createRemoteEnvironment("production") + reManagerMock := &mocks.RemoteEnvironmentManager{} + reManagerMock.On("Get", "production", metav1.GetOptions{}). + Return(remoteEnvironment, nil) + + repository := remoteenv.NewServiceRepository(reManagerMock) + require.NotNil(t, repository) + + // when + services, err := repository.GetAll("production") + + // then + require.NoError(t, err) + require.NotNil(t, services) + + assert.Equal(t, len(services), 2) + s1 := services[0] + + assert.Equal(t, s1.ProviderDisplayName, "SAP Hybris") + assert.Equal(t, s1.DisplayName, "Orders API") + assert.Equal(t, s1.LongDescription, "This is Orders API") + assert.Equal(t, s1.API, &remoteenv.ServiceAPI{ + GatewayURL: "https://orders-gateway.production.svc.cluster.local/", + AccessLabel: "access-label-1", + TargetUrl: "https://192.168.1.2", + OauthUrl: "https://192.168.1.3/token", + CredentialsSecretName: "re-ac031e8c-9aa4-4cb7-8999-0d358726ffaa", + }) + + s2 := services[1] + + assert.Equal(t, s2.ProviderDisplayName, "SAP Hybris") + assert.Equal(t, s2.DisplayName, "Products API") + assert.Equal(t, s2.LongDescription, "This is Products API") + assert.Equal(t, s2.API, &remoteenv.ServiceAPI{ + GatewayURL: "https://products-gateway.production.svc.cluster.local/", + AccessLabel: "access-label-2", + TargetUrl: "https://192.168.1.3", + OauthUrl: "https://192.168.1.4/token", + CredentialsSecretName: "re-bc031e8c-9aa4-4cb7-8999-0d358726ffab", + }) + }) + + t.Run("should fail if unable to read RE", func(t *testing.T) { + // given + reManagerMock := &mocks.RemoteEnvironmentManager{} + reManagerMock.On("Get", "re", metav1.GetOptions{}). + Return(nil, errors.New("failed to get RE")) + + repository := remoteenv.NewServiceRepository(reManagerMock) + require.NotNil(t, repository) + + // when + services, err := repository.GetAll("re") + + // then + require.Nil(t, services) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + }) + + t.Run("should fail if RE doesn't exist", func(t *testing.T) { + // given + reManagerMock := &mocks.RemoteEnvironmentManager{} + reManagerMock.On("Get", "not_existent", metav1.GetOptions{}). + Return(nil, k8serrors.NewNotFound(schema.GroupResource{}, "")) + + repository := remoteenv.NewServiceRepository(reManagerMock) + require.NotNil(t, repository) + + // when + services, err := repository.GetAll("not_existent") + + // then + require.Nil(t, services) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + + // when + service, err := repository.Get("not_existent", "id1") + + // then + require.Error(t, err) + assert.Equal(t, remoteenv.Service{}, service) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + }) + + t.Run("should get service by id", func(t *testing.T) { + // given + remoteEnvironment := createRemoteEnvironment("production") + reManagerMock := &mocks.RemoteEnvironmentManager{} + reManagerMock.On("Get", "production", metav1.GetOptions{}). + Return(remoteEnvironment, nil) + + repository := remoteenv.NewServiceRepository(reManagerMock) + require.NotNil(t, repository) + + // when + service, err := repository.Get("production", "id1") + + // then + require.NotNil(t, service) + require.NoError(t, err) + + assert.Equal(t, service.ProviderDisplayName, "SAP Hybris") + assert.Equal(t, service.DisplayName, "Orders API") + assert.Equal(t, service.LongDescription, "This is Orders API") + assert.Equal(t, service.API, &remoteenv.ServiceAPI{ + GatewayURL: "https://orders-gateway.production.svc.cluster.local/", + AccessLabel: "access-label-1", + TargetUrl: "https://192.168.1.2", + OauthUrl: "https://192.168.1.3/token", + CredentialsSecretName: "re-ac031e8c-9aa4-4cb7-8999-0d358726ffaa", + }) + }) + + t.Run("should return not found error if service doesn't exist", func(t *testing.T) { + // given + remoteEnvironment := createRemoteEnvironment("production") + reManagerMock := &mocks.RemoteEnvironmentManager{} + reManagerMock.On("Get", "production", metav1.GetOptions{}). + Return(remoteEnvironment, nil) + + repository := remoteenv.NewServiceRepository(reManagerMock) + require.NotNil(t, repository) + + // when + service, err := repository.Get("production", "not-existent") + + // then + assert.Equal(t, remoteenv.Service{}, service) + assert.Equal(t, apperrors.CodeNotFound, err.Code()) + }) +} + +func TestCreateServices(t *testing.T) { + t.Run("should create service", func(t *testing.T) { + // given + remoteEnvironment := createRemoteEnvironment("production") + reManagerMock := &mocks.RemoteEnvironmentManager{} + reManagerMock.On("Get", "production", metav1.GetOptions{}). + Return(remoteEnvironment, nil) + + service1 := createK8sService() + newServices := append(remoteEnvironment.Spec.Services, service1) + newRE := remoteEnvironment.DeepCopy() + + newRE.Spec.Services = newServices + reManagerMock.On("Update", newRE).Return(newRE, nil) + + repository := remoteenv.NewServiceRepository(reManagerMock) + require.NotNil(t, repository) + + newService1 := createService() + + // when + err := repository.Create("production", newService1) + + // then + require.NoError(t, err) + reManagerMock.AssertExpectations(t) + }) + + t.Run("should fail if failed to update RE", func(t *testing.T) { + // given + remoteEnvironment := createRemoteEnvironment("production") + reManagerMock := &mocks.RemoteEnvironmentManager{} + reManagerMock.On("Get", "production", metav1.GetOptions{}). + Return(remoteEnvironment, nil) + + reManagerMock.On("Update", mock.AnythingOfType("*v1alpha1.RemoteEnvironment")).Return(nil, errors.New("failed to update RE")) + + repository := remoteenv.NewServiceRepository(reManagerMock) + require.NotNil(t, repository) + + newService1 := createService() + + // when + err := repository.Create("production", newService1) + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + reManagerMock.AssertExpectations(t) + }) + + t.Run("should not allow to create service if service with the same id already exists", func(t *testing.T) { + // given + remoteEnvironment := createRemoteEnvironment("production") + reManagerMock := &mocks.RemoteEnvironmentManager{} + reManagerMock.On("Get", "production", metav1.GetOptions{}). + Return(remoteEnvironment, nil) + + repository := remoteenv.NewServiceRepository(reManagerMock) + require.NotNil(t, repository) + + newService := remoteenv.Service{ + ID: "id1", + DisplayName: "Promotions API", + LongDescription: "This is Promotions API", + Tags: []string{"promotions"}, + Events: true, + } + // when + err := repository.Create("production", newService) + + // then + assert.Equal(t, apperrors.CodeAlreadyExists, err.Code()) + }) + + t.Run("should fail if RE doesn't exist", func(t *testing.T) { + // given + reManagerMock := &mocks.RemoteEnvironmentManager{} + reManagerMock.On("Get", "production", metav1.GetOptions{}). + Return(nil, k8serrors.NewNotFound(schema.GroupResource{}, "")) + + repository := remoteenv.NewServiceRepository(reManagerMock) + require.NotNil(t, repository) + + // when + newService := remoteenv.Service{ + ID: "id1", + DisplayName: "Promotions API", + LongDescription: "This is Promotions API", + Tags: []string{"promotions"}, + Events: true, + } + err := repository.Update("production", newService) + + // then + assert.Equal(t, apperrors.CodeInternal, err.Code()) + }) +} + +func TestDeleteServices(t *testing.T) { + + t.Run("should delete service", func(t *testing.T) { + // given + remoteEnvironment := createRemoteEnvironment("production") + reManagerMock := &mocks.RemoteEnvironmentManager{} + reManagerMock.On("Get", "production", metav1.GetOptions{}). + Return(remoteEnvironment, nil) + + newRE := remoteEnvironment.DeepCopy() + + newRE.Spec.Services = remoteEnvironment.Spec.Services[1:] + reManagerMock.On("Update", newRE).Return(newRE, nil) + + repository := remoteenv.NewServiceRepository(reManagerMock) + require.NotNil(t, repository) + + // when + err := repository.Delete("production", "id1") + + // then + require.NoError(t, err) + reManagerMock.AssertExpectations(t) + }) + + t.Run("should ignore not found error if service doesn't exist", func(t *testing.T) { + // given + remoteEnvironment := createRemoteEnvironment("production") + reManagerMock := &mocks.RemoteEnvironmentManager{} + reManagerMock.On("Get", "production", metav1.GetOptions{}). + Return(remoteEnvironment, nil) + + repository := remoteenv.NewServiceRepository(reManagerMock) + require.NotNil(t, repository) + + // when + err := repository.Delete("production", "not-existent") + + // then + assert.NoError(t, err) + reManagerMock.AssertExpectations(t) + }) + + t.Run("should fail if failed to update RE", func(t *testing.T) { + // given + remoteEnvironment := createRemoteEnvironment("production") + reManagerMock := &mocks.RemoteEnvironmentManager{} + reManagerMock.On("Get", "production", metav1.GetOptions{}). + Return(remoteEnvironment, nil) + + reManagerMock.On("Update", mock.AnythingOfType("*v1alpha1.RemoteEnvironment")).Return(nil, errors.New("failed to update RE")) + + repository := remoteenv.NewServiceRepository(reManagerMock) + require.NotNil(t, repository) + + // when + err := repository.Delete("production", "id1") + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + reManagerMock.AssertExpectations(t) + }) + + t.Run("should return not found error if RE doesn't exist", func(t *testing.T) { + // given + reManagerMock := &mocks.RemoteEnvironmentManager{} + reManagerMock.On("Get", "production", metav1.GetOptions{}). + Return(nil, k8serrors.NewNotFound(schema.GroupResource{}, "")) + + repository := remoteenv.NewServiceRepository(reManagerMock) + require.NotNil(t, repository) + + // when + err := repository.Delete("production", "id1") + + // then + assert.Equal(t, apperrors.CodeInternal, err.Code()) + }) +} + +func TestUpdateServices(t *testing.T) { + t.Run("should update service", func(t *testing.T) { + // given + remoteEnvironment := createRemoteEnvironment("production") + reManagerMock := &mocks.RemoteEnvironmentManager{} + reManagerMock.On("Get", "production", metav1.GetOptions{}). + Return(remoteEnvironment, nil) + + reEntry1 := v1alpha1.Entry{ + Type: "API", + GatewayUrl: "https://promotions-gateway.production.svc.cluster.local/", + AccessLabel: "access-label-3", + TargetUrl: "https://192.168.10.10", + OauthUrl: "https://192.168.10.10/token", + CredentialsSecretName: "new_secret", + } + + reEntry2 := v1alpha1.Entry{ + Type: "Events", + } + + newRE := remoteEnvironment.DeepCopy() + newRE.Spec.Services[0].DisplayName = "Promotions API" + newRE.Spec.Services[0].ProviderDisplayName = "SAP Labs Poland" + newRE.Spec.Services[0].LongDescription = "This is Promotions API" + newRE.Spec.Services[0].Tags = []string{"promotions"} + newRE.Spec.Services[0].Entries = []v1alpha1.Entry{reEntry1, reEntry2} + + reManagerMock.On("Update", newRE).Return(newRE, nil) + + repository := remoteenv.NewServiceRepository(reManagerMock) + require.NotNil(t, repository) + + service := remoteenv.Service{ + ID: "id1", + DisplayName: "Promotions API", + LongDescription: "This is Promotions API", + ProviderDisplayName: "SAP Labs Poland", + Tags: []string{"promotions"}, + API: &remoteenv.ServiceAPI{ + GatewayURL: "https://promotions-gateway.production.svc.cluster.local/", + AccessLabel: "access-label-3", + TargetUrl: "https://192.168.10.10", + OauthUrl: "https://192.168.10.10/token", + CredentialsSecretName: "new_secret"}, + Events: true, + } + + // when + err := repository.Update("production", service) + + // then + require.NoError(t, err) + reManagerMock.AssertExpectations(t) + }) + + t.Run("should return not found error if RE doesn't exist", func(t *testing.T) { + // given + remoteEnvironment := createRemoteEnvironment("production") + reManagerMock := &mocks.RemoteEnvironmentManager{} + reManagerMock.On("Get", "production", metav1.GetOptions{}). + Return(remoteEnvironment, nil) + + repository := remoteenv.NewServiceRepository(reManagerMock) + require.NotNil(t, repository) + + // when + service := remoteenv.Service{ + ID: "not-existent", + } + err := repository.Update("production", service) + + // then + assert.Equal(t, apperrors.CodeNotFound, err.Code()) + }) +} + +func createRemoteEnvironment(name string) *v1alpha1.RemoteEnvironment { + + reService1Entry := v1alpha1.Entry{ + Type: "API", + GatewayUrl: "https://orders-gateway.production.svc.cluster.local/", + AccessLabel: "access-label-1", + TargetUrl: "https://192.168.1.2", + OauthUrl: "https://192.168.1.3/token", + CredentialsSecretName: "re-ac031e8c-9aa4-4cb7-8999-0d358726ffaa", + } + reService1 := v1alpha1.Service{ + ID: "id1", + DisplayName: "Orders API", + LongDescription: "This is Orders API", + ProviderDisplayName: "SAP Hybris", + Tags: []string{"orders"}, + Entries: []v1alpha1.Entry{reService1Entry}, + } + + reService2Entry := v1alpha1.Entry{ + Type: "API", + GatewayUrl: "https://products-gateway.production.svc.cluster.local/", + AccessLabel: "access-label-2", + TargetUrl: "https://192.168.1.3", + OauthUrl: "https://192.168.1.4/token", + CredentialsSecretName: "re-bc031e8c-9aa4-4cb7-8999-0d358726ffab", + } + + reService2 := v1alpha1.Service{ + ID: "id2", + DisplayName: "Products API", + LongDescription: "This is Products API", + ProviderDisplayName: "SAP Hybris", + Tags: []string{"products"}, + Entries: []v1alpha1.Entry{reService2Entry}, + } + + reSource1 := v1alpha1.Source{ + Environment: "production", + Type: "commerce", + Namespace: "local.kyma.commerce"} + + reSpec1 := v1alpha1.RemoteEnvironmentSpec{ + Description: "test_1", + Source: reSource1, + Services: []v1alpha1.Service{ + reService1, + reService2, + }, + } + + return &v1alpha1.RemoteEnvironment{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: reSpec1, + } +} + +func createService() remoteenv.Service { + return remoteenv.Service{ + ID: "id3", + DisplayName: "Promotions API", + LongDescription: "This is Promotions API", + ProviderDisplayName: "SAP Hybris", + Tags: []string{"promotions"}, + API: &remoteenv.ServiceAPI{ + GatewayURL: "https://promotions-gateway.production.svc.cluster.local/", + AccessLabel: "access-label-1", + TargetUrl: "https://192.168.1.2", + OauthUrl: "https://192.168.1.3/token", + CredentialsSecretName: "re-ac031e8c-9aa4-4cb7-8999-0d358726ffaa"}, + Events: true, + } +} + +func createK8sService() v1alpha1.Service { + serviceEntry1 := v1alpha1.Entry{ + Type: "API", + GatewayUrl: "https://promotions-gateway.production.svc.cluster.local/", + AccessLabel: "access-label-1", + TargetUrl: "https://192.168.1.2", + OauthUrl: "https://192.168.1.3/token", + CredentialsSecretName: "re-ac031e8c-9aa4-4cb7-8999-0d358726ffaa", + } + serviceEntry2 := v1alpha1.Entry{ + Type: "Events", + } + + return v1alpha1.Service{ + ID: "id3", + DisplayName: "Promotions API", + LongDescription: "This is Promotions API", + ProviderDisplayName: "SAP Hybris", + Tags: []string{"promotions"}, + Entries: []v1alpha1.Entry{serviceEntry1, serviceEntry2}, + } +} diff --git a/components/application-connector/internal/metadata/remoteenv/util.go b/components/application-connector/internal/metadata/remoteenv/util.go new file mode 100644 index 000000000000..dbc9b9648caf --- /dev/null +++ b/components/application-connector/internal/metadata/remoteenv/util.go @@ -0,0 +1,124 @@ +package remoteenv + +import ( + "fmt" + + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + log "github.com/sirupsen/logrus" +) + +func convertFromK8sType(service v1alpha1.Service) (Service, apperrors.AppError) { + var api *ServiceAPI + var events bool + { + for _, entry := range service.Entries { + if entry.Type == specAPIType { + api = &ServiceAPI{ + GatewayURL: entry.GatewayUrl, + AccessLabel: entry.AccessLabel, + TargetUrl: entry.TargetUrl, + OauthUrl: entry.OauthUrl, + CredentialsSecretName: entry.CredentialsSecretName, + } + } else if entry.Type == specEventsType { + events = true + } else { + message := fmt.Sprintf("incorrect type of entry '%s' in Remote Environment Service definition", entry.Type) + log.Error(message) + return Service{}, apperrors.Internal(message) + } + } + } + + return Service{ + ID: service.ID, + DisplayName: service.DisplayName, + LongDescription: service.LongDescription, + ProviderDisplayName: service.ProviderDisplayName, + Tags: service.Tags, + API: api, + Events: events, + }, nil +} + +func convertToK8sType(service Service) v1alpha1.Service { + var serviceEntries = make([]v1alpha1.Entry, 0, 2) + if service.API != nil { + apiEntry := v1alpha1.Entry{ + Type: specAPIType, + GatewayUrl: service.API.GatewayURL, + AccessLabel: service.API.AccessLabel, + TargetUrl: service.API.TargetUrl, + OauthUrl: service.API.OauthUrl, + CredentialsSecretName: service.API.CredentialsSecretName, + } + serviceEntries = append(serviceEntries, apiEntry) + } + + if service.Events { + eventsEntry := v1alpha1.Entry{Type: specEventsType} + serviceEntries = append(serviceEntries, eventsEntry) + } + + return v1alpha1.Service{ + ID: service.ID, + DisplayName: service.DisplayName, + LongDescription: service.LongDescription, + ProviderDisplayName: service.ProviderDisplayName, + Tags: service.Tags, + Entries: serviceEntries, + } +} + +func removeService(id string, re *v1alpha1.RemoteEnvironment) { + serviceIndex := getServiceIndex(id, re) + + if serviceIndex != -1 { + copy(re.Spec.Services[serviceIndex:], re.Spec.Services[serviceIndex+1:]) + size := len(re.Spec.Services) + re.Spec.Services = re.Spec.Services[:size-1] + } +} + +func replaceService(id string, re *v1alpha1.RemoteEnvironment, service v1alpha1.Service) { + serviceIndex := getServiceIndex(id, re) + + if serviceIndex != -1 { + re.Spec.Services[serviceIndex] = service + } +} + +func ensureServiceExists(id string, re *v1alpha1.RemoteEnvironment) apperrors.AppError { + if !serviceExists(id, re) { + message := fmt.Sprintf("Service with ID %s doesn't exist.", id) + + return apperrors.NotFound(message) + } + + return nil +} + +func ensureServiceNotExists(id string, re *v1alpha1.RemoteEnvironment) apperrors.AppError { + if serviceExists(id, re) { + message := fmt.Sprintf("Service with ID %s already exists.", id) + + return apperrors.AlreadyExists(message) + } + + return nil +} + +func serviceExists(id string, re *v1alpha1.RemoteEnvironment) bool { + return getServiceIndex(id, re) != -1 +} + +func getServiceIndex(id string, re *v1alpha1.RemoteEnvironment) int { + for i, service := range re.Spec.Services { + if service.ID == id { + return i + } + } + + return -1 +} diff --git a/components/application-connector/internal/metadata/secrets/mocks/Manager.go b/components/application-connector/internal/metadata/secrets/mocks/Manager.go new file mode 100644 index 000000000000..6ff17640f096 --- /dev/null +++ b/components/application-connector/internal/metadata/secrets/mocks/Manager.go @@ -0,0 +1,95 @@ +// Code generated by mockery v1.0.0 +package mocks + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +import mock "github.com/stretchr/testify/mock" + +import v1 "k8s.io/api/core/v1" + +// Manager is an autogenerated mock type for the Manager type +type Manager struct { + mock.Mock +} + +// Create provides a mock function with given fields: _a0 +func (_m *Manager) Create(_a0 *v1.Secret) (*v1.Secret, error) { + ret := _m.Called(_a0) + + var r0 *v1.Secret + if rf, ok := ret.Get(0).(func(*v1.Secret) *v1.Secret); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1.Secret) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*v1.Secret) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: name, options +func (_m *Manager) Delete(name string, options *metav1.DeleteOptions) error { + ret := _m.Called(name, options) + + var r0 error + if rf, ok := ret.Get(0).(func(string, *metav1.DeleteOptions) error); ok { + r0 = rf(name, options) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Get provides a mock function with given fields: name, options +func (_m *Manager) Get(name string, options metav1.GetOptions) (*v1.Secret, error) { + ret := _m.Called(name, options) + + var r0 *v1.Secret + if rf, ok := ret.Get(0).(func(string, metav1.GetOptions) *v1.Secret); ok { + r0 = rf(name, options) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1.Secret) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, metav1.GetOptions) error); ok { + r1 = rf(name, options) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Update provides a mock function with given fields: _a0 +func (_m *Manager) Update(_a0 *v1.Secret) (*v1.Secret, error) { + ret := _m.Called(_a0) + + var r0 *v1.Secret + if rf, ok := ret.Get(0).(func(*v1.Secret) *v1.Secret); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1.Secret) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*v1.Secret) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/application-connector/internal/metadata/secrets/mocks/Repository.go b/components/application-connector/internal/metadata/secrets/mocks/Repository.go new file mode 100644 index 000000000000..2a3348e3f54d --- /dev/null +++ b/components/application-connector/internal/metadata/secrets/mocks/Repository.go @@ -0,0 +1,88 @@ +// Code generated by mockery v1.0.0 +package mocks + +import apperrors "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" +import mock "github.com/stretchr/testify/mock" + +// Repository is an autogenerated mock type for the Repository type +type Repository struct { + mock.Mock +} + +// Create provides a mock function with given fields: remoteEnvironment, name, clientID, clientSecret, serviceID +func (_m *Repository) Create(remoteEnvironment string, name string, clientID string, clientSecret string, serviceID string) apperrors.AppError { + ret := _m.Called(remoteEnvironment, name, clientID, clientSecret, serviceID) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string, string, string, string, string) apperrors.AppError); ok { + r0 = rf(remoteEnvironment, name, clientID, clientSecret, serviceID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} + +// Delete provides a mock function with given fields: name +func (_m *Repository) Delete(name string) apperrors.AppError { + ret := _m.Called(name) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string) apperrors.AppError); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} + +// Get provides a mock function with given fields: remoteEnvironment, name +func (_m *Repository) Get(remoteEnvironment string, name string) (string, string, apperrors.AppError) { + ret := _m.Called(remoteEnvironment, name) + + var r0 string + if rf, ok := ret.Get(0).(func(string, string) string); ok { + r0 = rf(remoteEnvironment, name) + } else { + r0 = ret.Get(0).(string) + } + + var r1 string + if rf, ok := ret.Get(1).(func(string, string) string); ok { + r1 = rf(remoteEnvironment, name) + } else { + r1 = ret.Get(1).(string) + } + + var r2 apperrors.AppError + if rf, ok := ret.Get(2).(func(string, string) apperrors.AppError); ok { + r2 = rf(remoteEnvironment, name) + } else { + if ret.Get(2) != nil { + r2 = ret.Get(2).(apperrors.AppError) + } + } + + return r0, r1, r2 +} + +// Upsert provides a mock function with given fields: remoteEnvironment, name, clientID, clientSecret, secretID +func (_m *Repository) Upsert(remoteEnvironment string, name string, clientID string, clientSecret string, secretID string) apperrors.AppError { + ret := _m.Called(remoteEnvironment, name, clientID, clientSecret, secretID) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string, string, string, string, string) apperrors.AppError); ok { + r0 = rf(remoteEnvironment, name, clientID, clientSecret, secretID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} diff --git a/components/application-connector/internal/metadata/secrets/repository.go b/components/application-connector/internal/metadata/secrets/repository.go new file mode 100644 index 000000000000..12c48d9c2b59 --- /dev/null +++ b/components/application-connector/internal/metadata/secrets/repository.go @@ -0,0 +1,109 @@ +// Package secrets contains components for accessing/modifying client secrets +package secrets + +import ( + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + "github.com/kyma-project/kyma/components/application-connector/internal/k8sconsts" + "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + ClientIDKey = "clientId" + ClientSecretKey = "clientSecret" +) + +// Repository contains operations for managing client credentials +type Repository interface { + Create(remoteEnvironment, name, clientID, clientSecret, serviceID string) apperrors.AppError + Get(remoteEnvironment, name string) (string, string, apperrors.AppError) + Delete(name string) apperrors.AppError + Upsert(remoteEnvironment, name, clientID, clientSecret, secretID string) apperrors.AppError +} + +type repository struct { + secretsManager Manager +} + +// Manager contains operations for managing k8s secrets +type Manager interface { + Create(secret *v1.Secret) (*v1.Secret, error) + Get(name string, options metav1.GetOptions) (*v1.Secret, error) + Delete(name string, options *metav1.DeleteOptions) error + Update(secret *v1.Secret) (*v1.Secret, error) +} + +// NewRepository creates a new secrets repository +func NewRepository(secretsManager Manager) Repository { + return &repository{ + secretsManager: secretsManager, + } +} + +// Create adds a new secret with one entry containing specified clientId and clientSecret +func (r *repository) Create(remoteEnvironment, name, clientID, clientSecret, serviceID string) apperrors.AppError { + secret := makeSecret(name, clientID, clientSecret, serviceID, remoteEnvironment) + return r.create(remoteEnvironment, secret, name) +} + +func (r *repository) Get(remoteEnvironment, name string) (clientId string, clientSecret string, error apperrors.AppError) { + secret, err := r.secretsManager.Get(name, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return "", "", apperrors.NotFound("secret %s not found", name) + } + return "", "", apperrors.Internal("failed to get %s secret, %s", name, err) + } + + return string(secret.Data[ClientIDKey]), string(secret.Data[ClientSecretKey]), nil +} + +func (r *repository) Delete(name string) apperrors.AppError { + err := r.secretsManager.Delete(name, &metav1.DeleteOptions{}) + if err != nil && !k8serrors.IsNotFound(err) { + return apperrors.Internal("failed to delete %s secret, %s", name, err) + } + return nil +} + +func (r *repository) Upsert(remoteEnvironment, name, clientID, clientSecret, serviceID string) apperrors.AppError { + secret := makeSecret(name, clientID, clientSecret, serviceID, remoteEnvironment) + + _, err := r.secretsManager.Update(secret) + if err != nil { + if k8serrors.IsNotFound(err) { + return r.create(remoteEnvironment, secret, name) + } + return apperrors.Internal("failed to update %s secret, %s", name, err) + } + return nil +} + +func (r *repository) create(remoteEnvironment string, secret *v1.Secret, name string) apperrors.AppError { + _, err := r.secretsManager.Create(secret) + if err != nil { + if k8serrors.IsAlreadyExists(err) { + return apperrors.AlreadyExists("secret %s already exists.", name) + } + return apperrors.Internal("failed to create %s secret, %s", name, err) + } + return nil +} + +func makeSecret(name, clientID, clientSecret, serviceID, remoteEnvironment string) *v1.Secret { + secretMap := make(map[string][]byte) + secretMap[ClientIDKey] = []byte(clientID) + secretMap[ClientSecretKey] = []byte(clientSecret) + + return &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + k8sconsts.LabelRemoteEnvironment: remoteEnvironment, + k8sconsts.LabelServiceId: serviceID, + }, + }, + Data: secretMap, + } +} diff --git a/components/application-connector/internal/metadata/secrets/repository_test.go b/components/application-connector/internal/metadata/secrets/repository_test.go new file mode 100644 index 000000000000..a3b9f58ef6cf --- /dev/null +++ b/components/application-connector/internal/metadata/secrets/repository_test.go @@ -0,0 +1,270 @@ +package secrets + +import ( + "errors" + "testing" + + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/secrets/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestRepository_Create(t *testing.T) { + + t.Run("should create secret", func(t *testing.T) { + // given + secretsManagerMock := &mocks.Manager{} + repository := NewRepository(secretsManagerMock) + + secret := makeSecret("new-secret", "CLIENT_ID", "CLIENT_SECRET", "secretId", "re") + secretsManagerMock.On("Create", secret).Return(secret, nil) + + // when + err := repository.Create("re", "new-secret", "CLIENT_ID", "CLIENT_SECRET", "secretId") + + // then + assert.NoError(t, err) + secretsManagerMock.AssertExpectations(t) + }) + + t.Run("should fail if unable to create secret", func(t *testing.T) { + // given + secretsManagerMock := &mocks.Manager{} + repository := NewRepository(secretsManagerMock) + + secret := makeSecret("new-secret", "CLIENT_ID", "CLIENT_SECRET", "secretId", "re") + secretsManagerMock.On("Create", secret).Return(nil, errors.New("some error")) + + // when + err := repository.Create("re", "new-secret", "CLIENT_ID", "CLIENT_SECRET", "secretId") + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + secretsManagerMock.AssertExpectations(t) + }) + + t.Run("should return already exists if secret was already created", func(t *testing.T) { + // given + secretsManagerMock := &mocks.Manager{} + repository := NewRepository(secretsManagerMock) + + secret := makeSecret("new-secret", "CLIENT_ID", "CLIENT_SECRET", "secretId", "re") + secretsManagerMock.On("Create", secret).Return(nil, k8serrors.NewAlreadyExists(schema.GroupResource{}, "")) + + // when + err := repository.Create("re", "new-secret", "CLIENT_ID", "CLIENT_SECRET", "secretId") + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeAlreadyExists, err.Code()) + secretsManagerMock.AssertExpectations(t) + }) +} + +func TestRepository_Get(t *testing.T) { + t.Run("should get given secret", func(t *testing.T) { + // given + secretsManagerMock := &mocks.Manager{} + repository := NewRepository(secretsManagerMock) + + secret := makeSecret("new-secret", "CLIENT_ID", "CLIENT_SECRET", "secretId", "re") + secretsManagerMock.On("Get", "new-secret", metav1.GetOptions{}).Return(secret, nil) + + // when + clientId, clientSecret, err := repository.Get("re", "new-secret") + + // then + assert.NoError(t, err) + assert.Equal(t, "CLIENT_ID", clientId) + assert.Equal(t, "CLIENT_SECRET", clientSecret) + + secretsManagerMock.AssertExpectations(t) + }) + + t.Run("should return an error in case fetching fails", func(t *testing.T) { + // given + secretsManagerMock := &mocks.Manager{} + repository := NewRepository(secretsManagerMock) + + secretsManagerMock.On("Get", "secret-name", metav1.GetOptions{}).Return( + nil, + errors.New("some error")) + + // when + clientId, clientSecret, err := repository.Get("re", "secret-name") + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.NotEmpty(t, err.Error()) + + assert.Equal(t, "", clientId) + assert.Equal(t, "", clientSecret) + + secretsManagerMock.AssertExpectations(t) + }) + + t.Run("should return not found if secret does not exist", func(t *testing.T) { + // given + secretsManagerMock := &mocks.Manager{} + repository := NewRepository(secretsManagerMock) + + secretsManagerMock.On("Get", "secret-name", metav1.GetOptions{}).Return( + nil, + k8serrors.NewNotFound(schema.GroupResource{}, + "")) + + // when + clientId, clientSecret, err := repository.Get("re", "secret-name") + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeNotFound, err.Code()) + assert.NotEmpty(t, err.Error()) + + assert.Equal(t, "", clientId) + assert.Equal(t, "", clientSecret) + + secretsManagerMock.AssertExpectations(t) + }) +} + +func TestRepository_Delete(t *testing.T) { + + t.Run("should delete given secret", func(t *testing.T) { + // given + secretsManagerMock := &mocks.Manager{} + repository := NewRepository(secretsManagerMock) + + secretsManagerMock.On("Delete", "test-secret", &metav1.DeleteOptions{}).Return( + nil) + + // when + err := repository.Delete("test-secret") + + // then + assert.NoError(t, err) + secretsManagerMock.AssertExpectations(t) + + }) + + t.Run("should return error if deletion fails", func(t *testing.T) { + // given + secretsManagerMock := &mocks.Manager{} + repository := NewRepository(secretsManagerMock) + + secretsManagerMock.On("Delete", "test-secret", &metav1.DeleteOptions{}).Return( + errors.New("some error")) + + // when + err := repository.Delete("test-secret") + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.NotEmpty(t, err.Error()) + + secretsManagerMock.AssertExpectations(t) + }) + + t.Run("should not return error if secret does not exist", func(t *testing.T) { + // given + secretsManagerMock := &mocks.Manager{} + repository := NewRepository(secretsManagerMock) + + secretsManagerMock.On("Delete", "test-secret", &metav1.DeleteOptions{}).Return( + k8serrors.NewNotFound(schema.GroupResource{}, "")) + + // when + err := repository.Delete("test-secret") + + // then + assert.NoError(t, err) + secretsManagerMock.AssertExpectations(t) + }) +} + +func TestRepository_Upsert(t *testing.T) { + + t.Run("should update secret if it exists", func(t *testing.T) { + // given + secretsManagerMock := &mocks.Manager{} + repository := NewRepository(secretsManagerMock) + + secret := makeSecret("new-secret", "CLIENT_ID", "CLIENT_SECRET", "secretId", "re") + secretsManagerMock.On("Update", secret).Return( + secret, nil) + + // when + err := repository.Upsert("re", "new-secret", "CLIENT_ID", "CLIENT_SECRET", "secretId") + + // then + assert.NoError(t, err) + secretsManagerMock.AssertExpectations(t) + }) + + t.Run("should create secret if it does not exist", func(t *testing.T) { + // given + secretsManagerMock := &mocks.Manager{} + repository := NewRepository(secretsManagerMock) + + secret := makeSecret("new-secret", "CLIENT_ID", "CLIENT_SECRET", "secretId", "re") + secretsManagerMock.On("Update", secret).Return( + nil, k8serrors.NewNotFound(schema.GroupResource{}, "")) + secretsManagerMock.On("Create", secret).Return(secret, nil) + + // when + err := repository.Upsert("re", "new-secret", "CLIENT_ID", "CLIENT_SECRET", "secretId") + + // then + assert.NoError(t, err) + secretsManagerMock.AssertExpectations(t) + }) + + t.Run("should return an error if update fails", func(t *testing.T) { + // given + secretsManagerMock := &mocks.Manager{} + repository := NewRepository(secretsManagerMock) + + secret := makeSecret("new-secret", "CLIENT_ID", "CLIENT_SECRET", "secretId", "re") + secretsManagerMock.On("Update", secret).Return(nil, errors.New("some error")) + + // when + err := repository.Upsert("re", "new-secret", "CLIENT_ID", "CLIENT_SECRET", "secretId") + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.NotEmpty(t, err.Error()) + + secretsManagerMock.AssertNotCalled(t, "Create", mock.AnythingOfType("*v1.Secret")) + secretsManagerMock.AssertExpectations(t) + }) + + t.Run("should return an error if create fails", func(t *testing.T) { + // given + secretsManagerMock := &mocks.Manager{} + repository := NewRepository(secretsManagerMock) + + secret := makeSecret("new-secret", "CLIENT_ID", "CLIENT_SECRET", "secretId", "re") + secretsManagerMock.On("Update", secret).Return( + nil, k8serrors.NewNotFound(schema.GroupResource{}, "")) + secretsManagerMock.On("Create", secret).Return(secret, errors.New("some error")) + + // when + err := repository.Upsert("re", "new-secret", "CLIENT_ID", "CLIENT_SECRET", "secretId") + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.NotEmpty(t, err.Error()) + + secretsManagerMock.AssertExpectations(t) + }) +} diff --git a/components/application-connector/internal/metadata/serviceapi/mocks/Service.go b/components/application-connector/internal/metadata/serviceapi/mocks/Service.go new file mode 100644 index 000000000000..367fb8d26c60 --- /dev/null +++ b/components/application-connector/internal/metadata/serviceapi/mocks/Service.go @@ -0,0 +1,103 @@ +// Code generated by mockery v1.0.0 +package mocks + +import apperrors "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" +import mock "github.com/stretchr/testify/mock" +import remoteenv "github.com/kyma-project/kyma/components/application-connector/internal/metadata/remoteenv" +import serviceapi "github.com/kyma-project/kyma/components/application-connector/internal/metadata/serviceapi" + +// Service is an autogenerated mock type for the Service type +type Service struct { + mock.Mock +} + +// Delete provides a mock function with given fields: remoteEnvironment, id +func (_m *Service) Delete(remoteEnvironment string, id string) apperrors.AppError { + ret := _m.Called(remoteEnvironment, id) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(string, string) apperrors.AppError); ok { + r0 = rf(remoteEnvironment, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} + +// New provides a mock function with given fields: remoteEnvironment, id, api +func (_m *Service) New(remoteEnvironment string, id string, api *serviceapi.API) (*remoteenv.ServiceAPI, apperrors.AppError) { + ret := _m.Called(remoteEnvironment, id, api) + + var r0 *remoteenv.ServiceAPI + if rf, ok := ret.Get(0).(func(string, string, *serviceapi.API) *remoteenv.ServiceAPI); ok { + r0 = rf(remoteEnvironment, id, api) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*remoteenv.ServiceAPI) + } + } + + var r1 apperrors.AppError + if rf, ok := ret.Get(1).(func(string, string, *serviceapi.API) apperrors.AppError); ok { + r1 = rf(remoteEnvironment, id, api) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(apperrors.AppError) + } + } + + return r0, r1 +} + +// Read provides a mock function with given fields: remoteEnvironment, serviceApi +func (_m *Service) Read(remoteEnvironment string, serviceApi *remoteenv.ServiceAPI) (*serviceapi.API, apperrors.AppError) { + ret := _m.Called(remoteEnvironment, serviceApi) + + var r0 *serviceapi.API + if rf, ok := ret.Get(0).(func(string, *remoteenv.ServiceAPI) *serviceapi.API); ok { + r0 = rf(remoteEnvironment, serviceApi) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*serviceapi.API) + } + } + + var r1 apperrors.AppError + if rf, ok := ret.Get(1).(func(string, *remoteenv.ServiceAPI) apperrors.AppError); ok { + r1 = rf(remoteEnvironment, serviceApi) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(apperrors.AppError) + } + } + + return r0, r1 +} + +// Update provides a mock function with given fields: remoteEnvironment, id, api +func (_m *Service) Update(remoteEnvironment string, id string, api *serviceapi.API) (*remoteenv.ServiceAPI, apperrors.AppError) { + ret := _m.Called(remoteEnvironment, id, api) + + var r0 *remoteenv.ServiceAPI + if rf, ok := ret.Get(0).(func(string, string, *serviceapi.API) *remoteenv.ServiceAPI); ok { + r0 = rf(remoteEnvironment, id, api) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*remoteenv.ServiceAPI) + } + } + + var r1 apperrors.AppError + if rf, ok := ret.Get(1).(func(string, string, *serviceapi.API) apperrors.AppError); ok { + r1 = rf(remoteEnvironment, id, api) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(apperrors.AppError) + } + } + + return r0, r1 +} diff --git a/components/application-connector/internal/metadata/serviceapi/serviceapiservice.go b/components/application-connector/internal/metadata/serviceapi/serviceapiservice.go new file mode 100644 index 000000000000..087b99f45b15 --- /dev/null +++ b/components/application-connector/internal/metadata/serviceapi/serviceapiservice.go @@ -0,0 +1,187 @@ +package serviceapi + +import ( + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + "github.com/kyma-project/kyma/components/application-connector/internal/k8sconsts" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/accessservice" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/istio" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/remoteenv" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/secrets" +) + +// API is an internal representation of a service's API. +type API struct { + // TargetUrl points to API. + TargetUrl string + // Credentials is a credentials of API. + Credentials *Credentials + // Spec contains specification of an API. + Spec []byte +} + +// Credentials contains OAuth configuration. +type Credentials struct { + // Oauth is OAuth configuration. + Oauth Oauth +} + +// Oauth contains details of OAuth configuration +type Oauth struct { + // URL to OAuth token provider. + URL string + // ClientID to use for authentication. + ClientID string + // ClientSecret to use for authentication. + ClientSecret string +} + +// Service manages API definition of a service +type Service interface { + // New handles a new API. It creates all requires resources. + New(remoteEnvironment, id string, api *API) (*remoteenv.ServiceAPI, apperrors.AppError) + // Read reads API from Remote Environment API definition. It also reads all additional information. + Read(remoteEnvironment string, serviceApi *remoteenv.ServiceAPI) (*API, apperrors.AppError) + // Delete removes API with given id. + Delete(remoteEnvironment, id string) apperrors.AppError + // Update replaces existing API with a new one. + Update(remoteEnvironment, id string, api *API) (*remoteenv.ServiceAPI, apperrors.AppError) +} + +type defaultService struct { + nameResolver k8sconsts.NameResolver + accessServiceManager accessservice.AccessServiceManager + secretsRepository secrets.Repository + istioService istio.Service +} + +func NewService( + nameResolver k8sconsts.NameResolver, + accessServiceManager accessservice.AccessServiceManager, + secretsRepository secrets.Repository, + istioService istio.Service) Service { + + return defaultService{ + nameResolver: nameResolver, + accessServiceManager: accessServiceManager, + secretsRepository: secretsRepository, + istioService: istioService, + } +} + +func (sas defaultService) New(remoteEnvironment, id string, api *API) (*remoteenv.ServiceAPI, apperrors.AppError) { + resourceName := sas.nameResolver.GetResourceName(remoteEnvironment, id) + gatewayUrl := sas.nameResolver.GetMetadataUrl(remoteEnvironment, id) + + serviceAPI := &remoteenv.ServiceAPI{} + serviceAPI.TargetUrl = api.TargetUrl + serviceAPI.GatewayURL = gatewayUrl + + err := sas.accessServiceManager.Create(remoteEnvironment, id, resourceName) + if err != nil { + return nil, apperrors.Internal("failed to create access service, %s", err) + } + + serviceAPI.AccessLabel = resourceName + + if sas.oauthCredentialsProvided(api.Credentials) { + err := sas.secretsRepository.Create(remoteEnvironment, resourceName, api.Credentials.Oauth.ClientID, api.Credentials.Oauth.ClientSecret, id) + if err != nil { + return nil, apperrors.Internal("failed to create credentials secret, %s", err) + } + serviceAPI.OauthUrl = api.Credentials.Oauth.URL + serviceAPI.CredentialsSecretName = resourceName + } + + err = sas.istioService.Create(remoteEnvironment, id, resourceName) + if err != nil { + return nil, apperrors.Internal("failed to create istio resources, %s", err) + } + + return serviceAPI, nil +} + +func (sas defaultService) Read(remoteEnvironment string, remoteenvAPI *remoteenv.ServiceAPI) (*API, apperrors.AppError) { + api := &API{ + TargetUrl: remoteenvAPI.TargetUrl, + } + + if remoteenvAPI.OauthUrl != "" && remoteenvAPI.CredentialsSecretName != "" { + api.Credentials = &Credentials{ + Oauth: Oauth{ + URL: remoteenvAPI.OauthUrl, + }, + } + + clientId, clientSecret, err := sas.secretsRepository.Get(remoteEnvironment, remoteenvAPI.CredentialsSecretName) + if err != nil { + return nil, apperrors.Internal("failed to read oauth credentials from %s secret, %s", + remoteenvAPI.CredentialsSecretName, err.Error()) + } + api.Credentials.Oauth.ClientID = clientId + api.Credentials.Oauth.ClientSecret = clientSecret + } + + return api, nil +} + +func (sas defaultService) Delete(remoteEnvironment, id string) apperrors.AppError { + resourceName := sas.nameResolver.GetResourceName(remoteEnvironment, id) + + err := sas.accessServiceManager.Delete(resourceName) + if err != nil { + return apperrors.Internal("failed to delete access service, %s", err) + } + + err = sas.secretsRepository.Delete(resourceName) + if err != nil { + return apperrors.Internal("failed to delete credentials secret, %s", err) + } + + err = sas.istioService.Delete(resourceName) + if err != nil { + return apperrors.Internal("failed to delete istio resources, %s", err) + } + + return nil +} + +func (sas defaultService) Update(remoteEnvironment, id string, api *API) (*remoteenv.ServiceAPI, apperrors.AppError) { + resourceName := sas.nameResolver.GetResourceName(remoteEnvironment, id) + gatewayUrl := sas.nameResolver.GetMetadataUrl(remoteEnvironment, id) + + serviceAPI := &remoteenv.ServiceAPI{} + serviceAPI.TargetUrl = api.TargetUrl + serviceAPI.GatewayURL = gatewayUrl + + err := sas.accessServiceManager.Upsert(remoteEnvironment, id, resourceName) + if err != nil { + return nil, apperrors.Internal("failed to create access service, %s", err) + } + + serviceAPI.AccessLabel = resourceName + + if sas.oauthCredentialsProvided(api.Credentials) { + err = sas.secretsRepository.Upsert(remoteEnvironment, resourceName, api.Credentials.Oauth.ClientID, api.Credentials.Oauth.ClientSecret, id) + if err != nil { + return nil, apperrors.Internal("failed to update credentials secret, %s", err) + } + serviceAPI.OauthUrl = api.Credentials.Oauth.URL + serviceAPI.CredentialsSecretName = resourceName + } else { + err := sas.secretsRepository.Delete(resourceName) + if err != nil { + return nil, apperrors.Internal("failed to delete credentials secret, %s", err) + } + } + + err = sas.istioService.Upsert(remoteEnvironment, id, resourceName) + if err != nil { + return nil, apperrors.Internal("failed to update istio resources, %s", err) + } + + return serviceAPI, nil +} + +func (sas defaultService) oauthCredentialsProvided(credentials *Credentials) bool { + return credentials != nil && credentials.Oauth.ClientID != "" && credentials.Oauth.ClientSecret != "" +} diff --git a/components/application-connector/internal/metadata/serviceapi/serviceapiservice_test.go b/components/application-connector/internal/metadata/serviceapi/serviceapiservice_test.go new file mode 100644 index 000000000000..c80c569d0136 --- /dev/null +++ b/components/application-connector/internal/metadata/serviceapi/serviceapiservice_test.go @@ -0,0 +1,679 @@ +package serviceapi + +import ( + "testing" + + k8smocks "github.com/kyma-project/kyma/components/application-connector/internal/k8sconsts/mocks" + asmocks "github.com/kyma-project/kyma/components/application-connector/internal/metadata/accessservice/mocks" + istiomocks "github.com/kyma-project/kyma/components/application-connector/internal/metadata/istio/mocks" + secretsmocks "github.com/kyma-project/kyma/components/application-connector/internal/metadata/secrets/mocks" + + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/remoteenv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + resourceName = "resource-uuid-1" + gatewayUrl = "url-uuid-1" +) + +var ( + anyString = mock.AnythingOfType("string") +) + +func TestNewService(t *testing.T) { + t.Run("should add all required components for API with credentials", func(t *testing.T) { + // given + api := &API{ + TargetUrl: "http://target.com", + Credentials: &Credentials{ + Oauth: Oauth{ + URL: "http://oauth.com", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + } + + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("GetResourceName", "re", "uuid-1").Return(resourceName) + nameResolver.On("GetMetadataUrl", "re", "uuid-1").Return(gatewayUrl) + + accessServiceManager := new(asmocks.AccessServiceManager) + accessServiceManager.On("Create", "re", "uuid-1", resourceName).Return(nil) + + secretsRepository := new(secretsmocks.Repository) + secretsRepository.On( + "Create", + "re", + resourceName, + api.Credentials.Oauth.ClientID, + api.Credentials.Oauth.ClientSecret, + "uuid-1", + ).Return(nil) + + istioService := new(istiomocks.Service) + istioService.On("Create", "re", "uuid-1", resourceName).Return(nil) + + service := NewService(nameResolver, accessServiceManager, secretsRepository, istioService) + + // when + remoteEnvServiceAPI, err := service.New("re", "uuid-1", api) + + // then + require.NoError(t, err) + assert.Equal(t, gatewayUrl, remoteEnvServiceAPI.GatewayURL) + assert.Equal(t, resourceName, remoteEnvServiceAPI.AccessLabel) + assert.Equal(t, api.TargetUrl, remoteEnvServiceAPI.TargetUrl) + assert.Equal(t, api.Credentials.Oauth.URL, remoteEnvServiceAPI.OauthUrl) + assert.Equal(t, resourceName, remoteEnvServiceAPI.CredentialsSecretName) + + accessServiceManager.AssertExpectations(t) + secretsRepository.AssertExpectations(t) + istioService.AssertExpectations(t) + }) + + t.Run("should add all required components for API without credentials", func(t *testing.T) { + // given + api := &API{ + TargetUrl: "http://target.com", + } + + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("GetResourceName", "re", "uuid-1").Return(resourceName) + nameResolver.On("GetMetadataUrl", "re", "uuid-1").Return(gatewayUrl) + + accessServiceManager := new(asmocks.AccessServiceManager) + accessServiceManager.On("Create", "re", "uuid-1", resourceName).Return(nil) + + istioService := new(istiomocks.Service) + istioService.On("Create", "re", "uuid-1", resourceName).Return(nil) + + service := NewService(nameResolver, accessServiceManager, nil, istioService) + + // when + remoteEnvServiceAPI, err := service.New("re", "uuid-1", api) + + // then + require.NoError(t, err) + assert.Equal(t, gatewayUrl, remoteEnvServiceAPI.GatewayURL) + assert.Equal(t, resourceName, remoteEnvServiceAPI.AccessLabel) + assert.Equal(t, api.TargetUrl, remoteEnvServiceAPI.TargetUrl) + assert.Equal(t, "", remoteEnvServiceAPI.OauthUrl) + assert.Equal(t, "", remoteEnvServiceAPI.CredentialsSecretName) + + accessServiceManager.AssertExpectations(t) + istioService.AssertExpectations(t) + }) + + t.Run("should return error when creating access service fails", func(t *testing.T) { + // given + api := &API{ + TargetUrl: "http://target.com", + Credentials: &Credentials{ + Oauth: Oauth{ + URL: "http://oauth.com", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + } + + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("GetResourceName", "re", "uuid-1").Return(resourceName) + nameResolver.On("GetMetadataUrl", "re", "uuid-1").Return(gatewayUrl) + + accessServiceManager := new(asmocks.AccessServiceManager) + accessServiceManager.On("Create", "re", "uuid-1", resourceName).Return(apperrors.Internal("some error")) + + service := NewService(nameResolver, accessServiceManager, nil, nil) + + // when + result, err := service.New("re", "uuid-1", api) + + // then + assert.Nil(t, result) + assert.Error(t, err) + assert.Contains(t, err.Error(), "some error") + + accessServiceManager.AssertExpectations(t) + }) + + t.Run("should return error when creating secret fails", func(t *testing.T) { + // given + api := &API{ + TargetUrl: "http://target.com", + Credentials: &Credentials{ + Oauth: Oauth{ + URL: "http://oauth.com", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + } + + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("GetResourceName", "re", "uuid-1").Return(resourceName) + nameResolver.On("GetMetadataUrl", "re", "uuid-1").Return(gatewayUrl) + + accessServiceManager := new(asmocks.AccessServiceManager) + accessServiceManager.On("Create", "re", "uuid-1", resourceName).Return(nil) + + secretsRepository := new(secretsmocks.Repository) + secretsRepository.On( + "Create", + "re", + resourceName, + api.Credentials.Oauth.ClientID, + api.Credentials.Oauth.ClientSecret, + "uuid-1", + ).Return(apperrors.Internal("some error")) + + service := NewService(nameResolver, accessServiceManager, secretsRepository, nil) + + // when + result, err := service.New("re", "uuid-1", api) + + // then + assert.Nil(t, result) + assert.Error(t, err) + assert.Contains(t, err.Error(), "some error") + + accessServiceManager.AssertExpectations(t) + secretsRepository.AssertExpectations(t) + }) + + t.Run("should return error when creating istio resources fails", func(t *testing.T) { + // given + api := &API{ + TargetUrl: "http://target.com", + Credentials: &Credentials{ + Oauth: Oauth{ + URL: "http://oauth.com", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + } + + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("GetResourceName", "re", "uuid-1").Return(resourceName) + nameResolver.On("GetMetadataUrl", "re", "uuid-1").Return(gatewayUrl) + + accessServiceManager := new(asmocks.AccessServiceManager) + accessServiceManager.On("Create", "re", "uuid-1", resourceName).Return(nil) + + secretsRepository := new(secretsmocks.Repository) + secretsRepository.On( + "Create", + "re", + resourceName, + api.Credentials.Oauth.ClientID, + api.Credentials.Oauth.ClientSecret, + "uuid-1", + ).Return(nil) + + istioService := new(istiomocks.Service) + istioService.On("Create", "re", "uuid-1", resourceName).Return(apperrors.Internal("some error")) + + service := NewService(nameResolver, accessServiceManager, secretsRepository, istioService) + + // when + result, err := service.New("re", "uuid-1", api) + + // then + assert.Nil(t, result) + assert.Error(t, err) + assert.Contains(t, err.Error(), "some error") + + accessServiceManager.AssertExpectations(t) + secretsRepository.AssertExpectations(t) + istioService.AssertExpectations(t) + }) +} + +func TestDefaultService_Read(t *testing.T) { + t.Run("should read API with oauth credentials", func(t *testing.T) { + // given + remoteEnvServiceAPi := &remoteenv.ServiceAPI{ + TargetUrl: "http://target.com", + OauthUrl: "http://oauth.com", + CredentialsSecretName: "secret-name", + } + + secretsRepository := new(secretsmocks.Repository) + secretsRepository.On("Get", "re", "secret-name").Return("clientId", "clientSecret", nil) + + service := NewService(nil, nil, secretsRepository, nil) + + // when + api, err := service.Read("re", remoteEnvServiceAPi) + + // then + require.NoError(t, err) + assert.Equal(t, "http://target.com", api.TargetUrl) + assert.Equal(t, "http://oauth.com", api.Credentials.Oauth.URL) + assert.Equal(t, "clientId", api.Credentials.Oauth.ClientID) + assert.Equal(t, "clientSecret", api.Credentials.Oauth.ClientSecret) + assert.Nil(t, api.Spec) + + secretsRepository.AssertExpectations(t) + }) + + t.Run("should read API without oauth credentials", func(t *testing.T) { + // given + remoteEnvServiceAPi := &remoteenv.ServiceAPI{ + TargetUrl: "http://target.com", + } + + service := NewService(nil, nil, nil, nil) + + // when + api, err := service.Read("re", remoteEnvServiceAPi) + + // then + require.NoError(t, err) + assert.Equal(t, "http://target.com", api.TargetUrl) + assert.Nil(t, api.Credentials) + assert.Nil(t, api.Spec) + }) + + t.Run("should return error when reading secret fails", func(t *testing.T) { + // given + remoteEnvServiceAPi := &remoteenv.ServiceAPI{ + TargetUrl: "http://target.com", + OauthUrl: "http://oauth.com", + CredentialsSecretName: "secret-name", + } + + secretsRepository := new(secretsmocks.Repository) + secretsRepository.On("Get", "re", "secret-name"). + Return("", "", apperrors.Internal("secret error")) + + service := NewService(nil, nil, secretsRepository, nil) + + // when + api, err := service.Read("re", remoteEnvServiceAPi) + + // then + assert.Error(t, err) + assert.Nil(t, api) + assert.Contains(t, err.Error(), "secret error") + + secretsRepository.AssertExpectations(t) + }) +} + +func TestDefaultService_Delete(t *testing.T) { + t.Run("should delete an API", func(t *testing.T) { + // given + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("GetResourceName", "re", "uuid-1").Return(resourceName) + + accessServiceManager := new(asmocks.AccessServiceManager) + accessServiceManager.On("Delete", resourceName).Return(nil) + + secretsRepository := new(secretsmocks.Repository) + secretsRepository.On("Delete", resourceName).Return(nil) + + istioService := new(istiomocks.Service) + istioService.On("Delete", resourceName).Return(nil) + + service := NewService(nameResolver, accessServiceManager, secretsRepository, istioService) + + // when + err := service.Delete("re", "uuid-1") + + // then + assert.NoError(t, err) + + nameResolver.AssertExpectations(t) + accessServiceManager.AssertExpectations(t) + secretsRepository.AssertExpectations(t) + istioService.AssertExpectations(t) + }) + + t.Run("should return an error if accessService deletion fails", func(t *testing.T) { + // given + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("GetResourceName", "re", "uuid-1").Return(resourceName) + + accessServiceManager := new(asmocks.AccessServiceManager) + accessServiceManager.On("Delete", resourceName).Return(apperrors.Internal("an error")) + + service := NewService(nameResolver, accessServiceManager, nil, nil) + + // when + err := service.Delete("re", "uuid-1") + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.NotEmpty(t, err.Error()) + + nameResolver.AssertExpectations(t) + accessServiceManager.AssertExpectations(t) + }) + + t.Run("should return an error if secret deletion fails", func(t *testing.T) { + // given + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("GetResourceName", "re", "uuid-1").Return(resourceName) + + accessServiceManager := new(asmocks.AccessServiceManager) + accessServiceManager.On("Delete", resourceName).Return(nil) + + secretsRepository := new(secretsmocks.Repository) + secretsRepository.On("Delete", resourceName).Return(apperrors.Internal("an error")) + + service := NewService(nameResolver, accessServiceManager, secretsRepository, nil) + + // when + err := service.Delete("re", "uuid-1") + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.NotEmpty(t, err.Error()) + + nameResolver.AssertExpectations(t) + accessServiceManager.AssertExpectations(t) + secretsRepository.AssertExpectations(t) + }) + + t.Run("should return an error if istio deletion fails", func(t *testing.T) { + // given + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("GetResourceName", "re", "uuid-1").Return(resourceName) + + accessServiceManager := new(asmocks.AccessServiceManager) + accessServiceManager.On("Delete", resourceName).Return(nil) + + secretsRepository := new(secretsmocks.Repository) + secretsRepository.On("Delete", resourceName).Return(nil) + + istioService := new(istiomocks.Service) + istioService.On("Delete", resourceName).Return(apperrors.Internal("")) + + service := NewService(nameResolver, accessServiceManager, secretsRepository, istioService) + + // when + err := service.Delete("re", "uuid-1") + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.NotEmpty(t, err.Error()) + + nameResolver.AssertExpectations(t) + accessServiceManager.AssertExpectations(t) + secretsRepository.AssertExpectations(t) + istioService.AssertExpectations(t) + }) +} + +func TestDefaultService_Update(t *testing.T) { + t.Run("should update an API with a new one containing a secret", func(t *testing.T) { + // given + api := &API{ + TargetUrl: "http://target.com", + Credentials: &Credentials{ + Oauth: Oauth{ + URL: "http://oauth.com", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + } + + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("GetResourceName", "re", "uuid-1").Return(resourceName) + nameResolver.On("GetMetadataUrl", "re", "uuid-1").Return(gatewayUrl) + + accessServiceManager := new(asmocks.AccessServiceManager) + accessServiceManager.On("Upsert", "re", "uuid-1", resourceName).Return(nil) + + secretsRepository := new(secretsmocks.Repository) + secretsRepository.On( + "Upsert", + "re", + resourceName, + api.Credentials.Oauth.ClientID, + api.Credentials.Oauth.ClientSecret, + "uuid-1", + ).Return(nil) + + istioService := new(istiomocks.Service) + istioService.On("Upsert", "re", "uuid-1", resourceName).Return(nil) + + service := NewService(nameResolver, accessServiceManager, secretsRepository, istioService) + + // when + remoteEnvServiceAPI, err := service.Update("re", "uuid-1", api) + + // then + require.NoError(t, err) + assert.Equal(t, gatewayUrl, remoteEnvServiceAPI.GatewayURL) + assert.Equal(t, resourceName, remoteEnvServiceAPI.AccessLabel) + assert.Equal(t, "http://target.com", remoteEnvServiceAPI.TargetUrl) + assert.Equal(t, "http://oauth.com", remoteEnvServiceAPI.OauthUrl) + assert.Equal(t, resourceName, remoteEnvServiceAPI.CredentialsSecretName) + + nameResolver.AssertExpectations(t) + accessServiceManager.AssertExpectations(t) + secretsRepository.AssertExpectations(t) + istioService.AssertExpectations(t) + }) + + t.Run("should update an API with a new one not containing a secret", func(t *testing.T) { + // given + api := &API{ + TargetUrl: "http://target.com", + Credentials: nil, + } + + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("GetResourceName", "re", "uuid-1").Return(resourceName) + nameResolver.On("GetMetadataUrl", "re", "uuid-1").Return(gatewayUrl) + + accessServiceManager := new(asmocks.AccessServiceManager) + accessServiceManager.On("Upsert", "re", "uuid-1", resourceName).Return(nil) + + secretsRepository := new(secretsmocks.Repository) + secretsRepository.On("Delete", resourceName).Return(nil) + + istioService := new(istiomocks.Service) + istioService.On("Upsert", "re", "uuid-1", resourceName).Return(nil) + + service := NewService( + nameResolver, + accessServiceManager, + secretsRepository, + istioService, + ) + + // when + remoteEnvServiceAPI, err := service.Update("re", "uuid-1", api) + + // then + require.NoError(t, err) + assert.Equal(t, gatewayUrl, remoteEnvServiceAPI.GatewayURL) + assert.Equal(t, resourceName, remoteEnvServiceAPI.AccessLabel) + assert.Equal(t, "http://target.com", remoteEnvServiceAPI.TargetUrl) + assert.Equal(t, "", remoteEnvServiceAPI.OauthUrl) + assert.Equal(t, "", remoteEnvServiceAPI.CredentialsSecretName) + + nameResolver.AssertExpectations(t) + accessServiceManager.AssertExpectations(t) + secretsRepository.AssertExpectations(t) + istioService.AssertExpectations(t) + }) + + t.Run("should return error when updating access service fails", func(t *testing.T) { + // given + api := &API{ + TargetUrl: "http://target.com", + Credentials: &Credentials{ + Oauth: Oauth{ + URL: "http://oauth.com", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + } + + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("GetResourceName", "re", "uuid-1").Return(resourceName) + nameResolver.On("GetMetadataUrl", "re", "uuid-1").Return(gatewayUrl) + + accessServiceManager := new(asmocks.AccessServiceManager) + accessServiceManager.On("Upsert", "re", "uuid-1", resourceName). + Return(apperrors.Internal("some error")) + + service := NewService(nameResolver, accessServiceManager, nil, nil) + + // when + result, err := service.Update("re", "uuid-1", api) + + // then + assert.Nil(t, result) + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.Contains(t, err.Error(), "some error") + + nameResolver.AssertExpectations(t) + accessServiceManager.AssertExpectations(t) + }) + + t.Run("should return error when updating secret fails", func(t *testing.T) { + // given + api := &API{ + TargetUrl: "http://target.com", + Credentials: &Credentials{ + Oauth: Oauth{ + URL: "http://oauth.com", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + } + + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("GetResourceName", "re", "uuid-1").Return(resourceName) + nameResolver.On("GetMetadataUrl", "re", "uuid-1").Return(gatewayUrl) + + accessServiceManager := new(asmocks.AccessServiceManager) + accessServiceManager.On("Upsert", "re", "uuid-1", resourceName).Return(nil) + + secretsRepository := new(secretsmocks.Repository) + secretsRepository.On( + "Upsert", + "re", + resourceName, + api.Credentials.Oauth.ClientID, + api.Credentials.Oauth.ClientSecret, + "uuid-1", + ).Return(apperrors.Internal("some error")) + + service := NewService(nameResolver, accessServiceManager, secretsRepository, nil) + + // when + result, err := service.Update("re", "uuid-1", api) + + // then + assert.Nil(t, result) + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.Contains(t, err.Error(), "some error") + + nameResolver.AssertExpectations(t) + accessServiceManager.AssertExpectations(t) + secretsRepository.AssertExpectations(t) + }) + + t.Run("should return error when deleting secret fails", func(t *testing.T) { + // given + api := &API{ + TargetUrl: "http://target.com", + Credentials: nil, + } + + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("GetResourceName", "re", "uuid-1").Return(resourceName) + nameResolver.On("GetMetadataUrl", "re", "uuid-1").Return(gatewayUrl) + + accessServiceManager := new(asmocks.AccessServiceManager) + accessServiceManager.On("Upsert", "re", "uuid-1", resourceName).Return(nil) + + secretsRepository := new(secretsmocks.Repository) + secretsRepository.On("Delete", resourceName).Return(apperrors.Internal("some error")) + + service := NewService(nameResolver, accessServiceManager, secretsRepository, nil) + + // when + result, err := service.Update("re", "uuid-1", api) + + // then + assert.Nil(t, result) + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.Contains(t, err.Error(), "some error") + + nameResolver.AssertExpectations(t) + accessServiceManager.AssertExpectations(t) + secretsRepository.AssertExpectations(t) + }) + + t.Run("should return error when updating istio resources fails", func(t *testing.T) { + // given + api := &API{ + TargetUrl: "http://target.com", + Credentials: &Credentials{ + Oauth: Oauth{ + URL: "http://oauth.com", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + } + + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("GetResourceName", "re", "uuid-1").Return(resourceName) + nameResolver.On("GetMetadataUrl", "re", "uuid-1").Return(gatewayUrl) + + accessServiceManager := new(asmocks.AccessServiceManager) + accessServiceManager.On("Upsert", "re", "uuid-1", resourceName).Return(nil) + + secretsRepository := new(secretsmocks.Repository) + secretsRepository.On( + "Upsert", + "re", + resourceName, + api.Credentials.Oauth.ClientID, + api.Credentials.Oauth.ClientSecret, + "uuid-1", + ).Return(nil) + + istioService := new(istiomocks.Service) + istioService.On("Upsert", "re", "uuid-1", resourceName).Return(apperrors.Internal("some error")) + + service := NewService(nameResolver, accessServiceManager, secretsRepository, istioService) + + // when + result, err := service.Update("re", "uuid-1", api) + + // then + assert.Nil(t, result) + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.Contains(t, err.Error(), "some error") + + nameResolver.AssertExpectations(t) + accessServiceManager.AssertExpectations(t) + secretsRepository.AssertExpectations(t) + istioService.AssertExpectations(t) + }) +} diff --git a/components/application-connector/internal/metadata/servicedefservice.go b/components/application-connector/internal/metadata/servicedefservice.go new file mode 100644 index 000000000000..25dd4151019a --- /dev/null +++ b/components/application-connector/internal/metadata/servicedefservice.go @@ -0,0 +1,261 @@ +// Package metadata contains components for accessing Kyma storage (Remote Environments, Minio) +package metadata + +import ( + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/minio" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/remoteenv" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/serviceapi" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/uuid" +) + +// ServiceDefinitionService is a service that manages ServiceDefinition objects. +type ServiceDefinitionService interface { + // Create adds new ServiceDefinition. + Create(remoteEnvironment string, serviceDefinition *ServiceDefinition) (id string, err apperrors.AppError) + + // GetByID returns ServiceDefinition with provided ID. + GetByID(remoteEnvironment, id string) (serviceDefinition ServiceDefinition, err apperrors.AppError) + + // GetAll returns all ServiceDefinitions. + GetAll(remoteEnvironment string) (serviceDefinitions []ServiceDefinition, err apperrors.AppError) + + // Update updates a service definition with provided ID. + Update(remoteEnvironment, id string, serviceDef *ServiceDefinition) apperrors.AppError + + // Delete deletes a ServiceDefinition. + Delete(remoteEnvironment, id string) apperrors.AppError + + // GetAPI gets API of a service with given ID + GetAPI(remoteEnvironment, serviceId string) (*serviceapi.API, apperrors.AppError) +} + +type serviceDefinitionService struct { + uuidGenerator uuid.Generator + serviceAPIService serviceapi.Service + remoteEnvironmentRepository remoteenv.ServiceRepository + minioService minio.Service +} + +// NewServiceDefinitionService creates new ServiceDefinitionService with provided dependencies. +func NewServiceDefinitionService(uuidGenerator uuid.Generator, serviceAPIService serviceapi.Service, remoteEnvironmentRepository remoteenv.ServiceRepository, minioService minio.Service) ServiceDefinitionService { + return &serviceDefinitionService{ + uuidGenerator: uuidGenerator, + serviceAPIService: serviceAPIService, + remoteEnvironmentRepository: remoteEnvironmentRepository, + minioService: minioService, + } +} + +// Create adds new ServiceDefinition. Based on ServiceDefinition a new service is added to RemoteEnvironment. +func (sds *serviceDefinitionService) Create(remoteEnvironment string, serviceDef *ServiceDefinition) (string, apperrors.AppError) { + id := sds.uuidGenerator.NewUUID() + + service := initService(serviceDef, id) + + if apiDefined(serviceDef) { + serviceAPI, err := sds.serviceAPIService.New(remoteEnvironment, id, serviceDef.Api) + if err != nil { + return "", apperrors.Internal("failed to add new API, %s", err) + } + service.API = serviceAPI + } + + err := sds.insertSpecs(id, serviceDef.Documentation, serviceDef.Api, serviceDef.Events) + if err != nil { + return "", apperrors.Internal("failed to insert specs, %s", err) + } + + err = sds.remoteEnvironmentRepository.Create(remoteEnvironment, *service) + if err != nil { + return "", apperrors.Internal("failed to create service in remote environment, %s", err) + } + + serviceDef.ID = id + return id, nil +} + +// GetByID returns ServiceDefinition with provided ID. +func (sds *serviceDefinitionService) GetByID(remoteEnvironment, id string) (ServiceDefinition, apperrors.AppError) { + service, err := sds.remoteEnvironmentRepository.Get(remoteEnvironment, id) + if err != nil { + if err.Code() == apperrors.CodeNotFound { + return ServiceDefinition{}, apperrors.NotFound("service with ID %s not found", id) + } + return ServiceDefinition{}, apperrors.Internal("failed to read service with ID %s, %s", id, err) + } + + return sds.readService(remoteEnvironment, service) +} + +// GetAll returns all ServiceDefinitions. +func (sds *serviceDefinitionService) GetAll(remoteEnvironment string) ([]ServiceDefinition, apperrors.AppError) { + services, err := sds.remoteEnvironmentRepository.GetAll(remoteEnvironment) + if err != nil { + return nil, apperrors.Internal("failed to read services from remote environment, %s", err) + } + + res := make([]ServiceDefinition, 0) + for _, service := range services { + res = append(res, convertServiceBaseInfo(service)) + } + + return res, nil +} + +// Update updates a service with provided ID. +func (sds *serviceDefinitionService) Update(remoteEnvironment, id string, serviceDef *ServiceDefinition) apperrors.AppError { + _, err := sds.GetByID(remoteEnvironment, id) + if err != nil { + if err.Code() != apperrors.CodeNotFound { + return apperrors.NotFound("failed to get service before update, %s", err) + } + return apperrors.Internal("failed to read service, %s", err) + } + + service := initService(serviceDef, id) + + if !apiDefined(serviceDef) { + err = sds.serviceAPIService.Delete(remoteEnvironment, id) + if err != nil { + return apperrors.Internal("failed to delete API, %s", err) + } + } else { + service.API, err = sds.serviceAPIService.Update(remoteEnvironment, id, serviceDef.Api) + if err != nil { + return apperrors.Internal("failed to update API, %s", err) + } + } + + err = sds.insertSpecs(id, serviceDef.Documentation, serviceDef.Api, serviceDef.Events) + if err != nil { + return apperrors.Internal("failed to insert specification to Minio, %s", err) + } + + err = sds.remoteEnvironmentRepository.Update(remoteEnvironment, *service) + if err != nil { + return apperrors.Internal("failed to update service in RE repository, %s") + } + + serviceDef.ID = id + return nil +} + +// Delete deletes a service with given id. +func (sds *serviceDefinitionService) Delete(remoteEnvironment, id string) apperrors.AppError { + err := sds.serviceAPIService.Delete(remoteEnvironment, id) + if err != nil { + return apperrors.Internal("failed to delete service, %s", err) + } + + err = sds.remoteEnvironmentRepository.Delete(remoteEnvironment, id) + if err != nil { + return apperrors.Internal("failed to delete service from RE repository, %s", err) + } + + err = sds.minioService.Remove(id) + if err != nil { + return apperrors.Internal("failed to delete service data from Minio, %s", err) + } + + return nil +} + +// GetAPI gets API of a service with given ID +func (sds *serviceDefinitionService) GetAPI(remoteEnvironment, serviceId string) (*serviceapi.API, apperrors.AppError) { + service, err := sds.remoteEnvironmentRepository.Get(remoteEnvironment, serviceId) + if err != nil { + if err.Code() == apperrors.CodeNotFound { + return nil, apperrors.NotFound("service with ID %s not found", serviceId) + } + return nil, apperrors.Internal("failed to read %s service, %s", serviceId, err) + } + + if service.API == nil { + return nil, apperrors.WrongInput("service with ID '%s' has no API") + } + + api, err := sds.serviceAPIService.Read(remoteEnvironment, service.API) + if err != nil { + return nil, apperrors.Internal("failed to read API for %s service, %s", serviceId, err) + } + return api, nil +} + +func initService(serviceDef *ServiceDefinition, id string) *remoteenv.Service { + service := remoteenv.Service{ + ID: id, + DisplayName: serviceDef.Name, + LongDescription: serviceDef.Description, + ProviderDisplayName: serviceDef.Provider, + Tags: make([]string, 0), + } + + service.Events = serviceDef.Events != nil + + return &service +} + +func convertServiceBaseInfo(service remoteenv.Service) ServiceDefinition { + return ServiceDefinition{ + ID: service.ID, + Name: service.DisplayName, + Description: service.LongDescription, + Provider: service.ProviderDisplayName, + } +} + +func (sds *serviceDefinitionService) readService(remoteEnvironment string, service remoteenv.Service) (ServiceDefinition, apperrors.AppError) { + serviceDef := convertServiceBaseInfo(service) + + documentation, apiSpec, eventsSpec, err := sds.minioService.Get(service.ID) + if err != nil { + return ServiceDefinition{}, apperrors.Internal("reading specs failed, %s", err) + } + + if service.API != nil { + api, err := sds.serviceAPIService.Read(remoteEnvironment, service.API) + if err != nil { + return ServiceDefinition{}, apperrors.Internal("reading API failed, %s", err) + } + serviceDef.Api = api + + if apiSpec != nil { + serviceDef.Api.Spec = apiSpec + } + } + + if eventsSpec != nil { + serviceDef.Events = &Events{eventsSpec} + } + + if documentation != nil { + serviceDef.Documentation = documentation + } + + return serviceDef, nil +} + +func apiDefined(serviceDefinition *ServiceDefinition) bool { + return serviceDefinition.Api != nil +} + +func (sds *serviceDefinitionService) insertSpecs(id string, docs []byte, api *serviceapi.API, events *Events) apperrors.AppError { + var documentation []byte + var apiSpec []byte + var eventsSpec []byte + + if docs != nil { + documentation = docs + } + + if api != nil { + apiSpec = api.Spec + } + + if events != nil { + eventsSpec = events.Spec + } + + return sds.minioService.Put(id, documentation, apiSpec, eventsSpec) +} diff --git a/components/application-connector/internal/metadata/servicedefservice_test.go b/components/application-connector/internal/metadata/servicedefservice_test.go new file mode 100644 index 000000000000..ba4e3c32012a --- /dev/null +++ b/components/application-connector/internal/metadata/servicedefservice_test.go @@ -0,0 +1,1113 @@ +package metadata + +import ( + "testing" + + "github.com/kyma-project/kyma/components/application-connector/internal/apperrors" + miniomocks "github.com/kyma-project/kyma/components/application-connector/internal/metadata/minio/mocks" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/remoteenv" + remoteenvmocks "github.com/kyma-project/kyma/components/application-connector/internal/metadata/remoteenv/mocks" + "github.com/kyma-project/kyma/components/application-connector/internal/metadata/serviceapi" + serviceapimocks "github.com/kyma-project/kyma/components/application-connector/internal/metadata/serviceapi/mocks" + uuidmocks "github.com/kyma-project/kyma/components/application-connector/internal/metadata/uuid/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +var ( + empty []byte +) + +func TestServiceDefinitionService_Create(t *testing.T) { + + t.Run("should create service with API, events and documentation", func(t *testing.T) { + // given + serviceAPI := &serviceapi.API{ + TargetUrl: "http://target.com", + Credentials: &serviceapi.Credentials{ + Oauth: serviceapi.Oauth{ + URL: "http://oauth.com/token", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + Spec: []byte("api docs"), + } + serviceDefinition := ServiceDefinition{ + Name: "Some service", + Description: "Some cool service", + Provider: "Service Provider", + Api: serviceAPI, + Events: &Events{ + Spec: []byte("events spec"), + }, + Documentation: []byte("documentation"), + } + remoteEnvServiceAPI := &remoteenv.ServiceAPI{ + TargetUrl: "http://target.com", + OauthUrl: "http://oauth.com/token", + AccessLabel: "access-label", + GatewayURL: "gateway-url", + CredentialsSecretName: "secret-name", + } + remoteEnvService := remoteenv.Service{ + ID: "uuid-1", + DisplayName: "Some service", + LongDescription: "Some cool service", + ProviderDisplayName: "Service Provider", + Tags: make([]string, 0), + API: remoteEnvServiceAPI, + Events: true, + } + uuidGenerator := new(uuidmocks.Generator) + uuidGenerator.On("NewUUID").Return("uuid-1") + serviceAPIService := new(serviceapimocks.Service) + serviceAPIService.On("New", "re", "uuid-1", serviceAPI).Return(remoteEnvServiceAPI, nil) + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Create", "re", remoteEnvService).Return(nil) + minioService := new(miniomocks.Service) + minioService.On("Put", "uuid-1", []byte("documentation"), []byte("api docs"), []byte("events spec")).Return(nil) + + service := NewServiceDefinitionService(uuidGenerator, serviceAPIService, serviceRepository, minioService) + + // when + serviceID, err := service.Create("re", &serviceDefinition) + + // then + require.NoError(t, err) + assert.Equal(t, "uuid-1", serviceID) + + uuidGenerator.AssertExpectations(t) + serviceAPIService.AssertExpectations(t) + serviceRepository.AssertExpectations(t) + minioService.AssertExpectations(t) + }) + + t.Run("should create service without API", func(t *testing.T) { + // given + serviceDefinition := ServiceDefinition{ + Name: "Some service", + Description: "Some cool service", + Provider: "Service Provider", + Api: nil, + Events: &Events{ + Spec: []byte("test"), + }, + Documentation: []byte("documentation"), + } + + remoteEnvService := remoteenv.Service{ + ID: "uuid-1", + DisplayName: "Some service", + LongDescription: "Some cool service", + ProviderDisplayName: "Service Provider", + Tags: make([]string, 0), + API: nil, + Events: true, + } + uuidGenerator := new(uuidmocks.Generator) + uuidGenerator.On("NewUUID").Return("uuid-1") + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Create", "re", remoteEnvService).Return(nil) + minioService := new(miniomocks.Service) + minioService.On("Put", "uuid-1", mock.Anything, empty, []byte("test")).Return(nil) + + service := NewServiceDefinitionService(uuidGenerator, nil, serviceRepository, minioService) + + // when + serviceID, err := service.Create("re", &serviceDefinition) + + // then + require.NoError(t, err) + assert.Equal(t, "uuid-1", serviceID) + + uuidGenerator.AssertExpectations(t) + serviceRepository.AssertExpectations(t) + minioService.AssertExpectations(t) + }) + + t.Run("should create service with documentation only", func(t *testing.T) { + // given + serviceDefinition := ServiceDefinition{ + Name: "Some service", + Description: "Some cool service", + Provider: "Service Provider", + Api: nil, + Events: nil, + Documentation: []byte("documentation"), + } + + remoteEnvService := remoteenv.Service{ + ID: "uuid-1", + DisplayName: "Some service", + LongDescription: "Some cool service", + ProviderDisplayName: "Service Provider", + Tags: make([]string, 0), + API: nil, + Events: false, + } + uuidGenerator := new(uuidmocks.Generator) + uuidGenerator.On("NewUUID").Return("uuid-1") + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Create", "re", remoteEnvService).Return(nil) + minioService := new(miniomocks.Service) + minioService.On("Put", "uuid-1", mock.Anything, empty, empty).Return(nil) + + service := NewServiceDefinitionService(uuidGenerator, nil, serviceRepository, minioService) + + // when + serviceID, err := service.Create("re", &serviceDefinition) + + // then + require.NoError(t, err) + assert.Equal(t, "uuid-1", serviceID) + + uuidGenerator.AssertExpectations(t) + serviceRepository.AssertExpectations(t) + minioService.AssertExpectations(t) + }) + + t.Run("should create service without specs", func(t *testing.T) { + // given + serviceDefinition := ServiceDefinition{ + Name: "Some service", + Description: "Some cool service", + Provider: "Service Provider", + Api: nil, + Events: nil, + Documentation: nil, + } + + remoteEnvService := remoteenv.Service{ + ID: "uuid-1", + DisplayName: "Some service", + LongDescription: "Some cool service", + ProviderDisplayName: "Service Provider", + Tags: make([]string, 0), + API: nil, + Events: false, + } + uuidGenerator := new(uuidmocks.Generator) + uuidGenerator.On("NewUUID").Return("uuid-1") + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Create", "re", remoteEnvService).Return(nil) + minioService := new(miniomocks.Service) + minioService.On("Put", "uuid-1", empty, empty, empty).Return(nil) + + service := NewServiceDefinitionService(uuidGenerator, nil, serviceRepository, minioService) + + // when + serviceID, err := service.Create("re", &serviceDefinition) + + // then + require.NoError(t, err) + assert.Equal(t, "uuid-1", serviceID) + + uuidGenerator.AssertExpectations(t) + serviceRepository.AssertExpectations(t) + minioService.AssertExpectations(t) + }) + + t.Run("should return error when adding API fails", func(t *testing.T) { + // given + serviceAPI := &serviceapi.API{ + TargetUrl: "http://target.com", + } + serviceDefinition := ServiceDefinition{ + Name: "Some service", + Description: "Some cool service", + Provider: "Service Provider", + Api: serviceAPI, + } + + uuidGenerator := new(uuidmocks.Generator) + uuidGenerator.On("NewUUID").Return("uuid-1") + serviceAPIService := new(serviceapimocks.Service) + serviceAPIService.On("New", "re", "uuid-1", serviceAPI).Return(nil, apperrors.Internal("some error")) + + service := NewServiceDefinitionService(uuidGenerator, serviceAPIService, nil, nil) + + // when + serviceID, err := service.Create("re", &serviceDefinition) + + // then + assert.Empty(t, serviceID) + assert.Error(t, err) + assert.Contains(t, err.Error(), "some error") + + serviceAPIService.AssertExpectations(t) + }) + + t.Run("should return error when adding spec to Minio fails", func(t *testing.T) { + // given + serviceDefinition := ServiceDefinition{ + Name: "Some service", + Description: "Some cool service", + Provider: "Service Provider", + Api: nil, + Events: &Events{ + Spec: []byte("events spec"), + }, + Documentation: nil, + } + + uuidGenerator := new(uuidmocks.Generator) + uuidGenerator.On("NewUUID").Return("uuid-1") + minioService := new(miniomocks.Service) + minioService.On("Put", "uuid-1", empty, empty, []byte("events spec")).Return(apperrors.Internal("Error")) + + service := NewServiceDefinitionService(uuidGenerator, nil, nil, minioService) + + // when + _, err := service.Create("re", &serviceDefinition) + + // then + require.Error(t, err) + + uuidGenerator.AssertExpectations(t) + minioService.AssertExpectations(t) + }) + + t.Run("should return error when creating service in remote environment fails", func(t *testing.T) { + // given + serviceAPI := &serviceapi.API{ + TargetUrl: "http://target.com", + } + serviceDefinition := ServiceDefinition{ + Name: "Some service", + Description: "Some cool service", + Provider: "Service Provider", + Api: serviceAPI, + } + remoteEnvServiceAPI := &remoteenv.ServiceAPI{ + TargetUrl: "http://target.com", + OauthUrl: "", + AccessLabel: "access-label", + GatewayURL: "gateway-utr", + CredentialsSecretName: "", + } + remoteEnvService := remoteenv.Service{ + ID: "uuid-1", + DisplayName: "Some service", + LongDescription: "Some cool service", + ProviderDisplayName: "Service Provider", + Tags: make([]string, 0), + API: remoteEnvServiceAPI, + Events: false, + } + uuidGenerator := new(uuidmocks.Generator) + uuidGenerator.On("NewUUID").Return("uuid-1") + serviceAPIService := new(serviceapimocks.Service) + serviceAPIService.On("New", "re", "uuid-1", serviceAPI).Return(remoteEnvServiceAPI, nil) + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Create", "re", remoteEnvService).Return(apperrors.Internal("some error")) + minioService := new(miniomocks.Service) + minioService.On("Put", "uuid-1", empty, empty, empty).Return(nil) + + service := NewServiceDefinitionService(uuidGenerator, serviceAPIService, serviceRepository, minioService) + + // when + serviceID, err := service.Create("re", &serviceDefinition) + + // then + assert.Empty(t, serviceID) + assert.Error(t, err) + assert.Contains(t, err.Error(), "some error") + + uuidGenerator.AssertExpectations(t) + serviceAPIService.AssertExpectations(t) + serviceRepository.AssertExpectations(t) + minioService.AssertExpectations(t) + }) +} + +func TestServiceDefinitionService_GetAll(t *testing.T) { + + t.Run("should get all services", func(t *testing.T) { + // given + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("GetAll", "re").Return([]remoteenv.Service{ + { + ID: "uuid-1", + DisplayName: "Service1", + LongDescription: "Service1 description", + ProviderDisplayName: "Service1 Provider", + Tags: nil, + API: &remoteenv.ServiceAPI{ + TargetUrl: "http://service1.com", + CredentialsSecretName: "testSecret1", + }, + Events: false, + }, + { + ID: "uuid-2", + DisplayName: "Service2", + LongDescription: "Service2 description", + ProviderDisplayName: "Service2 Provider", + Tags: nil, + API: nil, + Events: true, + }, + }, nil) + + service := NewServiceDefinitionService(nil, nil, serviceRepository, nil) + + // when + result, err := service.GetAll("re") + require.NoError(t, err) + + // then + assert.Len(t, result, 2) + assert.Contains(t, result, ServiceDefinition{ + ID: "uuid-1", + Name: "Service1", + Description: "Service1 description", + Provider: "Service1 Provider", + }) + assert.Contains(t, result, ServiceDefinition{ + ID: "uuid-2", + Name: "Service2", + Description: "Service2 description", + Provider: "Service2 Provider", + }) + }) + + t.Run("should get empty list", func(t *testing.T) { + // given + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("GetAll", "re").Return([]remoteenv.Service{}, nil) + + service := NewServiceDefinitionService(nil, nil, serviceRepository, nil) + + // when + result, err := service.GetAll("re") + require.NoError(t, err) + + // then + assert.Len(t, result, 0) + }) +} + +func TestServiceDefinitionService_GetById(t *testing.T) { + + t.Run("should get service by ID", func(t *testing.T) { + // given + serviceAPI := &serviceapi.API{ + TargetUrl: "http://target.com", + Credentials: &serviceapi.Credentials{ + Oauth: serviceapi.Oauth{ + URL: "http://oauth.com/token", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + } + + remoteEnvServiceAPI := &remoteenv.ServiceAPI{ + TargetUrl: "http://target.com", + OauthUrl: "http://oauth.com/token", + AccessLabel: "access-label", + GatewayURL: "gateway-url", + CredentialsSecretName: "secret-name", + } + + remoteEnvService := remoteenv.Service{ + ID: "uuid-1", + DisplayName: "Some service", + LongDescription: "Some cool service", + ProviderDisplayName: "Service Provider", + Tags: make([]string, 0), + API: remoteEnvServiceAPI, + Events: false, + } + + serviceAPIService := new(serviceapimocks.Service) + serviceAPIService.On("Read", "re", remoteEnvServiceAPI).Return(serviceAPI, nil) + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Get", "re", "uuid-1").Return(remoteEnvService, nil) + minioService := new(miniomocks.Service) + minioService.On("Get", "uuid-1").Return(empty, empty, empty, nil) + + service := NewServiceDefinitionService(nil, serviceAPIService, serviceRepository, minioService) + + // when + result, err := service.GetByID("re", "uuid-1") + require.NoError(t, err) + + // then + assert.Equal(t, "uuid-1", result.ID) + assert.Equal(t, "Some service", result.Name) + assert.Equal(t, "Some cool service", result.Description) + assert.Equal(t, "Service Provider", result.Provider) + assert.Equal(t, "http://target.com", result.Api.TargetUrl) + assert.Equal(t, "http://oauth.com/token", result.Api.Credentials.Oauth.URL) + assert.Equal(t, "clientId", result.Api.Credentials.Oauth.ClientID) + assert.Equal(t, "clientSecret", result.Api.Credentials.Oauth.ClientSecret) + assert.Nil(t, result.Events) + + serviceAPIService.AssertExpectations(t) + serviceRepository.AssertExpectations(t) + minioService.AssertExpectations(t) + }) + + t.Run("should return error when getting service from remote environment fails", func(t *testing.T) { + // given + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Get", "re", "uuid-1").Return(remoteenv.Service{}, apperrors.Internal("get error")) + + service := NewServiceDefinitionService(nil, nil, serviceRepository, nil) + + // when + _, err := service.GetByID("re", "uuid-1") + + // then + assert.Error(t, err) + assert.Contains(t, err.Error(), "get error") + }) + + t.Run("should return error when reading API fails", func(t *testing.T) { + // given + remoteEnvServiceAPI := &remoteenv.ServiceAPI{ + TargetUrl: "http://target.com", + OauthUrl: "http://oauth.com/token", + AccessLabel: "access-label", + GatewayURL: "gateway-url", + CredentialsSecretName: "secret-name", + } + + remoteEnvService := remoteenv.Service{ + ID: "uuid-1", + DisplayName: "Some service", + LongDescription: "Some cool service", + ProviderDisplayName: "Service Provider", + Tags: make([]string, 0), + API: remoteEnvServiceAPI, + Events: false, + } + + serviceAPIService := new(serviceapimocks.Service) + serviceAPIService.On("Read", "re", remoteEnvServiceAPI).Return(nil, apperrors.Internal("api error")) + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Get", "re", "uuid-1").Return(remoteEnvService, nil) + minioService := new(miniomocks.Service) + minioService.On("Get", "uuid-1").Return(empty, empty, empty, nil) + + service := NewServiceDefinitionService(nil, serviceAPIService, serviceRepository, minioService) + + // when + _, err := service.GetByID("re", "uuid-1") + + // then + assert.Error(t, err) + assert.Contains(t, err.Error(), "api error") + }) + + t.Run("should return error when reading specs from Minio fails", func(t *testing.T) { + // given + remoteEnvService := remoteenv.Service{ + ID: "uuid-1", + DisplayName: "Some service", + LongDescription: "Some cool service", + ProviderDisplayName: "Service Provider", + Tags: make([]string, 0), + API: nil, + Events: false, + } + + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Get", "re", "uuid-1").Return(remoteEnvService, nil) + minioService := new(miniomocks.Service) + minioService.On("Get", "uuid-1").Return(empty, empty, empty, apperrors.Internal("error")) + + service := NewServiceDefinitionService(nil, nil, serviceRepository, minioService) + + // when + _, err := service.GetByID("re", "uuid-1") + + // then + assert.Error(t, err) + assert.Contains(t, err.Error(), "error") + + serviceRepository.AssertExpectations(t) + minioService.AssertExpectations(t) + }) +} + +func TestServiceDefinitionService_Update(t *testing.T) { + + t.Run("should update a service", func(t *testing.T) { + // given + serviceAPI := &serviceapi.API{ + TargetUrl: "http://target.com", + Credentials: &serviceapi.Credentials{ + Oauth: serviceapi.Oauth{ + URL: "http://oauth.com/token", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + Spec: []byte("api docs"), + } + + serviceDefinition := ServiceDefinition{ + Name: "Some service", + Description: "Some cool service", + Provider: "Service Provider", + Api: serviceAPI, + Events: &Events{ + Spec: []byte("events spec"), + }, + Documentation: []byte("documentation"), + } + + remoteEnvServiceAPI := &remoteenv.ServiceAPI{ + TargetUrl: "http://target.com", + OauthUrl: "http://oauth.com/token", + AccessLabel: "access-label", + GatewayURL: "gateway-url", + CredentialsSecretName: "secret-name", + } + + remoteEnvService := remoteenv.Service{ + ID: "uuid-1", + DisplayName: "Some service", + LongDescription: "Some cool service", + ProviderDisplayName: "Service Provider", + Tags: make([]string, 0), + API: remoteEnvServiceAPI, + Events: true, + } + + serviceAPIService := new(serviceapimocks.Service) + serviceAPIService.On("Update", "re", "uuid-1", serviceAPI).Return(remoteEnvServiceAPI, nil) + serviceAPIService.On("Read", "re", remoteEnvServiceAPI).Return(serviceAPI, nil) + + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Get", "re", "uuid-1").Return(remoteEnvService, nil) + serviceRepository.On("Update", "re", remoteEnvService).Return(nil) + + uuidGenerator := new(uuidmocks.Generator) + uuidGenerator.On("NewUUID").Return("uuid-1") + + minioService := new(miniomocks.Service) + minioService.On("Put", "uuid-1", []byte("documentation"), []byte("api docs"), []byte("events spec")).Return(nil) + minioService.On("Get", "uuid-1").Return(nil, nil, nil, nil) + + service := NewServiceDefinitionService(uuidGenerator, serviceAPIService, serviceRepository, minioService) + + // when + err := service.Update("re", "uuid-1", &serviceDefinition) + + // then + assert.NoError(t, err) + + serviceAPIService.AssertExpectations(t) + serviceRepository.AssertExpectations(t) + minioService.AssertExpectations(t) + }) + + t.Run("should update a service when no API was given", func(t *testing.T) { + // given + serviceDefinition := ServiceDefinition{ + Name: "Some service", + Description: "Some cool service", + Provider: "Service Provider", + Api: nil, + Events: &Events{ + Spec: []byte("events spec"), + }, + Documentation: []byte("documentation"), + } + + remoteEnvService := remoteenv.Service{ + ID: "uuid-1", + DisplayName: "Some service", + LongDescription: "Some cool service", + ProviderDisplayName: "Service Provider", + Tags: make([]string, 0), + API: nil, + Events: true, + } + + serviceAPIService := new(serviceapimocks.Service) + serviceAPIService.On("Delete", "re", "uuid-1").Return(nil) + + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Get", "re", "uuid-1").Return(remoteEnvService, nil) + serviceRepository.On("Update", "re", remoteEnvService).Return(nil) + + uuidGenerator := new(uuidmocks.Generator) + uuidGenerator.On("NewUUID").Return("uuid-1") + + minioService := new(miniomocks.Service) + minioService.On("Put", "uuid-1", []byte("documentation"), []byte(nil), []byte("events spec")).Return(nil) + minioService.On("Get", "uuid-1").Return(nil, nil, nil, nil) + + service := NewServiceDefinitionService(uuidGenerator, serviceAPIService, serviceRepository, minioService) + + // when + err := service.Update("re", "uuid-1", &serviceDefinition) + + // then + assert.NoError(t, err) + + serviceAPIService.AssertExpectations(t) + serviceRepository.AssertExpectations(t) + minioService.AssertExpectations(t) + }) + + t.Run("should return an error if cache initialization failed", func(t *testing.T) { + // given + serviceAPI := &serviceapi.API{ + TargetUrl: "http://target.com", + Credentials: &serviceapi.Credentials{ + Oauth: serviceapi.Oauth{ + URL: "http://oauth.com/token", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + Spec: []byte("api docs"), + } + + serviceDefinition := ServiceDefinition{ + Name: "Some service", + Description: "Some cool service", + Provider: "Service Provider", + Api: serviceAPI, + Events: &Events{ + Spec: []byte("events spec"), + }, + Documentation: []byte("documentation"), + } + + remoteEnvService := remoteenv.Service{ + ID: "uuid-1", + DisplayName: "Some service", + LongDescription: "Some cool service", + ProviderDisplayName: "Service Provider", + Tags: make([]string, 0), + API: nil, + Events: true, + } + + serviceAPIService := new(serviceapimocks.Service) + serviceAPIService.On("Update", "re", "uuid-1", serviceAPI).Return(&remoteenv.ServiceAPI{}, apperrors.Internal("an error")) + + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Get", "re", "uuid-1").Return(remoteEnvService, nil) + + uuidGenerator := new(uuidmocks.Generator) + uuidGenerator.On("NewUUID").Return("uuid-1") + + minioService := new(miniomocks.Service) + minioService.On("Put", "uuid-1", []byte("documentation"), []byte(nil), []byte("events spec")).Return(nil) + minioService.On("Get", "uuid-1").Return(nil, nil, nil, nil) + + service := NewServiceDefinitionService(uuidGenerator, serviceAPIService, serviceRepository, minioService) + + // when + err := service.Update("re", "uuid-1", &serviceDefinition) + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.NotEmpty(t, err.Error()) + + serviceRepository.AssertExpectations(t) + }) + + t.Run("should return an error if API update failed", func(t *testing.T) { + // given + serviceAPI := &serviceapi.API{ + TargetUrl: "http://target.com", + Credentials: &serviceapi.Credentials{ + Oauth: serviceapi.Oauth{ + URL: "http://oauth.com/token", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + Spec: []byte("api docs"), + } + + serviceDefinition := ServiceDefinition{ + Name: "Some service", + Description: "Some cool service", + Provider: "Service Provider", + Api: serviceAPI, + Events: &Events{ + Spec: []byte("events spec"), + }, + Documentation: []byte("documentation"), + } + + remoteEnvService := remoteenv.Service{ + ID: "uuid-1", + DisplayName: "Some service", + LongDescription: "Some cool service", + ProviderDisplayName: "Service Provider", + Tags: make([]string, 0), + API: nil, + Events: true, + } + + serviceAPIService := new(serviceapimocks.Service) + serviceAPIService.On("Update", "re", "uuid-1", serviceAPI).Return(&remoteenv.ServiceAPI{}, apperrors.Internal("an error")) + + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Get", "re", "uuid-1").Return(remoteEnvService, nil) + + uuidGenerator := new(uuidmocks.Generator) + uuidGenerator.On("NewUUID").Return("uuid-1") + + minioService := new(miniomocks.Service) + minioService.On("Put", "uuid-1", []byte("documentation"), []byte(nil), []byte("events spec")).Return(nil) + minioService.On("Get", "uuid-1").Return(nil, nil, nil, nil) + + service := NewServiceDefinitionService(uuidGenerator, serviceAPIService, serviceRepository, minioService) + + // when + err := service.Update("re", "uuid-1", &serviceDefinition) + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.NotEmpty(t, err.Error()) + + serviceAPIService.AssertExpectations(t) + }) + + t.Run("should return an error if Minio data update failed", func(t *testing.T) { + // given + serviceAPI := &serviceapi.API{ + TargetUrl: "http://target.com", + Credentials: &serviceapi.Credentials{ + Oauth: serviceapi.Oauth{ + URL: "http://oauth.com/token", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + Spec: []byte("api docs"), + } + + serviceDefinition := ServiceDefinition{ + Name: "Some service", + Description: "Some cool service", + Provider: "Service Provider", + Api: serviceAPI, + Events: &Events{ + Spec: []byte("events spec"), + }, + Documentation: []byte("documentation"), + } + + remoteEnvService := remoteenv.Service{ + ID: "uuid-1", + DisplayName: "Some service", + LongDescription: "Some cool service", + ProviderDisplayName: "Service Provider", + Tags: make([]string, 0), + API: nil, + Events: true, + } + + serviceAPIService := new(serviceapimocks.Service) + serviceAPIService.On("Update", "re", "uuid-1", serviceAPI).Return(&remoteenv.ServiceAPI{}, nil) + + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Get", "re", "uuid-1").Return(remoteEnvService, nil) + + uuidGenerator := new(uuidmocks.Generator) + uuidGenerator.On("NewUUID").Return("uuid-1") + + minioService := new(miniomocks.Service) + minioService.On("Put", "uuid-1", []byte("documentation"), []byte("api docs"), []byte("events spec")).Return(apperrors.Internal("an error")) + minioService.On("Get", "uuid-1").Return(nil, nil, nil, nil) + + service := NewServiceDefinitionService(uuidGenerator, serviceAPIService, serviceRepository, minioService) + + // when + err := service.Update("re", "uuid-1", &serviceDefinition) + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.NotEmpty(t, err.Error()) + + serviceAPIService.AssertExpectations(t) + minioService.AssertExpectations(t) + }) + + t.Run("should return an error if remoteenv update failed", func(t *testing.T) { + // given + serviceAPI := &serviceapi.API{ + TargetUrl: "http://target.com", + Credentials: &serviceapi.Credentials{ + Oauth: serviceapi.Oauth{ + URL: "http://oauth.com/token", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + Spec: []byte("api docs"), + } + + serviceDefinition := ServiceDefinition{ + Name: "Some service", + Description: "Some cool service", + Provider: "Service Provider", + Api: serviceAPI, + Events: &Events{ + Spec: []byte("events spec"), + }, + Documentation: []byte("documentation"), + } + + remoteEnvServiceAPI := &remoteenv.ServiceAPI{ + TargetUrl: "http://target.com", + OauthUrl: "http://oauth.com/token", + AccessLabel: "access-label", + GatewayURL: "gateway-url", + CredentialsSecretName: "secret-name", + } + + remoteEnvService := remoteenv.Service{ + ID: "uuid-1", + DisplayName: "Some service", + LongDescription: "Some cool service", + ProviderDisplayName: "Service Provider", + Tags: make([]string, 0), + API: remoteEnvServiceAPI, + Events: true, + } + + serviceAPIService := new(serviceapimocks.Service) + serviceAPIService.On("Update", "re", "uuid-1", serviceAPI).Return(remoteEnvServiceAPI, nil) + serviceAPIService.On("Read", "re", remoteEnvServiceAPI).Return(serviceAPI, nil) + + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Get", "re", "uuid-1").Return(remoteEnvService, nil) + serviceRepository.On("Update", "re", remoteEnvService).Return(apperrors.Internal("an error")) + + uuidGenerator := new(uuidmocks.Generator) + uuidGenerator.On("NewUUID").Return("uuid-1") + + minioService := new(miniomocks.Service) + minioService.On("Put", "uuid-1", []byte("documentation"), []byte("api docs"), []byte("events spec")).Return(nil) + minioService.On("Get", "uuid-1").Return(nil, nil, nil, nil) + + service := NewServiceDefinitionService(uuidGenerator, serviceAPIService, serviceRepository, minioService) + + // when + err := service.Update("re", "uuid-1", &serviceDefinition) + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.NotEmpty(t, err.Error()) + + serviceAPIService.AssertExpectations(t) + serviceRepository.AssertExpectations(t) + minioService.AssertExpectations(t) + }) +} + +func TestServiceDefinitionService_Delete(t *testing.T) { + + t.Run("should delete a service", func(t *testing.T) { + // given + serviceAPIService := new(serviceapimocks.Service) + serviceAPIService.On("Delete", "re", "uuid-1").Return(nil) + + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Delete", "re", "uuid-1").Return(nil) + + uuidGenerator := new(uuidmocks.Generator) + uuidGenerator.On("NewUUID").Return("uuid-1") + + minioService := new(miniomocks.Service) + minioService.On("Remove", "uuid-1").Return(nil) + + service := NewServiceDefinitionService(uuidGenerator, serviceAPIService, serviceRepository, minioService) + + // when + err := service.Delete("re", "uuid-1") + + // then + assert.NoError(t, err) + + serviceAPIService.AssertExpectations(t) + serviceRepository.AssertExpectations(t) + minioService.AssertExpectations(t) + }) + + t.Run("should return an error if API deletion failed", func(t *testing.T) { + // given + serviceAPIService := new(serviceapimocks.Service) + serviceAPIService.On("Delete", "re", "uuid-1").Return(apperrors.Internal("an error")) + + uuidGenerator := new(uuidmocks.Generator) + uuidGenerator.On("NewUUID").Return("uuid-1") + + service := NewServiceDefinitionService(uuidGenerator, serviceAPIService, nil, nil) + + // when + err := service.Delete("re", "uuid-1") + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.NotEmpty(t, err.Error()) + + serviceAPIService.AssertExpectations(t) + }) + + t.Run("should return an error if remoteenv delete failed", func(t *testing.T) { + // given + serviceAPIService := new(serviceapimocks.Service) + serviceAPIService.On("Delete", "re", "uuid-1").Return(nil) + + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Delete", "re", "uuid-1").Return(apperrors.Internal("an error")) + + service := NewServiceDefinitionService(nil, serviceAPIService, serviceRepository, nil) + + // when + err := service.Delete("re", "uuid-1") + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.NotEmpty(t, err.Error()) + + serviceAPIService.AssertExpectations(t) + serviceRepository.AssertExpectations(t) + }) + + t.Run("should return an error if Minio data deletion failed", func(t *testing.T) { + // given + serviceAPIService := new(serviceapimocks.Service) + serviceAPIService.On("Delete", "re", "uuid-1").Return(nil) + + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Delete", "re", "uuid-1").Return(nil) + + minioService := new(miniomocks.Service) + minioService.On("Remove", "uuid-1").Return(apperrors.Internal("an error")) + + service := NewServiceDefinitionService(nil, serviceAPIService, serviceRepository, minioService) + + // when + err := service.Delete("re", "uuid-1") + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.NotEmpty(t, err.Error()) + + serviceAPIService.AssertExpectations(t) + serviceRepository.AssertExpectations(t) + minioService.AssertExpectations(t) + }) +} + +func TestServiceDefinitionService_GetAPI(t *testing.T) { + + t.Run("should get API", func(t *testing.T) { + // given + remoteEnvServiceAPI := &remoteenv.ServiceAPI{} + remoteEnvService := remoteenv.Service{API: remoteEnvServiceAPI} + serviceAPI := &serviceapi.API{} + + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Get", "re", "uuid-1").Return(remoteEnvService, nil) + + serviceAPIService := new(serviceapimocks.Service) + serviceAPIService.On("Read", "re", remoteEnvServiceAPI).Return(serviceAPI, nil) + + service := NewServiceDefinitionService(nil, serviceAPIService, serviceRepository, nil) + + // when + result, err := service.GetAPI("re", "uuid-1") + + // then + require.NoError(t, err) + + assert.Equal(t, serviceAPI, result) + }) + + t.Run("should return not found error if service does not exist", func(t *testing.T) { + // given + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Get", "re", "uuid-1").Return(remoteenv.Service{}, apperrors.NotFound("missing")) + + service := NewServiceDefinitionService(nil, nil, serviceRepository, nil) + + // when + result, err := service.GetAPI("re", "uuid-1") + + // then + assert.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, apperrors.CodeNotFound, err.Code()) + }) + + t.Run("should return internal error if service does not exist", func(t *testing.T) { + // given + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Get", "re", "uuid-1").Return(remoteenv.Service{}, apperrors.Internal("some error")) + + service := NewServiceDefinitionService(nil, nil, serviceRepository, nil) + + // when + result, err := service.GetAPI("re", "uuid-1") + + // then + assert.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.Contains(t, err.Error(), "some error") + }) + + t.Run("should return bad request if service does not have API", func(t *testing.T) { + // given + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Get", "re", "uuid-1").Return(remoteenv.Service{}, nil) + + service := NewServiceDefinitionService(nil, nil, serviceRepository, nil) + + // when + result, err := service.GetAPI("re", "uuid-1") + + // then + assert.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, apperrors.CodeWrongInput, err.Code()) + }) + + t.Run("should return internal error if reading service API fails", func(t *testing.T) { + // given + remoteEnvServiceAPI := &remoteenv.ServiceAPI{} + remoteEnvService := remoteenv.Service{API: remoteEnvServiceAPI} + + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Get", "re", "uuid-1").Return(remoteEnvService, nil) + + serviceAPIService := new(serviceapimocks.Service) + serviceAPIService.On("Read", "re", remoteEnvServiceAPI).Return(nil, apperrors.Internal("some error")) + + service := NewServiceDefinitionService(nil, serviceAPIService, serviceRepository, nil) + + // when + result, err := service.GetAPI("re", "uuid-1") + + // then + assert.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.Contains(t, err.Error(), "some error") + }) +} diff --git a/components/application-connector/internal/metadata/uuid/generator.go b/components/application-connector/internal/metadata/uuid/generator.go new file mode 100644 index 000000000000..49e7362bbb48 --- /dev/null +++ b/components/application-connector/internal/metadata/uuid/generator.go @@ -0,0 +1,15 @@ +package uuid + +// Generator is an interface of an UUID generator. +type Generator interface { + // NewUUID generates an UUID. + NewUUID() string +} + +// GeneratorFunc is an adapter that simplifies creating Generator. +type GeneratorFunc func() string + +// NewUUID returns new UUID. It calls the inner method of GeneratorFunc. +func (f GeneratorFunc) NewUUID() string { + return f() +} diff --git a/components/application-connector/internal/metadata/uuid/mocks/Generator.go b/components/application-connector/internal/metadata/uuid/mocks/Generator.go new file mode 100644 index 000000000000..cc39959d75ed --- /dev/null +++ b/components/application-connector/internal/metadata/uuid/mocks/Generator.go @@ -0,0 +1,23 @@ +// Code generated by mockery v1.0.0 +package mocks + +import mock "github.com/stretchr/testify/mock" + +// Generator is an autogenerated mock type for the Generator type +type Generator struct { + mock.Mock +} + +// NewUUID provides a mock function with given fields: +func (_m *Generator) NewUUID() string { + ret := _m.Called() + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} diff --git a/components/application-connector/pkg/apis/istio/v1alpha2/doc.go b/components/application-connector/pkg/apis/istio/v1alpha2/doc.go new file mode 100644 index 000000000000..838e4f079b74 --- /dev/null +++ b/components/application-connector/pkg/apis/istio/v1alpha2/doc.go @@ -0,0 +1,2 @@ +// +k8s:deepcopy-gen=package +package v1alpha2 diff --git a/components/application-connector/pkg/apis/istio/v1alpha2/register.go b/components/application-connector/pkg/apis/istio/v1alpha2/register.go new file mode 100644 index 000000000000..031e1d355824 --- /dev/null +++ b/components/application-connector/pkg/apis/istio/v1alpha2/register.go @@ -0,0 +1,45 @@ +package v1alpha2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + Group = "config.istio.io" + Version = "v1alpha2" +) + +var ( + // SchemeGroupVersion is group version used to register these objects + SchemeGroupVersion = schema.GroupVersion{ + Group: Group, + Version: Version, + } + + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + AddToScheme = SchemeBuilder.AddToScheme +) + +// Kind takes an unqualified kind and returns back a Group qualified GroupKind +func Kind(kind string) schema.GroupKind { + return SchemeGroupVersion.WithKind(kind).GroupKind() +} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} + +// Adds the list of known types to Scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypeWithName(SchemeGroupVersion.WithKind("rule"), &Rule{}) + scheme.AddKnownTypeWithName(SchemeGroupVersion.WithKind("ruleList"), &RuleList{}) + scheme.AddKnownTypeWithName(SchemeGroupVersion.WithKind("denier"), &Denier{}) + scheme.AddKnownTypeWithName(SchemeGroupVersion.WithKind("denierList"), &DenierList{}) + scheme.AddKnownTypeWithName(SchemeGroupVersion.WithKind("checknothing"), &Checknothing{}) + scheme.AddKnownTypeWithName(SchemeGroupVersion.WithKind("checknothingList"), &ChecknothingList{}) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/components/application-connector/pkg/apis/istio/v1alpha2/types.go b/components/application-connector/pkg/apis/istio/v1alpha2/types.go new file mode 100644 index 000000000000..917393ed8c6b --- /dev/null +++ b/components/application-connector/pkg/apis/istio/v1alpha2/types.go @@ -0,0 +1,84 @@ +package v1alpha2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// Rule defines Istio Rule +type Rule struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec *RuleSpec `json:"spec"` +} + +// RuleSpec defines specification for Rule +type RuleSpec struct { + Match string `json:"match"` + Actions []RuleAction `json:"actions"` +} + +// RuleAction defines action for Rule +type RuleAction struct { + Handler string `json:"handler"` + Instances []string `json:"instances"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// RuleList is a list of Rules +type RuleList struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Items []Rule `json:"items"` +} + +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// Denier defines Istio Denier +type Denier struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec *DenierSpec `json:"spec"` +} + +// DenierSpec defines specification for Denier +type DenierSpec struct { + Status *DenierStatus `json:"status"` +} + +// DenierStatus defines status for Denier +type DenierStatus struct { + Code int32 `json:"code"` + Message string `json:"message"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// DenierList is a list of Deniers +type DenierList struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Items []Denier `json:"items"` +} + +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// Checknothing defines Istio CheckNothing +type Checknothing struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// ChecknothingList is a list of CheckNothing +type ChecknothingList struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Items []Checknothing `json:"items"` +} diff --git a/components/application-connector/pkg/apis/istio/v1alpha2/zz_generated.deepcopy.go b/components/application-connector/pkg/apis/istio/v1alpha2/zz_generated.deepcopy.go new file mode 100644 index 000000000000..f3d6f552fe82 --- /dev/null +++ b/components/application-connector/pkg/apis/istio/v1alpha2/zz_generated.deepcopy.go @@ -0,0 +1,289 @@ +// +build !ignore_autogenerated + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + 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 *Checknothing) DeepCopyInto(out *Checknothing) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Checknothing. +func (in *Checknothing) DeepCopy() *Checknothing { + if in == nil { + return nil + } + out := new(Checknothing) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Checknothing) 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 *ChecknothingList) DeepCopyInto(out *ChecknothingList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Checknothing, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChecknothingList. +func (in *ChecknothingList) DeepCopy() *ChecknothingList { + if in == nil { + return nil + } + out := new(ChecknothingList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ChecknothingList) 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 *Denier) DeepCopyInto(out *Denier) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + if in.Spec != nil { + in, out := &in.Spec, &out.Spec + if *in == nil { + *out = nil + } else { + *out = new(DenierSpec) + (*in).DeepCopyInto(*out) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Denier. +func (in *Denier) DeepCopy() *Denier { + if in == nil { + return nil + } + out := new(Denier) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Denier) 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 *DenierList) DeepCopyInto(out *DenierList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Denier, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DenierList. +func (in *DenierList) DeepCopy() *DenierList { + if in == nil { + return nil + } + out := new(DenierList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DenierList) 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 *DenierSpec) DeepCopyInto(out *DenierSpec) { + *out = *in + if in.Status != nil { + in, out := &in.Status, &out.Status + if *in == nil { + *out = nil + } else { + *out = new(DenierStatus) + **out = **in + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DenierSpec. +func (in *DenierSpec) DeepCopy() *DenierSpec { + if in == nil { + return nil + } + out := new(DenierSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DenierStatus) DeepCopyInto(out *DenierStatus) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DenierStatus. +func (in *DenierStatus) DeepCopy() *DenierStatus { + if in == nil { + return nil + } + out := new(DenierStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Rule) DeepCopyInto(out *Rule) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + if in.Spec != nil { + in, out := &in.Spec, &out.Spec + if *in == nil { + *out = nil + } else { + *out = new(RuleSpec) + (*in).DeepCopyInto(*out) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Rule. +func (in *Rule) DeepCopy() *Rule { + if in == nil { + return nil + } + out := new(Rule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Rule) 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 *RuleAction) DeepCopyInto(out *RuleAction) { + *out = *in + if in.Instances != nil { + in, out := &in.Instances, &out.Instances + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuleAction. +func (in *RuleAction) DeepCopy() *RuleAction { + if in == nil { + return nil + } + out := new(RuleAction) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RuleList) DeepCopyInto(out *RuleList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Rule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuleList. +func (in *RuleList) DeepCopy() *RuleList { + if in == nil { + return nil + } + out := new(RuleList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RuleList) 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 *RuleSpec) DeepCopyInto(out *RuleSpec) { + *out = *in + if in.Actions != nil { + in, out := &in.Actions, &out.Actions + *out = make([]RuleAction, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuleSpec. +func (in *RuleSpec) DeepCopy() *RuleSpec { + if in == nil { + return nil + } + out := new(RuleSpec) + in.DeepCopyInto(out) + return out +} diff --git a/components/application-connector/pkg/client/clientset/versioned/clientset.go b/components/application-connector/pkg/client/clientset/versioned/clientset.go new file mode 100644 index 000000000000..45a2457aec1b --- /dev/null +++ b/components/application-connector/pkg/client/clientset/versioned/clientset.go @@ -0,0 +1,84 @@ +// Code generated by client-gen. DO NOT EDIT. + +package versioned + +import ( + glog "github.com/golang/glog" + istiov1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2" + discovery "k8s.io/client-go/discovery" + rest "k8s.io/client-go/rest" + flowcontrol "k8s.io/client-go/util/flowcontrol" +) + +type Interface interface { + Discovery() discovery.DiscoveryInterface + IstioV1alpha2() istiov1alpha2.IstioV1alpha2Interface + // Deprecated: please explicitly pick a version if possible. + Istio() istiov1alpha2.IstioV1alpha2Interface +} + +// Clientset contains the clients for groups. Each group has exactly one +// version included in a Clientset. +type Clientset struct { + *discovery.DiscoveryClient + istioV1alpha2 *istiov1alpha2.IstioV1alpha2Client +} + +// IstioV1alpha2 retrieves the IstioV1alpha2Client +func (c *Clientset) IstioV1alpha2() istiov1alpha2.IstioV1alpha2Interface { + return c.istioV1alpha2 +} + +// Deprecated: Istio retrieves the default version of IstioClient. +// Please explicitly pick a version. +func (c *Clientset) Istio() istiov1alpha2.IstioV1alpha2Interface { + return c.istioV1alpha2 +} + +// Discovery retrieves the DiscoveryClient +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + if c == nil { + return nil + } + return c.DiscoveryClient +} + +// NewForConfig creates a new Clientset for the given config. +func NewForConfig(c *rest.Config) (*Clientset, error) { + configShallowCopy := *c + if configShallowCopy.RateLimiter == nil && configShallowCopy.QPS > 0 { + configShallowCopy.RateLimiter = flowcontrol.NewTokenBucketRateLimiter(configShallowCopy.QPS, configShallowCopy.Burst) + } + var cs Clientset + var err error + cs.istioV1alpha2, err = istiov1alpha2.NewForConfig(&configShallowCopy) + if err != nil { + return nil, err + } + + cs.DiscoveryClient, err = discovery.NewDiscoveryClientForConfig(&configShallowCopy) + if err != nil { + glog.Errorf("failed to create the DiscoveryClient: %v", err) + return nil, err + } + return &cs, nil +} + +// NewForConfigOrDie creates a new Clientset for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *Clientset { + var cs Clientset + cs.istioV1alpha2 = istiov1alpha2.NewForConfigOrDie(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClientForConfigOrDie(c) + return &cs +} + +// New creates a new Clientset for the given RESTClient. +func New(c rest.Interface) *Clientset { + var cs Clientset + cs.istioV1alpha2 = istiov1alpha2.New(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClient(c) + return &cs +} diff --git a/components/application-connector/pkg/client/clientset/versioned/doc.go b/components/application-connector/pkg/client/clientset/versioned/doc.go new file mode 100644 index 000000000000..0e0c2a8900e2 --- /dev/null +++ b/components/application-connector/pkg/client/clientset/versioned/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated clientset. +package versioned diff --git a/components/application-connector/pkg/client/clientset/versioned/fake/clientset_generated.go b/components/application-connector/pkg/client/clientset/versioned/fake/clientset_generated.go new file mode 100644 index 000000000000..565ef67642e7 --- /dev/null +++ b/components/application-connector/pkg/client/clientset/versioned/fake/clientset_generated.go @@ -0,0 +1,65 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + clientset "github.com/kyma-project/kyma/components/application-connector/pkg/client/clientset/versioned" + istiov1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2" + fakeistiov1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/fake" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/discovery" + fakediscovery "k8s.io/client-go/discovery/fake" + "k8s.io/client-go/testing" +) + +// NewSimpleClientset returns a clientset that will respond with the provided objects. +// It's backed by a very simple object tracker that processes creates, updates and deletions as-is, +// without applying any validations and/or defaults. It shouldn't be considered a replacement +// for a real clientset and is mostly useful in simple unit tests. +func NewSimpleClientset(objects ...runtime.Object) *Clientset { + o := testing.NewObjectTracker(scheme, codecs.UniversalDecoder()) + for _, obj := range objects { + if err := o.Add(obj); err != nil { + panic(err) + } + } + + fakePtr := testing.Fake{} + fakePtr.AddReactor("*", "*", testing.ObjectReaction(o)) + fakePtr.AddWatchReactor("*", func(action testing.Action) (handled bool, ret watch.Interface, err error) { + gvr := action.GetResource() + ns := action.GetNamespace() + watch, err := o.Watch(gvr, ns) + if err != nil { + return false, nil, err + } + return true, watch, nil + }) + + return &Clientset{fakePtr, &fakediscovery.FakeDiscovery{Fake: &fakePtr}} +} + +// Clientset implements clientset.Interface. Meant to be embedded into a +// struct to get a default implementation. This makes faking out just the method +// you want to test easier. +type Clientset struct { + testing.Fake + discovery *fakediscovery.FakeDiscovery +} + +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + return c.discovery +} + +var _ clientset.Interface = &Clientset{} + +// IstioV1alpha2 retrieves the IstioV1alpha2Client +func (c *Clientset) IstioV1alpha2() istiov1alpha2.IstioV1alpha2Interface { + return &fakeistiov1alpha2.FakeIstioV1alpha2{Fake: &c.Fake} +} + +// Istio retrieves the IstioV1alpha2Client +func (c *Clientset) Istio() istiov1alpha2.IstioV1alpha2Interface { + return &fakeistiov1alpha2.FakeIstioV1alpha2{Fake: &c.Fake} +} diff --git a/components/application-connector/pkg/client/clientset/versioned/fake/doc.go b/components/application-connector/pkg/client/clientset/versioned/fake/doc.go new file mode 100644 index 000000000000..3630ed1cd17d --- /dev/null +++ b/components/application-connector/pkg/client/clientset/versioned/fake/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated fake clientset. +package fake diff --git a/components/application-connector/pkg/client/clientset/versioned/fake/register.go b/components/application-connector/pkg/client/clientset/versioned/fake/register.go new file mode 100644 index 000000000000..1891c9aede09 --- /dev/null +++ b/components/application-connector/pkg/client/clientset/versioned/fake/register.go @@ -0,0 +1,38 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + istiov1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/apis/istio/v1alpha2" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var scheme = runtime.NewScheme() +var codecs = serializer.NewCodecFactory(scheme) +var parameterCodec = runtime.NewParameterCodec(scheme) + +func init() { + v1.AddToGroupVersion(scheme, schema.GroupVersion{Version: "v1"}) + AddToScheme(scheme) +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +func AddToScheme(scheme *runtime.Scheme) { + istiov1alpha2.AddToScheme(scheme) +} diff --git a/components/application-connector/pkg/client/clientset/versioned/scheme/doc.go b/components/application-connector/pkg/client/clientset/versioned/scheme/doc.go new file mode 100644 index 000000000000..14db57a58f8d --- /dev/null +++ b/components/application-connector/pkg/client/clientset/versioned/scheme/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package contains the scheme of the automatically generated clientset. +package scheme diff --git a/components/application-connector/pkg/client/clientset/versioned/scheme/register.go b/components/application-connector/pkg/client/clientset/versioned/scheme/register.go new file mode 100644 index 000000000000..03f4ed0a6620 --- /dev/null +++ b/components/application-connector/pkg/client/clientset/versioned/scheme/register.go @@ -0,0 +1,38 @@ +// Code generated by client-gen. DO NOT EDIT. + +package scheme + +import ( + istiov1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/apis/istio/v1alpha2" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var Scheme = runtime.NewScheme() +var Codecs = serializer.NewCodecFactory(Scheme) +var ParameterCodec = runtime.NewParameterCodec(Scheme) + +func init() { + v1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"}) + AddToScheme(Scheme) +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +func AddToScheme(scheme *runtime.Scheme) { + istiov1alpha2.AddToScheme(scheme) +} diff --git a/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/checknothing.go b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/checknothing.go new file mode 100644 index 000000000000..d909de5779e0 --- /dev/null +++ b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/checknothing.go @@ -0,0 +1,141 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/apis/istio/v1alpha2" + scheme "github.com/kyma-project/kyma/components/application-connector/pkg/client/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// ChecknothingsGetter has a method to return a ChecknothingInterface. +// A group's client should implement this interface. +type ChecknothingsGetter interface { + Checknothings(namespace string) ChecknothingInterface +} + +// ChecknothingInterface has methods to work with Checknothing resources. +type ChecknothingInterface interface { + Create(*v1alpha2.Checknothing) (*v1alpha2.Checknothing, error) + Update(*v1alpha2.Checknothing) (*v1alpha2.Checknothing, error) + Delete(name string, options *v1.DeleteOptions) error + DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error + Get(name string, options v1.GetOptions) (*v1alpha2.Checknothing, error) + List(opts v1.ListOptions) (*v1alpha2.ChecknothingList, error) + Watch(opts v1.ListOptions) (watch.Interface, error) + Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha2.Checknothing, err error) + ChecknothingExpansion +} + +// checknothings implements ChecknothingInterface +type checknothings struct { + client rest.Interface + ns string +} + +// newChecknothings returns a Checknothings +func newChecknothings(c *IstioV1alpha2Client, namespace string) *checknothings { + return &checknothings{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the checknothing, and returns the corresponding checknothing object, and an error if there is any. +func (c *checknothings) Get(name string, options v1.GetOptions) (result *v1alpha2.Checknothing, err error) { + result = &v1alpha2.Checknothing{} + err = c.client.Get(). + Namespace(c.ns). + Resource("checknothings"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of Checknothings that match those selectors. +func (c *checknothings) List(opts v1.ListOptions) (result *v1alpha2.ChecknothingList, err error) { + result = &v1alpha2.ChecknothingList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("checknothings"). + VersionedParams(&opts, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested checknothings. +func (c *checknothings) Watch(opts v1.ListOptions) (watch.Interface, error) { + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("checknothings"). + VersionedParams(&opts, scheme.ParameterCodec). + Watch() +} + +// Create takes the representation of a checknothing and creates it. Returns the server's representation of the checknothing, and an error, if there is any. +func (c *checknothings) Create(checknothing *v1alpha2.Checknothing) (result *v1alpha2.Checknothing, err error) { + result = &v1alpha2.Checknothing{} + err = c.client.Post(). + Namespace(c.ns). + Resource("checknothings"). + Body(checknothing). + Do(). + Into(result) + return +} + +// Update takes the representation of a checknothing and updates it. Returns the server's representation of the checknothing, and an error, if there is any. +func (c *checknothings) Update(checknothing *v1alpha2.Checknothing) (result *v1alpha2.Checknothing, err error) { + result = &v1alpha2.Checknothing{} + err = c.client.Put(). + Namespace(c.ns). + Resource("checknothings"). + Name(checknothing.Name). + Body(checknothing). + Do(). + Into(result) + return +} + +// Delete takes name of the checknothing and deletes it. Returns an error if one occurs. +func (c *checknothings) Delete(name string, options *v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("checknothings"). + Name(name). + Body(options). + Do(). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *checknothings) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("checknothings"). + VersionedParams(&listOptions, scheme.ParameterCodec). + Body(options). + Do(). + Error() +} + +// Patch applies the patch and returns the patched checknothing. +func (c *checknothings) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha2.Checknothing, err error) { + result = &v1alpha2.Checknothing{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("checknothings"). + SubResource(subresources...). + Name(name). + Body(data). + Do(). + Into(result) + return +} diff --git a/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/denier.go b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/denier.go new file mode 100644 index 000000000000..143d8f3734d0 --- /dev/null +++ b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/denier.go @@ -0,0 +1,141 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/apis/istio/v1alpha2" + scheme "github.com/kyma-project/kyma/components/application-connector/pkg/client/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// DeniersGetter has a method to return a DenierInterface. +// A group's client should implement this interface. +type DeniersGetter interface { + Deniers(namespace string) DenierInterface +} + +// DenierInterface has methods to work with Denier resources. +type DenierInterface interface { + Create(*v1alpha2.Denier) (*v1alpha2.Denier, error) + Update(*v1alpha2.Denier) (*v1alpha2.Denier, error) + Delete(name string, options *v1.DeleteOptions) error + DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error + Get(name string, options v1.GetOptions) (*v1alpha2.Denier, error) + List(opts v1.ListOptions) (*v1alpha2.DenierList, error) + Watch(opts v1.ListOptions) (watch.Interface, error) + Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha2.Denier, err error) + DenierExpansion +} + +// deniers implements DenierInterface +type deniers struct { + client rest.Interface + ns string +} + +// newDeniers returns a Deniers +func newDeniers(c *IstioV1alpha2Client, namespace string) *deniers { + return &deniers{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the denier, and returns the corresponding denier object, and an error if there is any. +func (c *deniers) Get(name string, options v1.GetOptions) (result *v1alpha2.Denier, err error) { + result = &v1alpha2.Denier{} + err = c.client.Get(). + Namespace(c.ns). + Resource("deniers"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of Deniers that match those selectors. +func (c *deniers) List(opts v1.ListOptions) (result *v1alpha2.DenierList, err error) { + result = &v1alpha2.DenierList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("deniers"). + VersionedParams(&opts, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested deniers. +func (c *deniers) Watch(opts v1.ListOptions) (watch.Interface, error) { + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("deniers"). + VersionedParams(&opts, scheme.ParameterCodec). + Watch() +} + +// Create takes the representation of a denier and creates it. Returns the server's representation of the denier, and an error, if there is any. +func (c *deniers) Create(denier *v1alpha2.Denier) (result *v1alpha2.Denier, err error) { + result = &v1alpha2.Denier{} + err = c.client.Post(). + Namespace(c.ns). + Resource("deniers"). + Body(denier). + Do(). + Into(result) + return +} + +// Update takes the representation of a denier and updates it. Returns the server's representation of the denier, and an error, if there is any. +func (c *deniers) Update(denier *v1alpha2.Denier) (result *v1alpha2.Denier, err error) { + result = &v1alpha2.Denier{} + err = c.client.Put(). + Namespace(c.ns). + Resource("deniers"). + Name(denier.Name). + Body(denier). + Do(). + Into(result) + return +} + +// Delete takes name of the denier and deletes it. Returns an error if one occurs. +func (c *deniers) Delete(name string, options *v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("deniers"). + Name(name). + Body(options). + Do(). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *deniers) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("deniers"). + VersionedParams(&listOptions, scheme.ParameterCodec). + Body(options). + Do(). + Error() +} + +// Patch applies the patch and returns the patched denier. +func (c *deniers) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha2.Denier, err error) { + result = &v1alpha2.Denier{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("deniers"). + SubResource(subresources...). + Name(name). + Body(data). + Do(). + Into(result) + return +} diff --git a/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/doc.go b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/doc.go new file mode 100644 index 000000000000..c11da26828d9 --- /dev/null +++ b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated typed clients. +package v1alpha2 diff --git a/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/fake/doc.go b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/fake/doc.go new file mode 100644 index 000000000000..2b5ba4c8e442 --- /dev/null +++ b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/fake/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// Package fake has the automatically generated clients. +package fake diff --git a/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/fake/fake_checknothing.go b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/fake/fake_checknothing.go new file mode 100644 index 000000000000..d6e2964512ba --- /dev/null +++ b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/fake/fake_checknothing.go @@ -0,0 +1,112 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/apis/istio/v1alpha2" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeChecknothings implements ChecknothingInterface +type FakeChecknothings struct { + Fake *FakeIstioV1alpha2 + ns string +} + +var checknothingsResource = schema.GroupVersionResource{Group: "istio", Version: "v1alpha2", Resource: "checknothings"} + +var checknothingsKind = schema.GroupVersionKind{Group: "istio", Version: "v1alpha2", Kind: "Checknothing"} + +// Get takes name of the checknothing, and returns the corresponding checknothing object, and an error if there is any. +func (c *FakeChecknothings) Get(name string, options v1.GetOptions) (result *v1alpha2.Checknothing, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(checknothingsResource, c.ns, name), &v1alpha2.Checknothing{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha2.Checknothing), err +} + +// List takes label and field selectors, and returns the list of Checknothings that match those selectors. +func (c *FakeChecknothings) List(opts v1.ListOptions) (result *v1alpha2.ChecknothingList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(checknothingsResource, checknothingsKind, c.ns, opts), &v1alpha2.ChecknothingList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha2.ChecknothingList{} + for _, item := range obj.(*v1alpha2.ChecknothingList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested checknothings. +func (c *FakeChecknothings) Watch(opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(checknothingsResource, c.ns, opts)) + +} + +// Create takes the representation of a checknothing and creates it. Returns the server's representation of the checknothing, and an error, if there is any. +func (c *FakeChecknothings) Create(checknothing *v1alpha2.Checknothing) (result *v1alpha2.Checknothing, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(checknothingsResource, c.ns, checknothing), &v1alpha2.Checknothing{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha2.Checknothing), err +} + +// Update takes the representation of a checknothing and updates it. Returns the server's representation of the checknothing, and an error, if there is any. +func (c *FakeChecknothings) Update(checknothing *v1alpha2.Checknothing) (result *v1alpha2.Checknothing, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(checknothingsResource, c.ns, checknothing), &v1alpha2.Checknothing{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha2.Checknothing), err +} + +// Delete takes name of the checknothing and deletes it. Returns an error if one occurs. +func (c *FakeChecknothings) Delete(name string, options *v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(checknothingsResource, c.ns, name), &v1alpha2.Checknothing{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeChecknothings) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(checknothingsResource, c.ns, listOptions) + + _, err := c.Fake.Invokes(action, &v1alpha2.ChecknothingList{}) + return err +} + +// Patch applies the patch and returns the patched checknothing. +func (c *FakeChecknothings) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha2.Checknothing, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(checknothingsResource, c.ns, name, data, subresources...), &v1alpha2.Checknothing{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha2.Checknothing), err +} diff --git a/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/fake/fake_denier.go b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/fake/fake_denier.go new file mode 100644 index 000000000000..7c59b0e6f6e8 --- /dev/null +++ b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/fake/fake_denier.go @@ -0,0 +1,112 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/apis/istio/v1alpha2" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeDeniers implements DenierInterface +type FakeDeniers struct { + Fake *FakeIstioV1alpha2 + ns string +} + +var deniersResource = schema.GroupVersionResource{Group: "istio", Version: "v1alpha2", Resource: "deniers"} + +var deniersKind = schema.GroupVersionKind{Group: "istio", Version: "v1alpha2", Kind: "Denier"} + +// Get takes name of the denier, and returns the corresponding denier object, and an error if there is any. +func (c *FakeDeniers) Get(name string, options v1.GetOptions) (result *v1alpha2.Denier, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(deniersResource, c.ns, name), &v1alpha2.Denier{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha2.Denier), err +} + +// List takes label and field selectors, and returns the list of Deniers that match those selectors. +func (c *FakeDeniers) List(opts v1.ListOptions) (result *v1alpha2.DenierList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(deniersResource, deniersKind, c.ns, opts), &v1alpha2.DenierList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha2.DenierList{} + for _, item := range obj.(*v1alpha2.DenierList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested deniers. +func (c *FakeDeniers) Watch(opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(deniersResource, c.ns, opts)) + +} + +// Create takes the representation of a denier and creates it. Returns the server's representation of the denier, and an error, if there is any. +func (c *FakeDeniers) Create(denier *v1alpha2.Denier) (result *v1alpha2.Denier, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(deniersResource, c.ns, denier), &v1alpha2.Denier{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha2.Denier), err +} + +// Update takes the representation of a denier and updates it. Returns the server's representation of the denier, and an error, if there is any. +func (c *FakeDeniers) Update(denier *v1alpha2.Denier) (result *v1alpha2.Denier, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(deniersResource, c.ns, denier), &v1alpha2.Denier{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha2.Denier), err +} + +// Delete takes name of the denier and deletes it. Returns an error if one occurs. +func (c *FakeDeniers) Delete(name string, options *v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(deniersResource, c.ns, name), &v1alpha2.Denier{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeDeniers) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(deniersResource, c.ns, listOptions) + + _, err := c.Fake.Invokes(action, &v1alpha2.DenierList{}) + return err +} + +// Patch applies the patch and returns the patched denier. +func (c *FakeDeniers) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha2.Denier, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(deniersResource, c.ns, name, data, subresources...), &v1alpha2.Denier{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha2.Denier), err +} diff --git a/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/fake/fake_istio_client.go b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/fake/fake_istio_client.go new file mode 100644 index 000000000000..1148292377ac --- /dev/null +++ b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/fake/fake_istio_client.go @@ -0,0 +1,32 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2" + rest "k8s.io/client-go/rest" + testing "k8s.io/client-go/testing" +) + +type FakeIstioV1alpha2 struct { + *testing.Fake +} + +func (c *FakeIstioV1alpha2) Checknothings(namespace string) v1alpha2.ChecknothingInterface { + return &FakeChecknothings{c, namespace} +} + +func (c *FakeIstioV1alpha2) Deniers(namespace string) v1alpha2.DenierInterface { + return &FakeDeniers{c, namespace} +} + +func (c *FakeIstioV1alpha2) Rules(namespace string) v1alpha2.RuleInterface { + return &FakeRules{c, namespace} +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *FakeIstioV1alpha2) RESTClient() rest.Interface { + var ret *rest.RESTClient + return ret +} diff --git a/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/fake/fake_rule.go b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/fake/fake_rule.go new file mode 100644 index 000000000000..46dfe00b8f35 --- /dev/null +++ b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/fake/fake_rule.go @@ -0,0 +1,112 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/apis/istio/v1alpha2" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeRules implements RuleInterface +type FakeRules struct { + Fake *FakeIstioV1alpha2 + ns string +} + +var rulesResource = schema.GroupVersionResource{Group: "istio", Version: "v1alpha2", Resource: "rules"} + +var rulesKind = schema.GroupVersionKind{Group: "istio", Version: "v1alpha2", Kind: "Rule"} + +// Get takes name of the rule, and returns the corresponding rule object, and an error if there is any. +func (c *FakeRules) Get(name string, options v1.GetOptions) (result *v1alpha2.Rule, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(rulesResource, c.ns, name), &v1alpha2.Rule{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha2.Rule), err +} + +// List takes label and field selectors, and returns the list of Rules that match those selectors. +func (c *FakeRules) List(opts v1.ListOptions) (result *v1alpha2.RuleList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(rulesResource, rulesKind, c.ns, opts), &v1alpha2.RuleList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha2.RuleList{} + for _, item := range obj.(*v1alpha2.RuleList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested rules. +func (c *FakeRules) Watch(opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(rulesResource, c.ns, opts)) + +} + +// Create takes the representation of a rule and creates it. Returns the server's representation of the rule, and an error, if there is any. +func (c *FakeRules) Create(rule *v1alpha2.Rule) (result *v1alpha2.Rule, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(rulesResource, c.ns, rule), &v1alpha2.Rule{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha2.Rule), err +} + +// Update takes the representation of a rule and updates it. Returns the server's representation of the rule, and an error, if there is any. +func (c *FakeRules) Update(rule *v1alpha2.Rule) (result *v1alpha2.Rule, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(rulesResource, c.ns, rule), &v1alpha2.Rule{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha2.Rule), err +} + +// Delete takes name of the rule and deletes it. Returns an error if one occurs. +func (c *FakeRules) Delete(name string, options *v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(rulesResource, c.ns, name), &v1alpha2.Rule{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeRules) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(rulesResource, c.ns, listOptions) + + _, err := c.Fake.Invokes(action, &v1alpha2.RuleList{}) + return err +} + +// Patch applies the patch and returns the patched rule. +func (c *FakeRules) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha2.Rule, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(rulesResource, c.ns, name, data, subresources...), &v1alpha2.Rule{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha2.Rule), err +} diff --git a/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/generated_expansion.go b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/generated_expansion.go new file mode 100644 index 000000000000..2bf9237a2cd5 --- /dev/null +++ b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/generated_expansion.go @@ -0,0 +1,9 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha2 + +type ChecknothingExpansion interface{} + +type DenierExpansion interface{} + +type RuleExpansion interface{} diff --git a/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/istio_client.go b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/istio_client.go new file mode 100644 index 000000000000..ae7f50ce163a --- /dev/null +++ b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/istio_client.go @@ -0,0 +1,84 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/apis/istio/v1alpha2" + "github.com/kyma-project/kyma/components/application-connector/pkg/client/clientset/versioned/scheme" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + rest "k8s.io/client-go/rest" +) + +type IstioV1alpha2Interface interface { + RESTClient() rest.Interface + ChecknothingsGetter + DeniersGetter + RulesGetter +} + +// IstioV1alpha2Client is used to interact with features provided by the istio group. +type IstioV1alpha2Client struct { + restClient rest.Interface +} + +func (c *IstioV1alpha2Client) Checknothings(namespace string) ChecknothingInterface { + return newChecknothings(c, namespace) +} + +func (c *IstioV1alpha2Client) Deniers(namespace string) DenierInterface { + return newDeniers(c, namespace) +} + +func (c *IstioV1alpha2Client) Rules(namespace string) RuleInterface { + return newRules(c, namespace) +} + +// NewForConfig creates a new IstioV1alpha2Client for the given config. +func NewForConfig(c *rest.Config) (*IstioV1alpha2Client, error) { + config := *c + if err := setConfigDefaults(&config); err != nil { + return nil, err + } + client, err := rest.RESTClientFor(&config) + if err != nil { + return nil, err + } + return &IstioV1alpha2Client{client}, nil +} + +// NewForConfigOrDie creates a new IstioV1alpha2Client for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *IstioV1alpha2Client { + client, err := NewForConfig(c) + if err != nil { + panic(err) + } + return client +} + +// New creates a new IstioV1alpha2Client for the given RESTClient. +func New(c rest.Interface) *IstioV1alpha2Client { + return &IstioV1alpha2Client{c} +} + +func setConfigDefaults(config *rest.Config) error { + gv := v1alpha2.SchemeGroupVersion + config.GroupVersion = &gv + config.APIPath = "/apis" + config.NegotiatedSerializer = serializer.DirectCodecFactory{CodecFactory: scheme.Codecs} + + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } + + return nil +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *IstioV1alpha2Client) RESTClient() rest.Interface { + if c == nil { + return nil + } + return c.restClient +} diff --git a/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/rule.go b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/rule.go new file mode 100644 index 000000000000..9fff29efb612 --- /dev/null +++ b/components/application-connector/pkg/client/clientset/versioned/typed/istio/v1alpha2/rule.go @@ -0,0 +1,141 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/apis/istio/v1alpha2" + scheme "github.com/kyma-project/kyma/components/application-connector/pkg/client/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// RulesGetter has a method to return a RuleInterface. +// A group's client should implement this interface. +type RulesGetter interface { + Rules(namespace string) RuleInterface +} + +// RuleInterface has methods to work with Rule resources. +type RuleInterface interface { + Create(*v1alpha2.Rule) (*v1alpha2.Rule, error) + Update(*v1alpha2.Rule) (*v1alpha2.Rule, error) + Delete(name string, options *v1.DeleteOptions) error + DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error + Get(name string, options v1.GetOptions) (*v1alpha2.Rule, error) + List(opts v1.ListOptions) (*v1alpha2.RuleList, error) + Watch(opts v1.ListOptions) (watch.Interface, error) + Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha2.Rule, err error) + RuleExpansion +} + +// rules implements RuleInterface +type rules struct { + client rest.Interface + ns string +} + +// newRules returns a Rules +func newRules(c *IstioV1alpha2Client, namespace string) *rules { + return &rules{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the rule, and returns the corresponding rule object, and an error if there is any. +func (c *rules) Get(name string, options v1.GetOptions) (result *v1alpha2.Rule, err error) { + result = &v1alpha2.Rule{} + err = c.client.Get(). + Namespace(c.ns). + Resource("rules"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of Rules that match those selectors. +func (c *rules) List(opts v1.ListOptions) (result *v1alpha2.RuleList, err error) { + result = &v1alpha2.RuleList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("rules"). + VersionedParams(&opts, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested rules. +func (c *rules) Watch(opts v1.ListOptions) (watch.Interface, error) { + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("rules"). + VersionedParams(&opts, scheme.ParameterCodec). + Watch() +} + +// Create takes the representation of a rule and creates it. Returns the server's representation of the rule, and an error, if there is any. +func (c *rules) Create(rule *v1alpha2.Rule) (result *v1alpha2.Rule, err error) { + result = &v1alpha2.Rule{} + err = c.client.Post(). + Namespace(c.ns). + Resource("rules"). + Body(rule). + Do(). + Into(result) + return +} + +// Update takes the representation of a rule and updates it. Returns the server's representation of the rule, and an error, if there is any. +func (c *rules) Update(rule *v1alpha2.Rule) (result *v1alpha2.Rule, err error) { + result = &v1alpha2.Rule{} + err = c.client.Put(). + Namespace(c.ns). + Resource("rules"). + Name(rule.Name). + Body(rule). + Do(). + Into(result) + return +} + +// Delete takes name of the rule and deletes it. Returns an error if one occurs. +func (c *rules) Delete(name string, options *v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("rules"). + Name(name). + Body(options). + Do(). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *rules) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("rules"). + VersionedParams(&listOptions, scheme.ParameterCodec). + Body(options). + Do(). + Error() +} + +// Patch applies the patch and returns the patched rule. +func (c *rules) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha2.Rule, err error) { + result = &v1alpha2.Rule{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("rules"). + SubResource(subresources...). + Name(name). + Body(data). + Do(). + Into(result) + return +} diff --git a/components/application-connector/pkg/client/informers/externalversions/factory.go b/components/application-connector/pkg/client/informers/externalversions/factory.go new file mode 100644 index 000000000000..baf23b98e88b --- /dev/null +++ b/components/application-connector/pkg/client/informers/externalversions/factory.go @@ -0,0 +1,115 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + reflect "reflect" + sync "sync" + time "time" + + versioned "github.com/kyma-project/kyma/components/application-connector/pkg/client/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/application-connector/pkg/client/informers/externalversions/internalinterfaces" + istio "github.com/kyma-project/kyma/components/application-connector/pkg/client/informers/externalversions/istio" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +type sharedInformerFactory struct { + client versioned.Interface + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc + lock sync.Mutex + defaultResync time.Duration + + informers map[reflect.Type]cache.SharedIndexInformer + // startedInformers is used for tracking which informers have been started. + // This allows Start() to be called multiple times safely. + startedInformers map[reflect.Type]bool +} + +// NewSharedInformerFactory constructs a new instance of sharedInformerFactory +func NewSharedInformerFactory(client versioned.Interface, defaultResync time.Duration) SharedInformerFactory { + return NewFilteredSharedInformerFactory(client, defaultResync, v1.NamespaceAll, nil) +} + +// NewFilteredSharedInformerFactory constructs a new instance of sharedInformerFactory. +// Listers obtained via this SharedInformerFactory will be subject to the same filters +// as specified here. +func NewFilteredSharedInformerFactory(client versioned.Interface, defaultResync time.Duration, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) SharedInformerFactory { + return &sharedInformerFactory{ + client: client, + namespace: namespace, + tweakListOptions: tweakListOptions, + defaultResync: defaultResync, + informers: make(map[reflect.Type]cache.SharedIndexInformer), + startedInformers: make(map[reflect.Type]bool), + } +} + +// Start initializes all requested informers. +func (f *sharedInformerFactory) Start(stopCh <-chan struct{}) { + f.lock.Lock() + defer f.lock.Unlock() + + for informerType, informer := range f.informers { + if !f.startedInformers[informerType] { + go informer.Run(stopCh) + f.startedInformers[informerType] = true + } + } +} + +// WaitForCacheSync waits for all started informers' cache were synced. +func (f *sharedInformerFactory) WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool { + informers := func() map[reflect.Type]cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informers := map[reflect.Type]cache.SharedIndexInformer{} + for informerType, informer := range f.informers { + if f.startedInformers[informerType] { + informers[informerType] = informer + } + } + return informers + }() + + res := map[reflect.Type]bool{} + for informType, informer := range informers { + res[informType] = cache.WaitForCacheSync(stopCh, informer.HasSynced) + } + return res +} + +// InternalInformerFor returns the SharedIndexInformer for obj using an internal +// client. +func (f *sharedInformerFactory) InformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informerType := reflect.TypeOf(obj) + informer, exists := f.informers[informerType] + if exists { + return informer + } + informer = newFunc(f.client, f.defaultResync) + f.informers[informerType] = informer + + return informer +} + +// SharedInformerFactory provides shared informers for resources in all known +// API group versions. +type SharedInformerFactory interface { + internalinterfaces.SharedInformerFactory + ForResource(resource schema.GroupVersionResource) (GenericInformer, error) + WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool + + Istio() istio.Interface +} + +func (f *sharedInformerFactory) Istio() istio.Interface { + return istio.New(f, f.namespace, f.tweakListOptions) +} diff --git a/components/application-connector/pkg/client/informers/externalversions/generic.go b/components/application-connector/pkg/client/informers/externalversions/generic.go new file mode 100644 index 000000000000..ade25192de44 --- /dev/null +++ b/components/application-connector/pkg/client/informers/externalversions/generic.go @@ -0,0 +1,50 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + "fmt" + + v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/apis/istio/v1alpha2" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +// GenericInformer is type of SharedIndexInformer which will locate and delegate to other +// sharedInformers based on type +type GenericInformer interface { + Informer() cache.SharedIndexInformer + Lister() cache.GenericLister +} + +type genericInformer struct { + informer cache.SharedIndexInformer + resource schema.GroupResource +} + +// Informer returns the SharedIndexInformer. +func (f *genericInformer) Informer() cache.SharedIndexInformer { + return f.informer +} + +// Lister returns the GenericLister. +func (f *genericInformer) Lister() cache.GenericLister { + return cache.NewGenericLister(f.Informer().GetIndexer(), f.resource) +} + +// ForResource gives generic access to a shared informer of the matching type +// TODO extend this to unknown resources with a client pool +func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { + switch resource { + // Group=istio, Version=v1alpha2 + case v1alpha2.SchemeGroupVersion.WithResource("checknothings"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Istio().V1alpha2().Checknothings().Informer()}, nil + case v1alpha2.SchemeGroupVersion.WithResource("deniers"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Istio().V1alpha2().Deniers().Informer()}, nil + case v1alpha2.SchemeGroupVersion.WithResource("rules"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Istio().V1alpha2().Rules().Informer()}, nil + + } + + return nil, fmt.Errorf("no informer found for %v", resource) +} diff --git a/components/application-connector/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go b/components/application-connector/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go new file mode 100644 index 000000000000..b1beb959da16 --- /dev/null +++ b/components/application-connector/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go @@ -0,0 +1,22 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package internalinterfaces + +import ( + time "time" + + versioned "github.com/kyma-project/kyma/components/application-connector/pkg/client/clientset/versioned" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + cache "k8s.io/client-go/tools/cache" +) + +type NewInformerFunc func(versioned.Interface, time.Duration) cache.SharedIndexInformer + +// SharedInformerFactory a small interface to allow for adding an informer without an import cycle +type SharedInformerFactory interface { + Start(stopCh <-chan struct{}) + InformerFor(obj runtime.Object, newFunc NewInformerFunc) cache.SharedIndexInformer +} + +type TweakListOptionsFunc func(*v1.ListOptions) diff --git a/components/application-connector/pkg/client/informers/externalversions/istio/interface.go b/components/application-connector/pkg/client/informers/externalversions/istio/interface.go new file mode 100644 index 000000000000..ab7e4fe44939 --- /dev/null +++ b/components/application-connector/pkg/client/informers/externalversions/istio/interface.go @@ -0,0 +1,30 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package istio + +import ( + internalinterfaces "github.com/kyma-project/kyma/components/application-connector/pkg/client/informers/externalversions/internalinterfaces" + v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/client/informers/externalversions/istio/v1alpha2" +) + +// Interface provides access to each of this group's versions. +type Interface interface { + // V1alpha2 provides access to shared informers for resources in V1alpha2. + V1alpha2() v1alpha2.Interface +} + +type group struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// V1alpha2 returns a new v1alpha2.Interface. +func (g *group) V1alpha2() v1alpha2.Interface { + return v1alpha2.New(g.factory, g.namespace, g.tweakListOptions) +} diff --git a/components/application-connector/pkg/client/informers/externalversions/istio/v1alpha2/checknothing.go b/components/application-connector/pkg/client/informers/externalversions/istio/v1alpha2/checknothing.go new file mode 100644 index 000000000000..186eee29fc08 --- /dev/null +++ b/components/application-connector/pkg/client/informers/externalversions/istio/v1alpha2/checknothing.go @@ -0,0 +1,73 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + time "time" + + istio_v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/apis/istio/v1alpha2" + versioned "github.com/kyma-project/kyma/components/application-connector/pkg/client/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/application-connector/pkg/client/informers/externalversions/internalinterfaces" + v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/client/listers/istio/v1alpha2" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// ChecknothingInformer provides access to a shared informer and lister for +// Checknothings. +type ChecknothingInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha2.ChecknothingLister +} + +type checknothingInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewChecknothingInformer constructs a new informer for Checknothing type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewChecknothingInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredChecknothingInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredChecknothingInformer constructs a new informer for Checknothing type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredChecknothingInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IstioV1alpha2().Checknothings(namespace).List(options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IstioV1alpha2().Checknothings(namespace).Watch(options) + }, + }, + &istio_v1alpha2.Checknothing{}, + resyncPeriod, + indexers, + ) +} + +func (f *checknothingInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredChecknothingInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *checknothingInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&istio_v1alpha2.Checknothing{}, f.defaultInformer) +} + +func (f *checknothingInformer) Lister() v1alpha2.ChecknothingLister { + return v1alpha2.NewChecknothingLister(f.Informer().GetIndexer()) +} diff --git a/components/application-connector/pkg/client/informers/externalversions/istio/v1alpha2/denier.go b/components/application-connector/pkg/client/informers/externalversions/istio/v1alpha2/denier.go new file mode 100644 index 000000000000..e83622947d45 --- /dev/null +++ b/components/application-connector/pkg/client/informers/externalversions/istio/v1alpha2/denier.go @@ -0,0 +1,73 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + time "time" + + istio_v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/apis/istio/v1alpha2" + versioned "github.com/kyma-project/kyma/components/application-connector/pkg/client/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/application-connector/pkg/client/informers/externalversions/internalinterfaces" + v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/client/listers/istio/v1alpha2" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// DenierInformer provides access to a shared informer and lister for +// Deniers. +type DenierInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha2.DenierLister +} + +type denierInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewDenierInformer constructs a new informer for Denier type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewDenierInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredDenierInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredDenierInformer constructs a new informer for Denier type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredDenierInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IstioV1alpha2().Deniers(namespace).List(options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IstioV1alpha2().Deniers(namespace).Watch(options) + }, + }, + &istio_v1alpha2.Denier{}, + resyncPeriod, + indexers, + ) +} + +func (f *denierInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredDenierInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *denierInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&istio_v1alpha2.Denier{}, f.defaultInformer) +} + +func (f *denierInformer) Lister() v1alpha2.DenierLister { + return v1alpha2.NewDenierLister(f.Informer().GetIndexer()) +} diff --git a/components/application-connector/pkg/client/informers/externalversions/istio/v1alpha2/interface.go b/components/application-connector/pkg/client/informers/externalversions/istio/v1alpha2/interface.go new file mode 100644 index 000000000000..82c8c45c9b85 --- /dev/null +++ b/components/application-connector/pkg/client/informers/externalversions/istio/v1alpha2/interface.go @@ -0,0 +1,43 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + internalinterfaces "github.com/kyma-project/kyma/components/application-connector/pkg/client/informers/externalversions/internalinterfaces" +) + +// Interface provides access to all the informers in this group version. +type Interface interface { + // Checknothings returns a ChecknothingInformer. + Checknothings() ChecknothingInformer + // Deniers returns a DenierInformer. + Deniers() DenierInformer + // Rules returns a RuleInformer. + Rules() RuleInformer +} + +type version struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// Checknothings returns a ChecknothingInformer. +func (v *version) Checknothings() ChecknothingInformer { + return &checknothingInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + +// Deniers returns a DenierInformer. +func (v *version) Deniers() DenierInformer { + return &denierInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + +// Rules returns a RuleInformer. +func (v *version) Rules() RuleInformer { + return &ruleInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} diff --git a/components/application-connector/pkg/client/informers/externalversions/istio/v1alpha2/rule.go b/components/application-connector/pkg/client/informers/externalversions/istio/v1alpha2/rule.go new file mode 100644 index 000000000000..e1f7c73a28c7 --- /dev/null +++ b/components/application-connector/pkg/client/informers/externalversions/istio/v1alpha2/rule.go @@ -0,0 +1,73 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + time "time" + + istio_v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/apis/istio/v1alpha2" + versioned "github.com/kyma-project/kyma/components/application-connector/pkg/client/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/application-connector/pkg/client/informers/externalversions/internalinterfaces" + v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/client/listers/istio/v1alpha2" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// RuleInformer provides access to a shared informer and lister for +// Rules. +type RuleInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha2.RuleLister +} + +type ruleInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewRuleInformer constructs a new informer for Rule type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewRuleInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredRuleInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredRuleInformer constructs a new informer for Rule type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredRuleInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IstioV1alpha2().Rules(namespace).List(options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IstioV1alpha2().Rules(namespace).Watch(options) + }, + }, + &istio_v1alpha2.Rule{}, + resyncPeriod, + indexers, + ) +} + +func (f *ruleInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredRuleInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *ruleInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&istio_v1alpha2.Rule{}, f.defaultInformer) +} + +func (f *ruleInformer) Lister() v1alpha2.RuleLister { + return v1alpha2.NewRuleLister(f.Informer().GetIndexer()) +} diff --git a/components/application-connector/pkg/client/listers/istio/v1alpha2/checknothing.go b/components/application-connector/pkg/client/listers/istio/v1alpha2/checknothing.go new file mode 100644 index 000000000000..9efd7f096f39 --- /dev/null +++ b/components/application-connector/pkg/client/listers/istio/v1alpha2/checknothing.go @@ -0,0 +1,78 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/apis/istio/v1alpha2" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// ChecknothingLister helps list Checknothings. +type ChecknothingLister interface { + // List lists all Checknothings in the indexer. + List(selector labels.Selector) (ret []*v1alpha2.Checknothing, err error) + // Checknothings returns an object that can list and get Checknothings. + Checknothings(namespace string) ChecknothingNamespaceLister + ChecknothingListerExpansion +} + +// checknothingLister implements the ChecknothingLister interface. +type checknothingLister struct { + indexer cache.Indexer +} + +// NewChecknothingLister returns a new ChecknothingLister. +func NewChecknothingLister(indexer cache.Indexer) ChecknothingLister { + return &checknothingLister{indexer: indexer} +} + +// List lists all Checknothings in the indexer. +func (s *checknothingLister) List(selector labels.Selector) (ret []*v1alpha2.Checknothing, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha2.Checknothing)) + }) + return ret, err +} + +// Checknothings returns an object that can list and get Checknothings. +func (s *checknothingLister) Checknothings(namespace string) ChecknothingNamespaceLister { + return checknothingNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// ChecknothingNamespaceLister helps list and get Checknothings. +type ChecknothingNamespaceLister interface { + // List lists all Checknothings in the indexer for a given namespace. + List(selector labels.Selector) (ret []*v1alpha2.Checknothing, err error) + // Get retrieves the Checknothing from the indexer for a given namespace and name. + Get(name string) (*v1alpha2.Checknothing, error) + ChecknothingNamespaceListerExpansion +} + +// checknothingNamespaceLister implements the ChecknothingNamespaceLister +// interface. +type checknothingNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all Checknothings in the indexer for a given namespace. +func (s checknothingNamespaceLister) List(selector labels.Selector) (ret []*v1alpha2.Checknothing, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha2.Checknothing)) + }) + return ret, err +} + +// Get retrieves the Checknothing from the indexer for a given namespace and name. +func (s checknothingNamespaceLister) Get(name string) (*v1alpha2.Checknothing, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha2.Resource("checknothing"), name) + } + return obj.(*v1alpha2.Checknothing), nil +} diff --git a/components/application-connector/pkg/client/listers/istio/v1alpha2/denier.go b/components/application-connector/pkg/client/listers/istio/v1alpha2/denier.go new file mode 100644 index 000000000000..9c4dd09bda81 --- /dev/null +++ b/components/application-connector/pkg/client/listers/istio/v1alpha2/denier.go @@ -0,0 +1,78 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/apis/istio/v1alpha2" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// DenierLister helps list Deniers. +type DenierLister interface { + // List lists all Deniers in the indexer. + List(selector labels.Selector) (ret []*v1alpha2.Denier, err error) + // Deniers returns an object that can list and get Deniers. + Deniers(namespace string) DenierNamespaceLister + DenierListerExpansion +} + +// denierLister implements the DenierLister interface. +type denierLister struct { + indexer cache.Indexer +} + +// NewDenierLister returns a new DenierLister. +func NewDenierLister(indexer cache.Indexer) DenierLister { + return &denierLister{indexer: indexer} +} + +// List lists all Deniers in the indexer. +func (s *denierLister) List(selector labels.Selector) (ret []*v1alpha2.Denier, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha2.Denier)) + }) + return ret, err +} + +// Deniers returns an object that can list and get Deniers. +func (s *denierLister) Deniers(namespace string) DenierNamespaceLister { + return denierNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// DenierNamespaceLister helps list and get Deniers. +type DenierNamespaceLister interface { + // List lists all Deniers in the indexer for a given namespace. + List(selector labels.Selector) (ret []*v1alpha2.Denier, err error) + // Get retrieves the Denier from the indexer for a given namespace and name. + Get(name string) (*v1alpha2.Denier, error) + DenierNamespaceListerExpansion +} + +// denierNamespaceLister implements the DenierNamespaceLister +// interface. +type denierNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all Deniers in the indexer for a given namespace. +func (s denierNamespaceLister) List(selector labels.Selector) (ret []*v1alpha2.Denier, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha2.Denier)) + }) + return ret, err +} + +// Get retrieves the Denier from the indexer for a given namespace and name. +func (s denierNamespaceLister) Get(name string) (*v1alpha2.Denier, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha2.Resource("denier"), name) + } + return obj.(*v1alpha2.Denier), nil +} diff --git a/components/application-connector/pkg/client/listers/istio/v1alpha2/expansion_generated.go b/components/application-connector/pkg/client/listers/istio/v1alpha2/expansion_generated.go new file mode 100644 index 000000000000..3ecc607e8b54 --- /dev/null +++ b/components/application-connector/pkg/client/listers/istio/v1alpha2/expansion_generated.go @@ -0,0 +1,27 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha2 + +// ChecknothingListerExpansion allows custom methods to be added to +// ChecknothingLister. +type ChecknothingListerExpansion interface{} + +// ChecknothingNamespaceListerExpansion allows custom methods to be added to +// ChecknothingNamespaceLister. +type ChecknothingNamespaceListerExpansion interface{} + +// DenierListerExpansion allows custom methods to be added to +// DenierLister. +type DenierListerExpansion interface{} + +// DenierNamespaceListerExpansion allows custom methods to be added to +// DenierNamespaceLister. +type DenierNamespaceListerExpansion interface{} + +// RuleListerExpansion allows custom methods to be added to +// RuleLister. +type RuleListerExpansion interface{} + +// RuleNamespaceListerExpansion allows custom methods to be added to +// RuleNamespaceLister. +type RuleNamespaceListerExpansion interface{} diff --git a/components/application-connector/pkg/client/listers/istio/v1alpha2/rule.go b/components/application-connector/pkg/client/listers/istio/v1alpha2/rule.go new file mode 100644 index 000000000000..03fd42831441 --- /dev/null +++ b/components/application-connector/pkg/client/listers/istio/v1alpha2/rule.go @@ -0,0 +1,78 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + v1alpha2 "github.com/kyma-project/kyma/components/application-connector/pkg/apis/istio/v1alpha2" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// RuleLister helps list Rules. +type RuleLister interface { + // List lists all Rules in the indexer. + List(selector labels.Selector) (ret []*v1alpha2.Rule, err error) + // Rules returns an object that can list and get Rules. + Rules(namespace string) RuleNamespaceLister + RuleListerExpansion +} + +// ruleLister implements the RuleLister interface. +type ruleLister struct { + indexer cache.Indexer +} + +// NewRuleLister returns a new RuleLister. +func NewRuleLister(indexer cache.Indexer) RuleLister { + return &ruleLister{indexer: indexer} +} + +// List lists all Rules in the indexer. +func (s *ruleLister) List(selector labels.Selector) (ret []*v1alpha2.Rule, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha2.Rule)) + }) + return ret, err +} + +// Rules returns an object that can list and get Rules. +func (s *ruleLister) Rules(namespace string) RuleNamespaceLister { + return ruleNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// RuleNamespaceLister helps list and get Rules. +type RuleNamespaceLister interface { + // List lists all Rules in the indexer for a given namespace. + List(selector labels.Selector) (ret []*v1alpha2.Rule, err error) + // Get retrieves the Rule from the indexer for a given namespace and name. + Get(name string) (*v1alpha2.Rule, error) + RuleNamespaceListerExpansion +} + +// ruleNamespaceLister implements the RuleNamespaceLister +// interface. +type ruleNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all Rules in the indexer for a given namespace. +func (s ruleNamespaceLister) List(selector labels.Selector) (ret []*v1alpha2.Rule, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha2.Rule)) + }) + return ret, err +} + +// Get retrieves the Rule from the indexer for a given namespace and name. +func (s ruleNamespaceLister) Get(name string) (*v1alpha2.Rule, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha2.Resource("rule"), name) + } + return obj.(*v1alpha2.Rule), nil +} diff --git a/components/application-connector/scripts/can-i-commit.sh b/components/application-connector/scripts/can-i-commit.sh new file mode 100755 index 000000000000..17f255b9395f --- /dev/null +++ b/components/application-connector/scripts/can-i-commit.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +RED='\033[0;31m' +GREEN='\033[0;32m' +INVERTED='\033[7m' +NC='\033[0m' # No Color + +echo -e "${INVERTED}" +echo "USER: " + $USER +echo "PATH: " + $PATH +echo "GOPATH:" + $GOPATH +echo -e "${NC}" + +## +# DEP STATUS +## +echo "? dep status" +depResult=$(dep status -v) +if [ $? != 0 ] + then + echo -e "${RED}✗ dep status\n$depResult${NC}" + exit 1; + else echo -e "${GREEN}√ dep status${NC}" +fi + +## +# MAKE BUILD +## +echo "? make build" +( make build ) +if [ $? != 0 ]; # Check make build passed + then + echo -e "${RED}✗ make build\n${NC}" + exit 1; + else echo -e "${GREEN}√ make build${NC}" +fi + +filesToCheck=$(find . -type f -name "*.go" | egrep -v "\/vendor\/|_*/automock/|_*/testdata/|/pkg\/|_*export_test.go") + +# +# GO FMT +# +goFmtResult=$(echo "${filesToCheck}" | xargs -L1 go fmt) +if [ $(echo ${#goFmtResult}) != 0 ] + then + echo -e "${RED}✗ go fmt${NC}\n$goFmtResult${NC}" + exit 1; + else echo -e "${GREEN}√ go fmt${NC}" +fi + +echo -e "${GREEN}Congrats $(whoami)! You've made it! You can now commit.${NC}" diff --git a/components/application-connector/scripts/delete-all.sh b/components/application-connector/scripts/delete-all.sh new file mode 100755 index 000000000000..b694d7f12aea --- /dev/null +++ b/components/application-connector/scripts/delete-all.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +HOST=51.144.236.138:8081 + +curl http://$HOST/v1/metadata/services | jq ".[].id" -r > IDs.txt + +while read p; do + echo "$p => $(curl -sIX DELETE http://$HOST/v1/metadata/services/$p | head -n 1)" + echo +done { + let body = []; + request.on('data', (chunk) => { + body.push(chunk); + }).on('end', () => { + body = Buffer.concat(body).toString(); + + console.log(`==== ${request.method} ${request.url}`); + console.log('> Headers'); + console.log(request.headers); + + console.log('> Body'); + console.log(body); + response.writeHead(200, {"Content-Type": "application/json"}); + response.end(JSON.stringify({ + "token_type": "Bearer", + "access_token": "122-b012b9bd-0073-4415-0b2b-f06c36cc4031", + "expires_in": 3600, + "scope": "" + })); + }); +}).listen(8084); diff --git a/components/application-connector/scripts/log-request.js b/components/application-connector/scripts/log-request.js new file mode 100644 index 000000000000..9a2d26439f5a --- /dev/null +++ b/components/application-connector/scripts/log-request.js @@ -0,0 +1,19 @@ +const http = require('http'); +const server = http.createServer(); + +server.on('request', (request, response) => { + let body = []; + request.on('data', (chunk) => { + body.push(chunk); + }).on('end', () => { + body = Buffer.concat(body).toString(); + + console.log(`==== ${request.method} ${request.url}`); + console.log('> Headers'); + console.log(request.headers); + + console.log('> Body'); + console.log(body); + response.end(); + }); +}).listen(8083); diff --git a/components/application-connector/test/echo-service/README.md b/components/application-connector/test/echo-service/README.md new file mode 100644 index 000000000000..530d28ccfa13 --- /dev/null +++ b/components/application-connector/test/echo-service/README.md @@ -0,0 +1,145 @@ +# Echo Service + +This is internal tool implemented to test Wormhole connection - a simple HTTP service which echoes REST/HTTP requests back to user. + +It responds with data that was sent in a request and any additional information that user requested through special headers. + +## Building And Running Echo Service + +### Building Echo Service + +To build Echo Service you can use: + +```bash +go build -o echo echo.go logger.go types.go +``` + +### Starting Echo Service Locally + +The command to run Echo Service is: + +```bash +go run echo.go logger.go types.go +``` + +If you've built Echo Service in previous step you can also execute created binary: + +```bash +./echo +``` + +Echo Service can also be ran by using Makefile, to do so following command needs to be executed in root directory of the project: + +```bash +make start-echo-service +``` + +### Building And Publishing Docker Image + +Echo Service Docker image can be built using: +```bash +docker build -t eu.gcr.io/kyma-project/tools/echo-service:0.0.1 -f Dockerfile-echo . +``` +The above command should be executed from root project catalog. + +In order to publish it to repository following command needs to be executed: + +```bash +docker push eu.gcr.io/kyma-project/tools/echo-service:0.0.1 +``` + +### Running Dockerized Echo Service + +First of all, docker image needs to be pulled from repository: + +```bash +docker pull eu.gcr.io/kyma-project/tools/echo-service:0.0.1 +``` + +And then, it can be started: + +```bash +docker run eu.gcr.io/kyma-project/tools/echo-service:0.0.1 +``` + +### Changing Port Number + +Default port for Echo Service is 9000, to change it simply add --port='XXXX' flag. +For example: + +```bash +go run echo.go types.go logger.go --port="8080" +``` + + +## Using Echo Service + +Echo Service accepts special headers to modify it's response: + +### Requesting Response Headers + +Default headers returned in response are: +- Content-Length +- Content-Type +- Date + +For the test purposes, you can request to return the additional headers together with the echo response. + +In order to receive any additional headers in response you can apply header to your request: +``` +echo-header-yourHeaderName : yourHeaderValue +``` + +After echo-service receives header of this structure it will append response headers with: +``` +yourHeaderName : yourHeaderValue +``` + +### Requesting Response Status Code + +By default Echo Service responds with status code 200, for testing you can request exact status code to be returned with the response. + +In order to force Echo Service to respond with given status code you can apply a header to your request: +``` +echo-statuscode : requestedValue +``` + +### Response Structure + +Echo Service always responds with a JSON body of following structure: + +``` +{ + "path":"/path/that/was/requested", + "method":"GET/POST/PUT/...", + "headers:" { + "HeaderName":"HeaderValue", + "AnotherHeader":"AnotherValue", + }, + "body":"bodyThatWasSent" +} +``` + +### Example Request/Response + +Executing given request: + +```bash +curl -X POST -H "Content-Type: application/json" -d '{"testValue1":"xyz","testValue2":"xyz"}' http://localhost:9000/test +``` + +Will result in following response: + +```json +{ + "path":"/test", + "method":"POST", + "headers":{ + "Accept":["*/*"], + "Content-Length":["39"], + "Content-Type":["application/json"], + "User-Agent":["curl/7.54.0"] + }, + "body":"{\"testValue1\":\"xyz\",\"testValue2\":\"xyz\"}" +} +``` diff --git a/components/binding-usage-controller/.gitignore b/components/binding-usage-controller/.gitignore new file mode 100644 index 000000000000..f641c4e2e3e6 --- /dev/null +++ b/components/binding-usage-controller/.gitignore @@ -0,0 +1,104 @@ +### LOCAL +# info.json is always generated by before-commit.sh +/info.json + +/.idea +/.vscode +/scripts + +### macOS template +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/dictionaries + +# Sensitive or high-churn files: +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.xml +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml + +# Gradle: +.idea/**/gradle.xml +.idea/**/libraries + +# CMake +cmake-build-debug/ + +# Mongo Explorer plugin: +.idea/**/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties +### Go template +# Binaries for programs and plugins +*.exe +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 +.glide/ + +*.iml +binding-usage-controller +/vendor diff --git a/components/binding-usage-controller/Gopkg.lock b/components/binding-usage-controller/Gopkg.lock new file mode 100644 index 000000000000..291b91227d4d --- /dev/null +++ b/components/binding-usage-controller/Gopkg.lock @@ -0,0 +1,643 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/PuerkitoBio/purell" + packages = ["."] + revision = "0bcb03f4b4d0a9428594752bd2a3b9aa0a9d4bd4" + version = "v1.1.0" + +[[projects]] + branch = "master" + name = "github.com/PuerkitoBio/urlesc" + packages = ["."] + revision = "de5bf2ad457846296e2031421a34e2568e304e35" + +[[projects]] + name = "github.com/davecgh/go-spew" + packages = ["spew"] + revision = "346938d642f2ec3594ed81d874461961cd0faa76" + version = "v1.1.0" + +[[projects]] + name = "github.com/emicklei/go-restful" + packages = [ + ".", + "log" + ] + revision = "92cd0815dd1a028a6e69faee9757c7436238e252" + version = "v2.6.1" + +[[projects]] + name = "github.com/ghodss/yaml" + packages = ["."] + revision = "0ca9ea5df5451ffdf184b4428c902747c2c11cd7" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "github.com/go-openapi/jsonpointer" + packages = ["."] + revision = "3a0015ad55fa9873f41605d3e8f28cd279c32ab2" + +[[projects]] + branch = "master" + name = "github.com/go-openapi/jsonreference" + packages = ["."] + revision = "3fb327e6747da3043567ee86abd02bb6376b6be2" + +[[projects]] + branch = "master" + name = "github.com/go-openapi/spec" + packages = ["."] + revision = "bcff419492eeeb01f76e77d2ebc714dc97b607f5" + +[[projects]] + branch = "master" + name = "github.com/go-openapi/swag" + packages = ["."] + revision = "811b1089cde9dad18d4d0c2d09fbdbf28dbd27a5" + +[[projects]] + name = "github.com/gogo/protobuf" + packages = [ + "proto", + "sortkeys" + ] + revision = "1adfc126b41513cc696b209667c8656ea7aac67c" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "github.com/golang/glog" + packages = ["."] + revision = "23def4e6c14b4da8ac2ed8007337bc5eb5007998" + +[[projects]] + branch = "master" + name = "github.com/golang/lint" + packages = ["golint"] + revision = "837967239b74207656c3f550e9dc6a944866c490" + +[[projects]] + name = "github.com/golang/protobuf" + packages = [ + "proto", + "ptypes", + "ptypes/any", + "ptypes/duration", + "ptypes/timestamp" + ] + revision = "925541529c1fa6821df4e44ce2723319eb2be768" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "github.com/google/gofuzz" + packages = ["."] + revision = "24818f796faf91cd76ec7bddd72458fbced7a6c1" + +[[projects]] + name = "github.com/googleapis/gnostic" + packages = [ + "OpenAPIv2", + "compiler", + "extensions" + ] + revision = "ee43cbb60db7bd22502942cccbc39059117352ab" + version = "v0.1.0" + +[[projects]] + branch = "master" + name = "github.com/hashicorp/golang-lru" + packages = [ + ".", + "simplelru" + ] + revision = "0fb14efe8c47ae851c0034ed7a448854d3d34cf3" + +[[projects]] + branch = "master" + name = "github.com/howeyc/gopass" + packages = ["."] + revision = "bf9dde6d0d2c004a008c27aaee91170c786f6db8" + +[[projects]] + name = "github.com/imdario/mergo" + packages = ["."] + revision = "163f41321a19dd09362d4c63cc2489db2015f1f4" + version = "0.3.2" + +[[projects]] + name = "github.com/json-iterator/go" + packages = ["."] + revision = "ca39e5af3ece67bbcda3d0f4f56a8e24d9f2dad4" + version = "1.1.3" + +[[projects]] + name = "github.com/kubeless/kubeless" + packages = [ + "pkg/apis/kubeless", + "pkg/apis/kubeless/v1beta1", + "pkg/client/clientset/versioned", + "pkg/client/clientset/versioned/fake", + "pkg/client/clientset/versioned/scheme", + "pkg/client/clientset/versioned/typed/kubeless/v1beta1", + "pkg/client/clientset/versioned/typed/kubeless/v1beta1/fake", + "pkg/client/informers/externalversions", + "pkg/client/informers/externalversions/internalinterfaces", + "pkg/client/informers/externalversions/kubeless", + "pkg/client/informers/externalversions/kubeless/v1beta1", + "pkg/client/listers/kubeless/v1beta1" + ] + revision = "4f4f531f6a1b685bf3842b26cfff5ca7eee533cc" + version = "v0.4.0" + +[[projects]] + name = "github.com/kubernetes-incubator/service-catalog" + packages = [ + "pkg/apis/servicecatalog", + "pkg/apis/servicecatalog/v1beta1", + "pkg/apis/settings", + "pkg/apis/settings/v1alpha1", + "pkg/client/clientset_generated/clientset", + "pkg/client/clientset_generated/clientset/fake", + "pkg/client/clientset_generated/clientset/scheme", + "pkg/client/clientset_generated/clientset/typed/servicecatalog/v1beta1", + "pkg/client/clientset_generated/clientset/typed/servicecatalog/v1beta1/fake", + "pkg/client/clientset_generated/clientset/typed/settings/v1alpha1", + "pkg/client/clientset_generated/clientset/typed/settings/v1alpha1/fake", + "pkg/client/informers_generated/externalversions", + "pkg/client/informers_generated/externalversions/internalinterfaces", + "pkg/client/informers_generated/externalversions/servicecatalog", + "pkg/client/informers_generated/externalversions/servicecatalog/v1beta1", + "pkg/client/informers_generated/externalversions/settings", + "pkg/client/informers_generated/externalversions/settings/v1alpha1", + "pkg/client/listers_generated/servicecatalog/v1beta1", + "pkg/client/listers_generated/settings/v1alpha1", + "pkg/pretty" + ] + revision = "98af5889bb75a9472313e288ed5c06c591a19a19" + version = "v0.1.11" + +[[projects]] + branch = "master" + name = "github.com/mailru/easyjson" + packages = [ + "buffer", + "jlexer", + "jwriter" + ] + revision = "8b799c424f57fa123fc63a99d6383bc6e4c02578" + +[[projects]] + name = "github.com/modern-go/concurrent" + packages = ["."] + revision = "bacd9c7ef1dd9b15be4a9909b8ac7a4e313eec94" + version = "1.0.3" + +[[projects]] + name = "github.com/modern-go/reflect2" + packages = ["."] + revision = "1df9eeb2bb81f327b96228865c5687bc2194af3f" + version = "1.0.0" + +[[projects]] + name = "github.com/pborman/uuid" + packages = ["."] + revision = "e790cca94e6cc75c7064b1332e63811d4aae1a53" + version = "v1.1" + +[[projects]] + name = "github.com/pkg/errors" + packages = ["."] + revision = "645ef00459ed84a119197bfb8d8205042c6df63d" + version = "v0.8.0" + +[[projects]] + name = "github.com/pmezard/go-difflib" + packages = ["difflib"] + revision = "792786c7400a136282c1664665ae0a8db921c6c2" + version = "v1.0.0" + +[[projects]] + name = "github.com/sirupsen/logrus" + packages = ["."] + revision = "c155da19408a8799da419ed3eeb0cb5db0ad5dbc" + version = "v1.0.5" + +[[projects]] + name = "github.com/spf13/pflag" + packages = ["."] + revision = "e57e3eeb33f795204c1ca35f56c44f83227c6e66" + version = "v1.0.0" + +[[projects]] + name = "github.com/stretchr/objx" + packages = ["."] + revision = "facf9a85c22f48d2f52f2380e4efce1768749a89" + version = "v0.1" + +[[projects]] + name = "github.com/stretchr/testify" + packages = [ + "assert", + "mock", + "require" + ] + revision = "12b6f73e6084dad08a7c6e575284b177ecafbc71" + version = "v1.2.1" + +[[projects]] + name = "github.com/vrischmann/envconfig" + packages = ["."] + revision = "98b0b9a570bdd3eb00e3e6eeb15548e7f982bfd3" + version = "1.0.0" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = ["ssh/terminal"] + revision = "c4a91bd4f524f10d064139674cf55852e055ad01" + +[[projects]] + branch = "master" + name = "golang.org/x/lint" + packages = ["."] + revision = "837967239b74207656c3f550e9dc6a944866c490" + +[[projects]] + branch = "master" + name = "golang.org/x/net" + packages = [ + "context", + "http2", + "http2/hpack", + "idna", + "lex/httplex" + ] + revision = "6078986fec03a1dcc236c34816c71b0e05018fda" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = [ + "unix", + "windows" + ] + revision = "932fb2287bee613f2a65e71685c9d9c0020da979" + +[[projects]] + name = "golang.org/x/text" + packages = [ + "collate", + "collate/build", + "internal/colltab", + "internal/gen", + "internal/tag", + "internal/triegen", + "internal/ucd", + "language", + "secure/bidirule", + "transform", + "unicode/bidi", + "unicode/cldr", + "unicode/norm", + "unicode/rangetable", + "width" + ] + revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" + version = "v0.3.0" + +[[projects]] + branch = "master" + name = "golang.org/x/time" + packages = ["rate"] + revision = "fbb02b2291d28baffd63558aa44b4b56f178d650" + +[[projects]] + branch = "master" + name = "golang.org/x/tools" + packages = [ + "cmd/goimports", + "go/ast/astutil", + "go/gcexportdata", + "go/gcimporter15", + "go/types/typeutil", + "imports" + ] + revision = "c41d1439521820892798a1ede0322bc890f8dd4c" + +[[projects]] + name = "gopkg.in/inf.v0" + packages = ["."] + revision = "3887ee99ecf07df5b447e9b00d9c0b2adaa9f3e4" + version = "v0.9.0" + +[[projects]] + name = "gopkg.in/yaml.v2" + packages = ["."] + revision = "7f97868eec74b32b0982dd158a51a446d1da7eb5" + version = "v2.1.1" + +[[projects]] + name = "k8s.io/api" + packages = [ + "admissionregistration/v1alpha1", + "admissionregistration/v1beta1", + "apps/v1", + "apps/v1beta1", + "apps/v1beta2", + "authentication/v1", + "authentication/v1beta1", + "authorization/v1", + "authorization/v1beta1", + "autoscaling/v1", + "autoscaling/v2beta1", + "batch/v1", + "batch/v1beta1", + "batch/v2alpha1", + "certificates/v1beta1", + "core/v1", + "events/v1beta1", + "extensions/v1beta1", + "networking/v1", + "policy/v1beta1", + "rbac/v1", + "rbac/v1alpha1", + "rbac/v1beta1", + "scheduling/v1alpha1", + "settings/v1alpha1", + "storage/v1", + "storage/v1alpha1", + "storage/v1beta1" + ] + revision = "73d903622b7391f3312dcbac6483fed484e185f8" + version = "kubernetes-1.10.0" + +[[projects]] + name = "k8s.io/apimachinery" + packages = [ + "pkg/api/errors", + "pkg/api/meta", + "pkg/api/resource", + "pkg/apis/meta/internalversion", + "pkg/apis/meta/v1", + "pkg/apis/meta/v1/unstructured", + "pkg/apis/meta/v1beta1", + "pkg/conversion", + "pkg/conversion/queryparams", + "pkg/fields", + "pkg/labels", + "pkg/runtime", + "pkg/runtime/schema", + "pkg/runtime/serializer", + "pkg/runtime/serializer/json", + "pkg/runtime/serializer/protobuf", + "pkg/runtime/serializer/recognizer", + "pkg/runtime/serializer/streaming", + "pkg/runtime/serializer/versioning", + "pkg/selection", + "pkg/types", + "pkg/util/cache", + "pkg/util/clock", + "pkg/util/diff", + "pkg/util/errors", + "pkg/util/framer", + "pkg/util/intstr", + "pkg/util/json", + "pkg/util/mergepatch", + "pkg/util/net", + "pkg/util/runtime", + "pkg/util/sets", + "pkg/util/strategicpatch", + "pkg/util/uuid", + "pkg/util/validation", + "pkg/util/validation/field", + "pkg/util/wait", + "pkg/util/yaml", + "pkg/version", + "pkg/watch", + "third_party/forked/golang/json", + "third_party/forked/golang/reflect" + ] + revision = "302974c03f7e50f16561ba237db776ab93594ef6" + version = "kubernetes-1.10.0" + +[[projects]] + name = "k8s.io/client-go" + packages = [ + "discovery", + "discovery/fake", + "informers", + "informers/admissionregistration", + "informers/admissionregistration/v1alpha1", + "informers/admissionregistration/v1beta1", + "informers/apps", + "informers/apps/v1", + "informers/apps/v1beta1", + "informers/apps/v1beta2", + "informers/autoscaling", + "informers/autoscaling/v1", + "informers/autoscaling/v2beta1", + "informers/batch", + "informers/batch/v1", + "informers/batch/v1beta1", + "informers/batch/v2alpha1", + "informers/certificates", + "informers/certificates/v1beta1", + "informers/core", + "informers/core/v1", + "informers/events", + "informers/events/v1beta1", + "informers/extensions", + "informers/extensions/v1beta1", + "informers/internalinterfaces", + "informers/networking", + "informers/networking/v1", + "informers/policy", + "informers/policy/v1beta1", + "informers/rbac", + "informers/rbac/v1", + "informers/rbac/v1alpha1", + "informers/rbac/v1beta1", + "informers/scheduling", + "informers/scheduling/v1alpha1", + "informers/settings", + "informers/settings/v1alpha1", + "informers/storage", + "informers/storage/v1", + "informers/storage/v1alpha1", + "informers/storage/v1beta1", + "kubernetes", + "kubernetes/fake", + "kubernetes/scheme", + "kubernetes/typed/admissionregistration/v1alpha1", + "kubernetes/typed/admissionregistration/v1alpha1/fake", + "kubernetes/typed/admissionregistration/v1beta1", + "kubernetes/typed/admissionregistration/v1beta1/fake", + "kubernetes/typed/apps/v1", + "kubernetes/typed/apps/v1/fake", + "kubernetes/typed/apps/v1beta1", + "kubernetes/typed/apps/v1beta1/fake", + "kubernetes/typed/apps/v1beta2", + "kubernetes/typed/apps/v1beta2/fake", + "kubernetes/typed/authentication/v1", + "kubernetes/typed/authentication/v1/fake", + "kubernetes/typed/authentication/v1beta1", + "kubernetes/typed/authentication/v1beta1/fake", + "kubernetes/typed/authorization/v1", + "kubernetes/typed/authorization/v1/fake", + "kubernetes/typed/authorization/v1beta1", + "kubernetes/typed/authorization/v1beta1/fake", + "kubernetes/typed/autoscaling/v1", + "kubernetes/typed/autoscaling/v1/fake", + "kubernetes/typed/autoscaling/v2beta1", + "kubernetes/typed/autoscaling/v2beta1/fake", + "kubernetes/typed/batch/v1", + "kubernetes/typed/batch/v1/fake", + "kubernetes/typed/batch/v1beta1", + "kubernetes/typed/batch/v1beta1/fake", + "kubernetes/typed/batch/v2alpha1", + "kubernetes/typed/batch/v2alpha1/fake", + "kubernetes/typed/certificates/v1beta1", + "kubernetes/typed/certificates/v1beta1/fake", + "kubernetes/typed/core/v1", + "kubernetes/typed/core/v1/fake", + "kubernetes/typed/events/v1beta1", + "kubernetes/typed/events/v1beta1/fake", + "kubernetes/typed/extensions/v1beta1", + "kubernetes/typed/extensions/v1beta1/fake", + "kubernetes/typed/networking/v1", + "kubernetes/typed/networking/v1/fake", + "kubernetes/typed/policy/v1beta1", + "kubernetes/typed/policy/v1beta1/fake", + "kubernetes/typed/rbac/v1", + "kubernetes/typed/rbac/v1/fake", + "kubernetes/typed/rbac/v1alpha1", + "kubernetes/typed/rbac/v1alpha1/fake", + "kubernetes/typed/rbac/v1beta1", + "kubernetes/typed/rbac/v1beta1/fake", + "kubernetes/typed/scheduling/v1alpha1", + "kubernetes/typed/scheduling/v1alpha1/fake", + "kubernetes/typed/settings/v1alpha1", + "kubernetes/typed/settings/v1alpha1/fake", + "kubernetes/typed/storage/v1", + "kubernetes/typed/storage/v1/fake", + "kubernetes/typed/storage/v1alpha1", + "kubernetes/typed/storage/v1alpha1/fake", + "kubernetes/typed/storage/v1beta1", + "kubernetes/typed/storage/v1beta1/fake", + "listers/admissionregistration/v1alpha1", + "listers/admissionregistration/v1beta1", + "listers/apps/v1", + "listers/apps/v1beta1", + "listers/apps/v1beta2", + "listers/autoscaling/v1", + "listers/autoscaling/v2beta1", + "listers/batch/v1", + "listers/batch/v1beta1", + "listers/batch/v2alpha1", + "listers/certificates/v1beta1", + "listers/core/v1", + "listers/events/v1beta1", + "listers/extensions/v1beta1", + "listers/networking/v1", + "listers/policy/v1beta1", + "listers/rbac/v1", + "listers/rbac/v1alpha1", + "listers/rbac/v1beta1", + "listers/scheduling/v1alpha1", + "listers/settings/v1alpha1", + "listers/storage/v1", + "listers/storage/v1alpha1", + "listers/storage/v1beta1", + "pkg/apis/clientauthentication", + "pkg/apis/clientauthentication/v1alpha1", + "pkg/version", + "plugin/pkg/client/auth/exec", + "rest", + "rest/watch", + "testing", + "tools/auth", + "tools/cache", + "tools/clientcmd", + "tools/clientcmd/api", + "tools/clientcmd/api/latest", + "tools/clientcmd/api/v1", + "tools/metrics", + "tools/pager", + "tools/reference", + "transport", + "util/buffer", + "util/cert", + "util/flowcontrol", + "util/homedir", + "util/integer", + "util/retry", + "util/workqueue" + ] + revision = "989be4278f353e42f26c416c53757d16fcff77db" + version = "kubernetes-1.10.1" + +[[projects]] + branch = "release-1.10" + name = "k8s.io/code-generator" + packages = [ + "cmd/client-gen", + "cmd/client-gen/args", + "cmd/client-gen/generators", + "cmd/client-gen/generators/fake", + "cmd/client-gen/generators/scheme", + "cmd/client-gen/generators/util", + "cmd/client-gen/path", + "cmd/client-gen/types", + "cmd/conversion-gen", + "cmd/conversion-gen/args", + "cmd/conversion-gen/generators", + "cmd/deepcopy-gen", + "cmd/deepcopy-gen/args", + "cmd/defaulter-gen", + "cmd/defaulter-gen/args", + "cmd/informer-gen", + "cmd/informer-gen/args", + "cmd/informer-gen/generators", + "cmd/lister-gen", + "cmd/lister-gen/args", + "cmd/lister-gen/generators", + "cmd/openapi-gen", + "cmd/openapi-gen/args", + "pkg/util" + ] + revision = "cbd9dba38c3d8e0035d4bb554bd321e2c190e629" + +[[projects]] + name = "k8s.io/gengo" + packages = [ + "args", + "examples/deepcopy-gen/generators", + "examples/defaulter-gen/generators", + "examples/set-gen/sets", + "generator", + "namer", + "parser", + "types" + ] + revision = "01a732e01d00cb9a81bb0ca050d3e6d2b947927b" + +[[projects]] + branch = "master" + name = "k8s.io/kube-openapi" + packages = [ + "pkg/common", + "pkg/generators", + "pkg/util/proto" + ] + revision = "50ae88d24ede7b8bad68e23c805b5d3da5c8abaf" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "5da7290e1abcd56b36b56e9ce17b0f998c04bd09aec9ed536c1af341968aa4af" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/components/binding-usage-controller/Gopkg.toml b/components/binding-usage-controller/Gopkg.toml new file mode 100644 index 000000000000..19f6002f5a46 --- /dev/null +++ b/components/binding-usage-controller/Gopkg.toml @@ -0,0 +1,89 @@ +# Gopkg.toml example +# +# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" +# +# [prune] +# non-go = false +# go-tests = true +# unused-packages = true + +required = [ + "github.com/golang/lint/golint", + "golang.org/x/tools/cmd/goimports", + + "k8s.io/code-generator/cmd/defaulter-gen", + "k8s.io/code-generator/cmd/deepcopy-gen", + "k8s.io/code-generator/cmd/conversion-gen", + "k8s.io/code-generator/cmd/client-gen", + "k8s.io/code-generator/cmd/lister-gen", + "k8s.io/code-generator/cmd/informer-gen", + "k8s.io/code-generator/cmd/openapi-gen", + "k8s.io/gengo/args", +] + +[prune] + unused-packages = true + go-tests = true + +[[constraint]] + name = "github.com/Masterminds/semver" + version = "1.4.0" + +[[constraint]] + name = "github.com/kubernetes-incubator/service-catalog" + version = "=v0.1.11" + +[[constraint]] + name = "github.com/kubeless/kubeless" + version = "v0.4.0" + +[[constraint]] + name = "k8s.io/api" + version = "kubernetes-1.10.1" + +[[constraint]] + name = "k8s.io/apimachinery" + version = "kubernetes-1.10.1" + +[[constraint]] + name = "k8s.io/client-go" + version = "kubernetes-1.10.1" + +[[constraint]] + name = "k8s.io/code-generator" + branch = "release-1.10" + +[[constraint]] + name = "k8s.io/gengo" + revision = "01a732e01d00cb9a81bb0ca050d3e6d2b947927b" + +[[override]] + name = "k8s.io/api" + version = "kubernetes-1.10.1" + +[[override]] + name = "k8s.io/apimachinery" + version = "kubernetes-1.10.1" + +[[override]] + name = "k8s.io/client-go" + version = "kubernetes-1.10.1" + + diff --git a/components/binding-usage-controller/Jenkinsfile b/components/binding-usage-controller/Jenkinsfile new file mode 100644 index 000000000000..2eaf3e8aa205 --- /dev/null +++ b/components/binding-usage-controller/Jenkinsfile @@ -0,0 +1,87 @@ +#!/usr/bin/env groovy + +def label = "kyma-${UUID.randomUUID().toString()}" +def application = "binding-usage-controller" + +def isMaster = params.GIT_BRANCH == 'master' + +def dockerPushRoot = isMaster + ? "${env.DOCKER_REGISTRY}" + : "${env.DOCKER_REGISTRY}snapshot/" + +def dockerImageTag = isMaster + ? params.APP_VERSION + : params.GIT_BRANCH + +echo """ +******************************** +Job started with the following parameters: +DOCKER_REGISTRY=${env.DOCKER_REGISTRY} +DOCKER_CREDENTIALS=${env.DOCKER_CREDENTIALS} +GIT_REVISION=${params.GIT_REVISION} +GIT_BRANCH=${params.GIT_BRANCH} +APP_VERSION=${params.APP_VERSION} +APP_FOLDER=${env.APP_FOLDER} +******************************** +""" + +podTemplate(label: label) { + node(label) { + try { + timestamps { + ansiColor('xterm') { + timeout(time: 20, unit: "MINUTES") { + stage("setup") { + checkout scm + + if(dockerImageTag == ""){ + error("No version for docker tag defined, please set APP_VERSION parameter for master branch or GIT_BRANCH parameter for any branch") + } + + withCredentials([usernamePassword(credentialsId: env.DOCKER_CREDENTIALS, passwordVariable: 'pwd', usernameVariable: 'uname')]) { + sh "docker login -u $uname -p '$pwd' $env.DOCKER_REGISTRY" + } + } + + stage("build and test $application") { + execute "./before-commit.sh ci" + } + + if (isMaster) { + stage("IP scan $application (Sourceclear)"){ + withCredentials([string(credentialsId: 'SRCCLR_API_TOKEN', variable: 'SRCCLR_API_TOKEN')]) { + execute("make scan","SRCCLR_API_TOKEN=$SRCCLR_API_TOKEN") + } + } + } + + stage("build image $application") { + dir(env.APP_FOLDER){ + sh "cp $application deploy/controller/$application" + sh "docker build -t $application:latest deploy/controller" + } + } + + stage("push image $application") { + sh "docker tag ${application}:latest ${dockerPushRoot}${application}:${dockerImageTag}" + sh "docker push ${dockerPushRoot}${application}:${dockerImageTag}" + } + } + } + } + } catch (ex) { + echo "Got exception: ${ex}" + currentBuild.result = "FAILURE" + def body = "${currentBuild.currentResult} ${env.JOB_NAME}${env.BUILD_DISPLAY_NAME}: on branch: ${params.GIT_BRANCH}. See details: ${env.BUILD_URL}" + emailext body: body, recipientProviders: [[$class: 'DevelopersRecipientProvider'], [$class: 'CulpritsRecipientProvider'], [$class: 'RequesterRecipientProvider']], subject: "${currentBuild.currentResult}: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'" + } + } +} + +def execute(command, envs = '') { + def buildpack = 'golang-buildpack:0.0.8' + def repositoryName = 'kyma' + def envText = envs=='' ? '' : "--env $envs" + workDir = pwd() + sh "docker run --rm -v $workDir:/go/src/github.com/kyma-project/$repositoryName/ -w /go/src/github.com/kyma-project/$repositoryName/$env.APP_FOLDER $envText ${env.DOCKER_REGISTRY}$buildpack /bin/bash -c '$command'" +} diff --git a/components/binding-usage-controller/README.md b/components/binding-usage-controller/README.md new file mode 100644 index 000000000000..d9360b7d5983 --- /dev/null +++ b/components/binding-usage-controller/README.md @@ -0,0 +1,38 @@ +# Binding Usage Controller + +## Overview + +The Binding Usage Controller injects the **ServiceBindings** into a given application using the **ServiceBindingUsage** resource, which allows this application to bind to a given ServiceInstance. The ServiceBindingUsage is a Kubernetes custom resource which is Namespace-scoped. For the custom resource definition, see the [ServiceBindingUsage CRD file](../../resources/cluster-essentials/templates/service-binding-usage.crd.yaml). For more detailed information on the Binding Usage Controller, see the [docs](./docs) folder in this repository. + +## Prerequisites + +To set up the project, use these tools: +* Version 1.9 or higher of [Go](https://golang.org/dl/) +* The latest version of [Docker](https://www.docker.com/) +* The latest version of [Dep](https://github.com/golang/dep) + +## Usage + +This section explains how to use the Binding Usage Controller. + +### Run a local version +To run the application without building the binary file, run this command: + +```bash +APP_KUBECONFIG_PATH=/Users/{User}/.kube/config APP_LOGGER_LEVEL=debug go run cmd/controller/main.go +``` + +For the description of the available environment variables, see the **Use environment variables** section. + +### Use environment variables +Use the following environment variables to configure the application: + +| Name | Required | Default | Description | +|-----|---------|--------|------------| +| **APP_PORT** | No | `3000` | The port on which the HTTP server listens. | +| **APP_LOGGER_LEVEL** | No | `info` | Show detailed logs in the application. | +| **APP_KUBECONFIG_PATH** | No | | The path to the `kubeconfig` file that you need to run an application outside of the cluster. | + +## Development + +Use the `before-commit.sh` script to test your changes before each commit. diff --git a/components/binding-usage-controller/before-commit.sh b/components/binding-usage-controller/before-commit.sh new file mode 100755 index 000000000000..e99a2425b590 --- /dev/null +++ b/components/binding-usage-controller/before-commit.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash + +readonly CI_FLAG=ci + +RED='\033[0;31m' +GREEN='\033[0;32m' +INVERTED='\033[7m' +NC='\033[0m' # No Color + +echo -e "${INVERTED}" +echo "USER: " + $USER +echo "PATH: " + $PATH +echo "GOPATH:" + $GOPATH +echo -e "${NC}" + +## +# DEP ENSURE +## +dep ensure -v --vendor-only +ensureResult=$? +if [ ${ensureResult} != 0 ]; then + echo -e "${RED}✗ dep ensure -v --vendor-only${NC}\n$ensureResult${NC}" + exit 1 +else echo -e "${GREEN}√ dep ensure -v --vendor-only${NC}" +fi + +## +# GO BUILD +## +buildEnv="" +if [ "$1" == "$CI_FLAG" ]; then + # build binary statically + buildEnv="env CGO_ENABLED=0" +fi + +${buildEnv} go build -o binding-usage-controller ./cmd/controller + +goBuildResult=$? +if [ ${goBuildResult} != 0 ]; then + echo -e "${RED}✗ go build${NC}\n$goBuildResult${NC}" + exit 1 +else echo -e "${GREEN}√ go build${NC}" +fi + +## +# DEP STATUS +## +echo "? dep status" +depResult=$(dep status -v) +if [ $? != 0 ]; then + echo -e "${RED}✗ dep status\n$depResult${NC}" + exit 1 +else echo -e "${GREEN}√ dep status${NC}" +fi + +## +# GO TEST +## +echo "? go test" +go test ./... +# Check if tests passed +if [ $? != 0 ]; then + echo -e "${RED}✗ go test\n${NC}" + exit 1 +else echo -e "${GREEN}√ go test${NC}" +fi + +goFilesToCheck=$(find . -type f -name "*.go" | egrep -v "\/vendor\/|_*/automock/|_*/testdata/|/pkg\/|_*export_test.go") + +## +# GO LINT +## +go build -o golint-vendored ./vendor/github.com/golang/lint/golint +buildLintResult=$? +if [ ${buildLintResult} != 0 ]; then + echo -e "${RED}✗ go build lint${NC}\n$buildLintResult${NC}" + exit 1 +fi + +golintResult=$(echo "${goFilesToCheck}" | xargs -L1 ./golint-vendored) +rm golint-vendored + +if [ $(echo ${#golintResult}) != 0 ]; then + echo -e "${RED}✗ golint\n$golintResult${NC}" + exit 1 +else echo -e "${GREEN}√ golint${NC}" +fi + +## +# GO IMPORTS & FMT +## +go build -o goimports-vendored ./vendor/golang.org/x/tools/cmd/goimports +buildGoImportResult=$? +if [ ${buildGoImportResult} != 0 ]; then + echo -e "${RED}✗ go build goimports${NC}\n$buildGoImportResult${NC}" + exit 1 +fi + +goImportsResult=$(echo "${goFilesToCheck}" | xargs -L1 ./goimports-vendored -w -l) +rm goimports-vendored + +if [ $(echo ${#goImportsResult}) != 0 ]; then + echo -e "${RED}✗ goimports and fmt ${NC}\n$goImportsResult${NC}" + exit 1 +else echo -e "${GREEN}√ goimports and fmt ${NC}" +fi + +## +# GO VET +## +packagesToVet=("./cmd/..." "./internal/..." "./pkg/signal/...") + +for vPackage in "${packagesToVet[@]}"; do + vetResult=$(go vet ${vPackage}) + if [ $(echo ${#vetResult}) != 0 ]; then + echo -e "${RED}✗ go vet ${vPackage} ${NC}\n$vetResult${NC}" + exit 1 + else echo -e "${GREEN}√ go vet ${vPackage} ${NC}" + fi +done \ No newline at end of file diff --git a/components/binding-usage-controller/cmd/controller/main.go b/components/binding-usage-controller/cmd/controller/main.go new file mode 100644 index 000000000000..dd1dbb8da308 --- /dev/null +++ b/components/binding-usage-controller/cmd/controller/main.go @@ -0,0 +1,143 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "time" + + kubelessClientset "github.com/kubeless/kubeless/pkg/client/clientset/versioned" + kubelessInformers "github.com/kubeless/kubeless/pkg/client/informers/externalversions" + serviceCatalogClientset "github.com/kubernetes-incubator/service-catalog/pkg/client/clientset_generated/clientset" + serviceCatalogInformers "github.com/kubernetes-incubator/service-catalog/pkg/client/informers_generated/externalversions" + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller" + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/platform/logger" + bindingUsageClientset "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/clientset/versioned" + bindingUsageInformers "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/informers/externalversions" + "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/signal" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "github.com/vrischmann/envconfig" + "k8s.io/client-go/informers" + k8sClientset "k8s.io/client-go/kubernetes" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +// informerResyncPeriod defines how often informer will execute relist action. Setting to zero disable resync. +// BEWARE: too short period time will increase the CPU load. +const informerResyncPeriod = time.Minute + +// Config holds application configuration +type Config struct { + Logger logger.Config + Port int `envconfig:"default=8080"` + KubeconfigPath string `envconfig:"optional"` + AppliedSBUConfigMapName string `envconfig:"default=applied-sbu-spec"` + AppliedSBUConfigMapNamespace string `envconfig:"default=kyma-system"` +} + +func main() { + var cfg Config + err := envconfig.InitWithPrefix(&cfg, "APP") + fatalOnError(errors.Wrap(err, "while reading configuration from environment variables")) + + log := logger.New(&cfg.Logger) + // set up signals so we can handle the first shutdown signal gracefully + stopCh := signal.SetupChannel() + + k8sConfig, err := newRestClientConfig(cfg.KubeconfigPath) + fatalOnError(err) + + // k8s informers + k8sCli, err := k8sClientset.NewForConfig(k8sConfig) + fatalOnError(err) + k8sInformersFactory := informers.NewSharedInformerFactory(k8sCli, informerResyncPeriod) + + // ServiceBindingUsage informers + bindingUsageCli, err := bindingUsageClientset.NewForConfig(k8sConfig) + fatalOnError(err) + bindingUsageInformerFactory := bindingUsageInformers.NewSharedInformerFactory(bindingUsageCli, informerResyncPeriod) + + // ServiceCatalog informers + serviceCatalogCli, err := serviceCatalogClientset.NewForConfig(k8sConfig) + fatalOnError(err) + serviceCatalogInformerFactory := serviceCatalogInformers.NewSharedInformerFactory(serviceCatalogCli, informerResyncPeriod) + + // Kubeless informers + kubelessCli, err := kubelessClientset.NewForConfig(k8sConfig) + fatalOnError(err) + kubelessInformerFactory := kubelessInformers.NewSharedInformerFactory(kubelessCli, informerResyncPeriod) + + podPresetModifier := controller.NewPodPresetModifier(k8sCli.SettingsV1alpha1()) + + dSupervisor := controller.NewDeploymentSupervisor(k8sInformersFactory.Apps().V1beta2().Deployments(), k8sCli.AppsV1beta2(), log) + fnSupervisor := controller.NewKubelessFunctionSupervisor(kubelessInformerFactory.Kubeless().V1beta1().Functions(), kubelessCli.KubelessV1beta1(), log) + + aggregator := controller.NewResourceSupervisorAggregator() + aggregator.Register(controller.KindDeployment, dSupervisor) + aggregator.Register(controller.KindKubelessFunction, fnSupervisor) + + labelsFetcher := controller.NewBindingLabelsFetcher(serviceCatalogInformerFactory.Servicecatalog().V1beta1().ServiceInstances().Lister(), serviceCatalogInformerFactory.Servicecatalog().V1beta1().ClusterServiceClasses().Lister()) + + cfgMapClient := k8sCli.CoreV1().ConfigMaps(cfg.AppliedSBUConfigMapNamespace) + usageSpecStorage := controller.NewBindingUsageSpecStorage(cfgMapClient, cfg.AppliedSBUConfigMapName) + + ctr := controller.NewServiceBindingUsage( + usageSpecStorage, + bindingUsageCli.ServicecatalogV1alpha1(), + bindingUsageInformerFactory.Servicecatalog().V1alpha1().ServiceBindingUsages(), + serviceCatalogInformerFactory.Servicecatalog().V1beta1().ServiceBindings(), + aggregator, + podPresetModifier, + labelsFetcher, + log, + ) + + // TODO consider to extract here the cache sync logic from controller + // and use WaitForCacheSync() method defined on factories + k8sInformersFactory.Start(stopCh) + kubelessInformerFactory.Start(stopCh) + bindingUsageInformerFactory.Start(stopCh) + serviceCatalogInformerFactory.Start(stopCh) + + go runStatuszHTTPServer(stopCh, fmt.Sprintf(":%d", cfg.Port), log) + + ctr.Run(stopCh) +} + +func runStatuszHTTPServer(stop <-chan struct{}, addr string, log logrus.FieldLogger) { + mux := http.NewServeMux() + mux.HandleFunc("/statusz", func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "OK") + }) + + srv := &http.Server{Addr: addr, Handler: mux} + + go func() { + <-stop + // We received an interrupt signal, shut down. + if err := srv.Shutdown(context.Background()); err != nil { + log.Errorf("HTTP server Shutdown: %v", err) + } + }() + + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + log.Errorf("HTTP server ListenAndServe: %v", err) + } +} + +func fatalOnError(err error) { + if err != nil { + logrus.Fatal(err.Error()) + } +} + +func newRestClientConfig(kubeConfigPath string) (*restclient.Config, error) { + if kubeConfigPath != "" { + return clientcmd.BuildConfigFromFlags("", kubeConfigPath) + } + + return restclient.InClusterConfig() +} diff --git a/components/binding-usage-controller/deploy/controller/Dockerfile b/components/binding-usage-controller/deploy/controller/Dockerfile new file mode 100644 index 000000000000..b43b9602fb60 --- /dev/null +++ b/components/binding-usage-controller/deploy/controller/Dockerfile @@ -0,0 +1,10 @@ +FROM alpine:3.7 + +RUN apk --no-cache add ca-certificates +RUN apk add --no-cache curl + +COPY ./binding-usage-controller /root/binding-usage-controller + +LABEL source=git@github.com:kyma-project/kyma.git + +ENTRYPOINT ["/root/binding-usage-controller"] \ No newline at end of file diff --git a/components/binding-usage-controller/docs/README.md b/components/binding-usage-controller/docs/README.md new file mode 100644 index 000000000000..89c0a6783872 --- /dev/null +++ b/components/binding-usage-controller/docs/README.md @@ -0,0 +1,9 @@ +# Documentation + +## Overview + +This directory contains the following documents that relate to the project: + +* [Architecture](architecture.md) presents the workflow of the Binding Usage Controller. +* [Example](example.md) shows the exemplary ServiceBindingUsage. +* [Status](status.md) describes the ServiceBindngUsage status. diff --git a/components/binding-usage-controller/docs/architecture.md b/components/binding-usage-controller/docs/architecture.md new file mode 100644 index 000000000000..df07dd41e19d --- /dev/null +++ b/components/binding-usage-controller/docs/architecture.md @@ -0,0 +1,42 @@ +# Architecture + +The Binding Usage Controller workflow steps are as follows: + +1. The Binding Usage Controller watches the ServiceBindingUsages in all Namespaces and triggers the logic described in this section. The user decides on the names of the ServiceBindingUsages. + +2. The Binding Usage Controller fetches all labels from the bindingLabels section in the ClusterServiceClass for which the ServiceBinding was created. + +3. The Binding Usage Controller creates a PodPreset which is applied to all Pods with the **use-{serviceBindingUsageUID}** label set to `{serviceBindingUsage resource version}`. The name of the PodPreset is the SHA1-encoded name of the ServiceBindingUsage. The PodPreset injects a Secret with the same name as specified in the **serviceBindingRef.name** field into those Pods. By default, the prefixing of the injected environment variables is disabled. Set the **envPrefix.name** to enable the prefix. **envPrefix.name** is used to prefix the name of the environment variable. + +4. The Binding Usage Controller labels the resource specified in the **usedBy** property. The labels mentioned in step 2 and 3 are used for labelling. + +5. The Binding Usage Controller adds an annotation with data of the applied ServiceBindingUsage. Annotations are used for the tracing and debugging purposes. With annotations, you can trace the labels that have been attached to a given Deployment. The patter of such annotation is as follows: + + ``` + servicebindingusages.servicecatalog.kyma.cx/tracing-information: '{ + "{ServiceBindingUsage Name}": { + "injectedLabels": { + "{label-key}": "{label-value}" + } + } +}' + ``` +See the example: + + ``` +servicebindingusages.servicecatalog.kyma.cx/tracing-information: '{ + "azure-mysqldb-instance-binding-usage": { + "injectedLabels": { + "use-db31ae30-7ecb-11e8-a568-000d3a384f22": "445978" + “access-label”: “ec-default“ + } + }, + "azure-sqldb-instance-1-binding-usage": { + "injectedLabels": { + "use-1f29d2e2-7ecc-11e8-a568-000d3a384f22": "446537" + } + } +} +``` + +![Architecture](assets/architecture.png) diff --git a/components/binding-usage-controller/docs/assets/architecture.html b/components/binding-usage-controller/docs/assets/architecture.html new file mode 100644 index 000000000000..6ae449092698 --- /dev/null +++ b/components/binding-usage-controller/docs/assets/architecture.html @@ -0,0 +1,11 @@ + + + + +Untitled Diagram + + +
+ + + \ No newline at end of file diff --git a/components/binding-usage-controller/docs/assets/architecture.png b/components/binding-usage-controller/docs/assets/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..25d6057d851722ca5b7bc0fc61c294616bee6195 GIT binary patch literal 90598 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
  • 8TBg#Kk?%kka$X4SATtXh*g=RAZ*+H6*JWz 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 literal 0 HcmV?d00001 diff --git a/components/binding-usage-controller/docs/example.md b/components/binding-usage-controller/docs/example.md new file mode 100644 index 000000000000..0009b4496004 --- /dev/null +++ b/components/binding-usage-controller/docs/example.md @@ -0,0 +1,57 @@ +# Example + +In this example, the ServiceBindingUsage injects a Secret associated with the `redis-instance-binding` ServiceBinding to the `redis-client` Deployment in the `production` Namespace. + + +```yaml +apiVersion: servicecatalog.kyma.cx/v1alpha1 +kind: ServiceBindingUsage +metadata: + name: redis-client-binding-usage + namespace: production + # Objects under the spec parameter do not have a Namespace property. It indicates that all of them should be available in the same Namespace as the ServiceBindingUsage. The ServiceBinding works in the same way in the Service Catalog. +spec: + # serviceBindingRef is the reference to the ServiceBinding and it needs to be in the same Namespace where the ServiceBindingUsage is created. + serviceBindingRef: + name: redis-instance-binding + # usedBy is the reference to the application to which the Binding Usage Controller injects environment variables included in the ServiceBinding pointed by the serviceBindingRef. The pointed resource should be available in the same Namespace as the ServiceBindingUsage. The supported kinds in the usedBy section are `Development` and `Function`. + usedBy: + kind: Deployment + name: redis-client + # parameters is a set of the parameters passed to the controller + parameters: + # envPrefix defines prefixing of environment variables injected by the ServiceBindingUsage. This field is not required as prefixing is disabled by default. + envPrefix: + # name is a required field if envPrefix is specified. + name: "pico-bello" +# status contains each action passed by the ServiceBindingUsage. +status: + # conditions represent the observations of the ServiceBindingUsage state. + conditions: + # lastTransitionTime is set when the Binding Usage Controller processes the ServiceBindingUsage for the first time or when the status field changes. + - lastTransitionTime: 2018-06-26T10:52:05Z + # lastUpdateTime is set on each condition update. The condition is updated every time when you process the ServiceBindingUsage. + lastUpdateTime: 2018-06-26T10:52:05Z + # status is a boolean which determines if the ServiceBinding injection is successful. + status: "True" + # type defines if the condition is `ready`. + type: Ready +``` + +**Conditions** can also have their **status** parameter set to `false`, in which case the **message** and **reason** fields appear. See the following example: + +```yaml +- lastTransitionTime: 2018-06-22T17:27:17Z +lastUpdateTime: 2018-06-22T17:27:22Z +# message describes the state of the ServiceBindingUsage. +message: 'while getting ServiceBinding "redis-instance-credential" from namespace + "default": servicebinding.servicecatalog.k8s.io "redis-instance-credential" + not found' +# reason briefly describes the state of the ServiceBindingUsage. +reason: ServiceBindingGetError +status: "False" +type: Ready +``` + +Find the list of all **conditions** and their descriptions in [this](../internal/controller/status/usage.go) file. +For the ready-to-use examples, see the [`examples`](../examples) folder. diff --git a/components/binding-usage-controller/docs/status.md b/components/binding-usage-controller/docs/status.md new file mode 100644 index 000000000000..511261f71fde --- /dev/null +++ b/components/binding-usage-controller/docs/status.md @@ -0,0 +1,10 @@ +# ServiceBindingUsage status + +The ServiceBindingUsage status ensures that the ServiceBindingUsage does not inject a Secret into the application without the user's knowledge. + +The status stores the latest condition which represents the current state of the ServiceBindingUsage. +The ServiceBindingUsage conditions are updated every time when the controller proceeds the ServiceBindingUsage. + +For example, when the required ServiceBinding does not exist and the ServiceBidingUsage cannot inject a Secret into a given application, the ServiceBindingUsage Controller retries for a specified number of times. + +Find the list of all **conditions** and their descriptions in [this](../internal/controller/status/usage.go) file. diff --git a/components/binding-usage-controller/examples/README.md b/components/binding-usage-controller/examples/README.md new file mode 100644 index 000000000000..fa658de75822 --- /dev/null +++ b/components/binding-usage-controller/examples/README.md @@ -0,0 +1,117 @@ +# Testing scenarios + +## Overview + +This document shows possible scenarios for the Service Binding Controller. + +Execute all commands from the examples in the `examples` directory. + +## Usage + +This section shows how to use the bindings both on a Deployment and a Function. +The [Function](#use-the-bindings-on-a-function) scenario uses the prefixing functionality for the injected environment variable. + +### Use the bindings on a Deployment + +To use the bindings on a Deployment, follow these steps: + +1. Export the name of the Namespace. +```bash +export namespace="kyma-examples" +``` +2. Create a Namespace. +```bash +kubectl create ns $namespace +``` +3. Create a Redis instance. +```bash +kubectl create -f servicecatalog/redis-instance.yaml -n $namespace +``` +4. Create Secrets for the Redis instance. +```bash +kubectl create -f servicecatalog/redis-instance-binding.yaml -n $namespace +``` +5. Check if the Redis instance is already provisioned. +```bash +kubectl get serviceinstance/redis -n $namespace -o jsonpath='{ .status.conditions[0].reason }' +``` +6. Create a Redis client. +```bash +kubectl create -f deploy/redis-client.yaml -n $namespace +``` +7. Create a Binding Usage. +```bash +kubectl create -f deploy/service-binding-usage.yaml -n $namespace +``` +8. Wait until the deployment Pod is ready. +```bash +kubectl get po -l app=redis-client -n $namespace -o jsonpath='{ .items[*].status.conditions[?(@.type=="Ready")].status }' +``` +10. Export the name of the Pod. +```bash +export podName=$(kubectl get po -l app=redis-client -n $namespace -o jsonpath='{ .items[*].metadata.name }') +``` +11. Execute the `check-redis` script on the Pod. +```bash +kubectl exec $podName -n $namespace /check-redis.sh +``` +The information and statistics about the Redis server appear. + +>**NOTE:** You can complete steps one to five through the UI. + +To perform a clean-up, remove the Namespace: + +```bash +kubectl delete ns $namespace +``` + +### Use the bindings on a Function + +To use the bindings on a Function, follow these steps: + +1. Export the name of the Namespace. +```bash +export namespace="kyma-examples" +``` +2. Create a Namespace. +```bash +kubectl create ns $namespace +``` +3. Create a Redis instance. +```bash +kubectl create -f servicecatalog/redis-instance.yaml -n $namespace +``` +4. Create Secrets for the Redis instance. +```bash +kubectl create -f servicecatalog/redis-instance-binding.yaml -n $namespace +``` +5. Check if the Redis instance is already provisioned. +```bash +kubectl get serviceinstance/redis -n $namespace -o jsonpath='{ .status.conditions[0].reason }' +``` +6. Create a Lambda. +```bash +kubectl create -f function/redis-client.yaml -n $namespace +``` +7. Create a Binding Usage with **APP_** prefix. +```bash +kubectl create -f function/service-binding-usage.yaml -n $namespace +``` +8. Wait until the Function is ready. +```bash +kubeless function ls redis-client --namespace $namespace +``` +9. Trigger the Function. +```bash +kubeless function call redis-client --namespace $namespace +``` + +The information and statistics about the Redis server appear. + +>**NOTE:** You can complete steps one to five through the UI. + +To perform a clean-up, remove the Namespace: + +```bash +kubectl delete ns $namespace +``` diff --git a/components/binding-usage-controller/examples/deploy/redis-client.yaml b/components/binding-usage-controller/examples/deploy/redis-client.yaml new file mode 100644 index 000000000000..ff20b2c72e6d --- /dev/null +++ b/components/binding-usage-controller/examples/deploy/redis-client.yaml @@ -0,0 +1,17 @@ +apiVersion: apps/v1beta1 +kind: Deployment +metadata: + name: redis-client +spec: + replicas: 1 + template: + metadata: + labels: + app: redis-client + spec: + containers: + - name: redis-client + image: "appregistry/redis:3.2.9-r2" + env: + - name: ALLOW_EMPTY_PASSWORD + value: "yes" \ No newline at end of file diff --git a/components/binding-usage-controller/examples/deploy/service-binding-usage.yaml b/components/binding-usage-controller/examples/deploy/service-binding-usage.yaml new file mode 100644 index 000000000000..5d4d1f266e9d --- /dev/null +++ b/components/binding-usage-controller/examples/deploy/service-binding-usage.yaml @@ -0,0 +1,10 @@ +apiVersion: servicecatalog.kyma.cx/v1alpha1 +kind: ServiceBindingUsage +metadata: + name: deploy-redis-client +spec: + serviceBindingRef: + name: redis-instance-credential + usedBy: + kind: Deployment + name: redis-client diff --git a/components/binding-usage-controller/examples/function/redis-client.yaml b/components/binding-usage-controller/examples/function/redis-client.yaml new file mode 100644 index 000000000000..614d1ceebff5 --- /dev/null +++ b/components/binding-usage-controller/examples/function/redis-client.yaml @@ -0,0 +1,51 @@ +apiVersion: kubeless.io/v1beta1 +kind: Function +metadata: + name: redis-client + labels: + example: service-binding +spec: + handler: app.handler + runtime: nodejs8 + type: HTTP + deps: | + { + "name": "app", + "version": "0.0.1", + "dependencies": { + "async-redis": "1.1.4" + } + } + function: | + const asyncRedis = require("async-redis"); + + module.exports = { + handler: async (event, context) => { + try { + const client = asyncRedis.createClient({ + host: process.env.APP_HOST, + port: process.env.APP_PORT, + password: process.env.APP_REDIS_PASSWORD, + retry_strategy: function (options) { + if (options.error && options.error.code === 'ECONNREFUSED') { + return new Error('The server refused the connection'); + } + if (options.total_retry_time > 1000*60*60) { + return new Error('Retry time exhausted'); + } + if (options.attempt > 10) { + return undefined; + } + // reconnect after + return Math.min(options.attempt * 100, 3000); + }}); + + const value = await client.info(); + + return value; + } catch (err) { + console.log(err); + return "Cannot connect to redis server, please see function logs for more details."; + } + }, + }; diff --git a/components/binding-usage-controller/examples/function/service-binding-usage.yaml b/components/binding-usage-controller/examples/function/service-binding-usage.yaml new file mode 100644 index 000000000000..2d75d8e41386 --- /dev/null +++ b/components/binding-usage-controller/examples/function/service-binding-usage.yaml @@ -0,0 +1,13 @@ +apiVersion: servicecatalog.kyma.cx/v1alpha1 +kind: ServiceBindingUsage +metadata: + name: fn-redis-client +spec: + serviceBindingRef: + name: redis-instance-credential + usedBy: + kind: Function + name: redis-client + parameters: + envPrefix: + name: "APP_" diff --git a/components/binding-usage-controller/examples/servicecatalog/redis-instance-binding.yaml b/components/binding-usage-controller/examples/servicecatalog/redis-instance-binding.yaml new file mode 100644 index 000000000000..1c43c645d45d --- /dev/null +++ b/components/binding-usage-controller/examples/servicecatalog/redis-instance-binding.yaml @@ -0,0 +1,7 @@ +apiVersion: servicecatalog.k8s.io/v1beta1 +kind: ServiceBinding +metadata: + name: redis-instance-credential +spec: + instanceRef: + name: redis diff --git a/components/binding-usage-controller/examples/servicecatalog/redis-instance.yaml b/components/binding-usage-controller/examples/servicecatalog/redis-instance.yaml new file mode 100644 index 000000000000..6032ba9ebf4c --- /dev/null +++ b/components/binding-usage-controller/examples/servicecatalog/redis-instance.yaml @@ -0,0 +1,7 @@ +apiVersion: servicecatalog.k8s.io/v1beta1 +kind: ServiceInstance +metadata: + name: redis +spec: + clusterServiceClassExternalName: redis + clusterServicePlanExternalName: micro diff --git a/components/binding-usage-controller/hack/boilerplate.go.txt b/components/binding-usage-controller/hack/boilerplate.go.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/components/binding-usage-controller/hack/generate-groups.sh b/components/binding-usage-controller/hack/generate-groups.sh new file mode 100755 index 000000000000..bf4890248c5d --- /dev/null +++ b/components/binding-usage-controller/hack/generate-groups.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +# This file was copied from k8s.io/code-generator project +# The only one modification was to specify path in `go install` execution (line 52). + +# Copyright 2017 The Kubernetes Authors. +# +# 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. + +set -o errexit +set -o nounset +set -o pipefail + +# generate-groups generates everything for a project with external types only, e.g. a project based +# on CustomResourceDefinitions. + +if [ "$#" -lt 4 ] || [ "${1}" == "--help" ]; then + cat < ... + + the generators comma separated to run (deepcopy,defaulter,client,lister,informer) or "all". + the output package name (e.g. github.com/example/project/pkg/generated). + the external types dir (e.g. github.com/example/api or github.com/example/project/pkg/apis). + the groups and their versions in the format "groupA:v1,v2 groupB:v1 groupC:v2", relative + to . + ... arbitrary flags passed to all generator binaries. + + +Examples: + $(basename $0) all github.com/example/project/pkg/client github.com/example/project/pkg/apis "foo:v1 bar:v1alpha1,v1beta1" + $(basename $0) deepcopy,client github.com/example/project/pkg/client github.com/example/project/pkg/apis "foo:v1 bar:v1alpha1,v1beta1" +EOF + exit 0 +fi + +GENS="$1" +OUTPUT_PKG="$2" +APIS_PKG="$3" +GROUPS_WITH_VERSIONS="$4" +shift 4 + +go install ./vendor/k8s.io/code-generator/cmd/{defaulter-gen,client-gen,lister-gen,informer-gen,deepcopy-gen} +function codegen::join() { local IFS="$1"; shift; echo "$*"; } + +# enumerate group versions +FQ_APIS=() # e.g. k8s.io/api/apps/v1 +for GVs in ${GROUPS_WITH_VERSIONS}; do + IFS=: read G Vs <<<"${GVs}" + + # enumerate versions + for V in ${Vs//,/ }; do + FQ_APIS+=(${APIS_PKG}/${G}/${V}) + done +done + +if [ "${GENS}" = "all" ] || grep -qw "deepcopy" <<<"${GENS}"; then + echo "Generating deepcopy funcs" + ${GOPATH}/bin/deepcopy-gen --input-dirs $(codegen::join , "${FQ_APIS[@]}") -O zz_generated.deepcopy --bounding-dirs ${APIS_PKG} "$@" +fi + +if [ "${GENS}" = "all" ] || grep -qw "client" <<<"${GENS}"; then + echo "Generating clientset for ${GROUPS_WITH_VERSIONS} at ${OUTPUT_PKG}/clientset" + ${GOPATH}/bin/client-gen --clientset-name versioned --input-base "" --input $(codegen::join , "${FQ_APIS[@]}") --clientset-path ${OUTPUT_PKG}/clientset "$@" +fi + +if [ "${GENS}" = "all" ] || grep -qw "lister" <<<"${GENS}"; then + echo "Generating listers for ${GROUPS_WITH_VERSIONS} at ${OUTPUT_PKG}/listers" + ${GOPATH}/bin/lister-gen --input-dirs $(codegen::join , "${FQ_APIS[@]}") --output-package ${OUTPUT_PKG}/listers "$@" +fi + +if [ "${GENS}" = "all" ] || grep -qw "informer" <<<"${GENS}"; then + echo "Generating informers for ${GROUPS_WITH_VERSIONS} at ${OUTPUT_PKG}/informers" + ${GOPATH}/bin/informer-gen \ + --input-dirs $(codegen::join , "${FQ_APIS[@]}") \ + --versioned-clientset-package ${OUTPUT_PKG}/clientset/versioned \ + --listers-package ${OUTPUT_PKG}/listers \ + --output-package ${OUTPUT_PKG}/informers \ + "$@" +fi diff --git a/components/binding-usage-controller/hack/update-codegen.sh b/components/binding-usage-controller/hack/update-codegen.sh new file mode 100755 index 000000000000..2720a629401b --- /dev/null +++ b/components/binding-usage-controller/hack/update-codegen.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -o errexit +set -o nounset +set -o pipefail + +SCRIPT_ROOT=$(dirname ${BASH_SOURCE})/.. +CODEGEN_PKG=${CODEGEN_PKG:-$(cd ${SCRIPT_ROOT}; ls -d -1 ./vendor/k8s.io/code-generator 2>/dev/null || echo ${GOPATH}/src/k8s.io/code-generator)} +REB_ROOT_PKG="github.com/kyma-project/kyma/components/binding-usage-controller/pkg" + +./hack/generate-groups.sh all \ + ${REB_ROOT_PKG}/client ${REB_ROOT_PKG}/apis \ + servicecatalog:v1alpha1 \ + --go-header-file ${SCRIPT_ROOT}/hack/boilerplate.go.txt \ No newline at end of file diff --git a/components/binding-usage-controller/internal/controller/automock/applied_spec_storage.go b/components/binding-usage-controller/internal/controller/automock/applied_spec_storage.go new file mode 100644 index 000000000000..46fedf4357ba --- /dev/null +++ b/components/binding-usage-controller/internal/controller/automock/applied_spec_storage.go @@ -0,0 +1,69 @@ +// Code generated by mockery v1.0.0 +package automock + +import controller "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller" +import mock "github.com/stretchr/testify/mock" +import v1alpha1 "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + +// AppliedSpecStorage is an autogenerated mock type for the AppliedSpecStorage type +type AppliedSpecStorage struct { + mock.Mock +} + +// Delete provides a mock function with given fields: namespace, name +func (_m *AppliedSpecStorage) Delete(namespace string, name string) error { + ret := _m.Called(namespace, name) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(namespace, name) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Get provides a mock function with given fields: namespace, name +func (_m *AppliedSpecStorage) Get(namespace string, name string) (*controller.UsageSpec, bool, error) { + ret := _m.Called(namespace, name) + + var r0 *controller.UsageSpec + if rf, ok := ret.Get(0).(func(string, string) *controller.UsageSpec); ok { + r0 = rf(namespace, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*controller.UsageSpec) + } + } + + var r1 bool + if rf, ok := ret.Get(1).(func(string, string) bool); ok { + r1 = rf(namespace, name) + } else { + r1 = ret.Get(1).(bool) + } + + var r2 error + if rf, ok := ret.Get(2).(func(string, string) error); ok { + r2 = rf(namespace, name) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// Upsert provides a mock function with given fields: bUsage, applied +func (_m *AppliedSpecStorage) Upsert(bUsage *v1alpha1.ServiceBindingUsage, applied bool) error { + ret := _m.Called(bUsage, applied) + + var r0 error + if rf, ok := ret.Get(0).(func(*v1alpha1.ServiceBindingUsage, bool) error); ok { + r0 = rf(bUsage, applied) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/components/binding-usage-controller/internal/controller/automock/binding_labels_fetcher.go b/components/binding-usage-controller/internal/controller/automock/binding_labels_fetcher.go new file mode 100644 index 000000000000..96078a897871 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/automock/binding_labels_fetcher.go @@ -0,0 +1,33 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import v1beta1 "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + +// bindingLabelsFetcher is an autogenerated mock type for the bindingLabelsFetcher type +type BindingLabelsFetcher struct { + mock.Mock +} + +// Fetch provides a mock function with given fields: svcBinding +func (_m *BindingLabelsFetcher) Fetch(svcBinding *v1beta1.ServiceBinding) (map[string]string, error) { + ret := _m.Called(svcBinding) + + var r0 map[string]string + if rf, ok := ret.Get(0).(func(*v1beta1.ServiceBinding) map[string]string); ok { + r0 = rf(svcBinding) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]string) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*v1beta1.ServiceBinding) error); ok { + r1 = rf(svcBinding) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/binding-usage-controller/internal/controller/automock/binding_usage_checker.go b/components/binding-usage-controller/internal/controller/automock/binding_usage_checker.go new file mode 100644 index 000000000000..8b89630772eb --- /dev/null +++ b/components/binding-usage-controller/internal/controller/automock/binding_usage_checker.go @@ -0,0 +1,24 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import v1alpha1 "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + +// BindingUsageChecker is an autogenerated mock type for the BindingUsageChecker type +type BindingUsageChecker struct { + mock.Mock +} + +// ValidateIfBindingUsageShouldBeProcessed provides a mock function with given fields: sbuFromRetry, bUsage +func (_m *BindingUsageChecker) ValidateIfBindingUsageShouldBeProcessed(sbuFromRetry bool, bUsage *v1alpha1.ServiceBindingUsage) error { + ret := _m.Called(sbuFromRetry, bUsage) + + var r0 error + if rf, ok := ret.Get(0).(func(bool, *v1alpha1.ServiceBindingUsage) error); ok { + r0 = rf(sbuFromRetry, bUsage) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/components/binding-usage-controller/internal/controller/automock/deployment_finder.go b/components/binding-usage-controller/internal/controller/automock/deployment_finder.go new file mode 100644 index 000000000000..dd8f5e85359d --- /dev/null +++ b/components/binding-usage-controller/internal/controller/automock/deployment_finder.go @@ -0,0 +1,33 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import v1beta2 "k8s.io/api/apps/v1beta2" + +// DeploymentFinder is an autogenerated mock type for the DeploymentFinder type +type DeploymentFinder struct { + mock.Mock +} + +// FindDeploymentModifiedByServiceBindingUsage provides a mock function with given fields: namespace, podPresetLabel +func (_m *DeploymentFinder) FindDeploymentModifiedByServiceBindingUsage(namespace string, podPresetLabel string) (*v1beta2.Deployment, error) { + ret := _m.Called(namespace, podPresetLabel) + + var r0 *v1beta2.Deployment + if rf, ok := ret.Get(0).(func(string, string) *v1beta2.Deployment); ok { + r0 = rf(namespace, podPresetLabel) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1beta2.Deployment) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(namespace, podPresetLabel) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/binding-usage-controller/internal/controller/automock/extends.go b/components/binding-usage-controller/internal/controller/automock/extends.go new file mode 100644 index 000000000000..fb46119211ac --- /dev/null +++ b/components/binding-usage-controller/internal/controller/automock/extends.go @@ -0,0 +1,54 @@ +package automock + +import ( + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller" + "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + "github.com/stretchr/testify/mock" + settingsV1alpha1 "k8s.io/api/settings/v1alpha1" +) + +func (_m *KubernetesResourceSupervisor) ExpectOnEnsureLabelsCreated(ns string, resourceName string, usageName string, labels map[string]string) *mock.Call { + return _m.On("EnsureLabelsCreated", ns, resourceName, usageName, labels).Return(nil) +} + +func (_m *KubernetesResourceSupervisor) ExpectOnHasSynced(synced bool) *mock.Call { + return _m.On("HasSynced").Return(synced) +} + +func (_m *KindsSupervisors) ExpectOnGet(k controller.Kind, supervisor controller.KubernetesResourceSupervisor) *mock.Call { + return _m.On("Get", k).Return(supervisor, nil) +} + +func (_m *KindsSupervisors) ExpectOnHasSynced(synced bool) *mock.Call { + return _m.On("HasSynced").Return(synced) +} + +func (_m *PodPresetModifier) ExpectOnUpsertPodPreset(newPodPreset *settingsV1alpha1.PodPreset) *mock.Call { + return _m.On("UpsertPodPreset", newPodPreset).Return(nil) +} + +func (_m *BindingLabelsFetcher) ExpectOnFetch(inBinding *v1beta1.ServiceBinding, outLabels map[string]string) *mock.Call { + return _m.On("Fetch", inBinding).Return(outLabels, nil) +} + +func (_m *BindingLabelsFetcher) ExpectErrorOnFetch(outError error) *mock.Call { + return _m.On("Fetch", mock.Anything).Return(nil, outError) +} + +func (_m *BindingUsageChecker) ExpectOnValidateIfBindingUsageShouldBeProcessed(sbuFromRetry bool, bUsage *v1alpha1.ServiceBindingUsage) *mock.Call { + return _m.On("ValidateIfBindingUsageShouldBeProcessed", sbuFromRetry, bUsage).Return(nil) +} + +func (_m *BindingUsageChecker) ExpectErrorOnValidateIfBindingUsageShouldBeProcessed(sbuFromRetry bool, bUsage *v1alpha1.ServiceBindingUsage, err error) *mock.Call { + return _m.On("ValidateIfBindingUsageShouldBeProcessed", sbuFromRetry, bUsage).Return(err) +} + +func (_m *AppliedSpecStorage) ExpectOnGet(namespace, name string, spec *controller.UsageSpec, found bool) *mock.Call { + return _m.On("Get", namespace, name).Return(spec, found, nil) +} + +func (_m *AppliedSpecStorage) ExpectOnUpsert(bUsage *v1alpha1.ServiceBindingUsage, applied bool) *mock.Call { + return _m.On("Upsert", bUsage, applied).Return(nil) + +} diff --git a/components/binding-usage-controller/internal/controller/automock/kinds_supervisors.go b/components/binding-usage-controller/internal/controller/automock/kinds_supervisors.go new file mode 100644 index 000000000000..c0a406b30c62 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/automock/kinds_supervisors.go @@ -0,0 +1,47 @@ +// Code generated by mockery v1.0.0 +package automock + +import controller "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller" +import mock "github.com/stretchr/testify/mock" + +// KindsSupervisors is an autogenerated mock type for the KindsSupervisors type +type KindsSupervisors struct { + mock.Mock +} + +// Get provides a mock function with given fields: kind +func (_m *KindsSupervisors) Get(kind controller.Kind) (controller.KubernetesResourceSupervisor, error) { + ret := _m.Called(kind) + + var r0 controller.KubernetesResourceSupervisor + if rf, ok := ret.Get(0).(func(controller.Kind) controller.KubernetesResourceSupervisor); ok { + r0 = rf(kind) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(controller.KubernetesResourceSupervisor) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(controller.Kind) error); ok { + r1 = rf(kind) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// HasSynced provides a mock function with given fields: +func (_m *KindsSupervisors) HasSynced() bool { + ret := _m.Called() + + var r0 bool + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} diff --git a/components/binding-usage-controller/internal/controller/automock/kubeless_function_finder.go b/components/binding-usage-controller/internal/controller/automock/kubeless_function_finder.go new file mode 100644 index 000000000000..f010157e784e --- /dev/null +++ b/components/binding-usage-controller/internal/controller/automock/kubeless_function_finder.go @@ -0,0 +1,33 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import v1beta1 "github.com/kubeless/kubeless/pkg/apis/kubeless/v1beta1" + +// KubelessFunctionFinder is an autogenerated mock type for the KubelessFunctionFinder type +type KubelessFunctionFinder struct { + mock.Mock +} + +// FindFunctionModifiedByServiceBindingUsage provides a mock function with given fields: fnNs, usageName +func (_m *KubelessFunctionFinder) FindFunctionModifiedByServiceBindingUsage(fnNs string, usageName string) (*v1beta1.Function, error) { + ret := _m.Called(fnNs, usageName) + + var r0 *v1beta1.Function + if rf, ok := ret.Get(0).(func(string, string) *v1beta1.Function); ok { + r0 = rf(fnNs, usageName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1beta1.Function) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(fnNs, usageName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/binding-usage-controller/internal/controller/automock/kubernetes_resource_supervisor.go b/components/binding-usage-controller/internal/controller/automock/kubernetes_resource_supervisor.go new file mode 100644 index 000000000000..9605e9235c45 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/automock/kubernetes_resource_supervisor.go @@ -0,0 +1,74 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" + +// KubernetesResourceSupervisor is an autogenerated mock type for the KubernetesResourceSupervisor type +type KubernetesResourceSupervisor struct { + mock.Mock +} + +// EnsureLabelsCreated provides a mock function with given fields: resourceNs, resourceName, usageName, usageVer, labels +func (_m *KubernetesResourceSupervisor) EnsureLabelsCreated(resourceNs string, resourceName string, usageName string, labels map[string]string) error { + ret := _m.Called(resourceNs, resourceName, usageName, labels) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string, string, map[string]string) error); ok { + r0 = rf(resourceNs, resourceName, usageName, labels) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// EnsureLabelsDeleted provides a mock function with given fields: resourceNs, resourceName, usageName +func (_m *KubernetesResourceSupervisor) EnsureLabelsDeleted(resourceNs string, resourceName string, usageName string) error { + ret := _m.Called(resourceNs, resourceName, usageName) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string, string) error); ok { + r0 = rf(resourceNs, resourceName, usageName) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetInjectedLabels provides a mock function with given fields: resourceNs, resourceName, usageName +func (_m *KubernetesResourceSupervisor) GetInjectedLabels(resourceNs string, resourceName string, usageName string) (map[string]string, error) { + ret := _m.Called(resourceNs, resourceName, usageName) + + var r0 map[string]string + if rf, ok := ret.Get(0).(func(string, string, string) map[string]string); ok { + r0 = rf(resourceNs, resourceName, usageName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]string) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string, string) error); ok { + r1 = rf(resourceNs, resourceName, usageName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// HasSynced provides a mock function with given fields: +func (_m *KubernetesResourceSupervisor) HasSynced() bool { + ret := _m.Called() + + var r0 bool + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} diff --git a/components/binding-usage-controller/internal/controller/automock/pod_preset_modifier.go b/components/binding-usage-controller/internal/controller/automock/pod_preset_modifier.go new file mode 100644 index 000000000000..3b268a70effe --- /dev/null +++ b/components/binding-usage-controller/internal/controller/automock/pod_preset_modifier.go @@ -0,0 +1,38 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import v1alpha1 "k8s.io/api/settings/v1alpha1" + +// PodPresetModifier is an autogenerated mock type for the PodPresetModifier type +type PodPresetModifier struct { + mock.Mock +} + +// UpsertPodPreset provides a mock function with given fields: newPodPreset +func (_m *PodPresetModifier) UpsertPodPreset(newPodPreset *v1alpha1.PodPreset) error { + ret := _m.Called(newPodPreset) + + var r0 error + if rf, ok := ret.Get(0).(func(*v1alpha1.PodPreset) error); ok { + r0 = rf(newPodPreset) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// EnsurePodPresetDeleted provides a mock function with given fields: name, namespace +func (_m *PodPresetModifier) EnsurePodPresetDeleted(name string, namespace string) error { + ret := _m.Called(name, namespace) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(name, namespace) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/components/binding-usage-controller/internal/controller/automock/usage_binding_annotation_tracer.go b/components/binding-usage-controller/internal/controller/automock/usage_binding_annotation_tracer.go new file mode 100644 index 000000000000..e2bfcb8cd15a --- /dev/null +++ b/components/binding-usage-controller/internal/controller/automock/usage_binding_annotation_tracer.go @@ -0,0 +1,61 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// UsageBindingAnnotationTracer is an autogenerated mock type for the UsageBindingAnnotationTracer type +type UsageBindingAnnotationTracer struct { + mock.Mock +} + +// DeleteAnnotationAboutBindingUsage provides a mock function with given fields: objMeta, usageName +func (_m *UsageBindingAnnotationTracer) DeleteAnnotationAboutBindingUsage(objMeta *v1.ObjectMeta, usageName string) error { + ret := _m.Called(objMeta, usageName) + + var r0 error + if rf, ok := ret.Get(0).(func(*v1.ObjectMeta, string) error); ok { + r0 = rf(objMeta, usageName) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetInjectedLabels provides a mock function with given fields: objMeta, usageName +func (_m *UsageBindingAnnotationTracer) GetInjectedLabels(objMeta v1.ObjectMeta, usageName string) (map[string]string, error) { + ret := _m.Called(objMeta, usageName) + + var r0 map[string]string + if rf, ok := ret.Get(0).(func(v1.ObjectMeta, string) map[string]string); ok { + r0 = rf(objMeta, usageName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]string) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(v1.ObjectMeta, string) error); ok { + r1 = rf(objMeta, usageName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SetAnnotationAboutBindingUsage provides a mock function with given fields: objMeta, usageName, usageVer, labels +func (_m *UsageBindingAnnotationTracer) SetAnnotationAboutBindingUsage(objMeta *v1.ObjectMeta, usageName string, labels map[string]string) error { + ret := _m.Called(objMeta, usageName, labels) + + var r0 error + if rf, ok := ret.Get(0).(func(*v1.ObjectMeta, string, map[string]string) error); ok { + r0 = rf(objMeta, usageName, labels) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/components/binding-usage-controller/internal/controller/controller.go b/components/binding-usage-controller/internal/controller/controller.go new file mode 100644 index 000000000000..4eb205218bd5 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/controller.go @@ -0,0 +1,562 @@ +package controller + +import ( + "crypto/sha1" + "encoding/hex" + "fmt" + "reflect" + "time" + + scTypes "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + scInformer "github.com/kubernetes-incubator/service-catalog/pkg/client/informers_generated/externalversions/servicecatalog/v1beta1" + scLister "github.com/kubernetes-incubator/service-catalog/pkg/client/listers_generated/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller/pretty" + sbuStatus "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller/status" + sbuTypes "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + sbuClient "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1" + sbuInformer "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/informers/externalversions/servicecatalog/v1alpha1" + sbuLister "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/listers/servicecatalog/v1alpha1" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + coreV1 "k8s.io/api/core/v1" + settingsV1alpha1 "k8s.io/api/settings/v1alpha1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" +) + +const ( + // defaultMaxRetries is the number of times a ServiceBindingUsage will be retried before it is dropped out of the queue. + // With the current rate-limiter in use (5ms*2^(defaultMaxRetries-1)) the following numbers represent the times + // a deployment is going to be requeued: + // + // 5ms, 10ms, 20ms, 40ms, 80ms, 160ms, 320ms, 640ms, 1.3s, 2.6s, 5.1s, 10.2s, 20.4s, 41s, 82s + defaultMaxRetries = 15 + + podPresetOwnerAnnotationKey = "servicebindingusages.servicecatalog.kyma.cx/owner-name" +) + +//go:generate mockery -name=podPresetModifier -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=kindsSupervisors -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=deploymentFinder -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=kubelessFunctionFinder -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=bindingLabelsFetcher -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=appliedSpecStorage -output=automock -outpkg=automock -case=underscore + +type ( + podPresetModifier interface { + UpsertPodPreset(newPodPreset *settingsV1alpha1.PodPreset) error + EnsurePodPresetDeleted(name, namespace string) error + } + + kindsSupervisors interface { + Get(kind Kind) (KubernetesResourceSupervisor, error) + HasSynced() bool + } + + bindingLabelsFetcher interface { + Fetch(svcBinding *scTypes.ServiceBinding) (map[string]string, error) + } + + appliedSpecStorage interface { + Get(namespace, name string) (*UsageSpec, bool, error) + Delete(namespace, name string) error + Upsert(bUsage *sbuTypes.ServiceBindingUsage, applied bool) error + } + + prefixGetter interface { + GetPrefix(bUsage *sbuTypes.ServiceBindingUsage) string + } +) + +// ServiceBindingUsageController watches ServiceBindingUsage and injects data to given Deployment/Function +type ServiceBindingUsageController struct { + appliedSpecStorage appliedSpecStorage + bindingUsageClient sbuClient.ServicecatalogV1alpha1Interface + bindingUsageLister sbuLister.ServiceBindingUsageLister + bindingUsageListerSynced cache.InformerSynced + bindingLister scLister.ServiceBindingLister + bindingListerSynced cache.InformerSynced + labelsFetcher bindingLabelsFetcher + kindsSupervisors kindsSupervisors + podPresetModifier podPresetModifier + maxRetires int + log logrus.FieldLogger + queue workqueue.RateLimitingInterface + prefixGetter prefixGetter + + // testHookAsyncOpDone used only in unit tests + testHookAsyncOpDone func() +} + +// NewServiceBindingUsage creates a new ServiceBindingUsageController. +func NewServiceBindingUsage( + appliedSpecStorage appliedSpecStorage, + bindingUsageClient sbuClient.ServicecatalogV1alpha1Interface, + sbuInformer sbuInformer.ServiceBindingUsageInformer, + bindingInformer scInformer.ServiceBindingInformer, + kindSupervisors kindsSupervisors, + podPresetModifier podPresetModifier, + labelsFetcher bindingLabelsFetcher, + log logrus.FieldLogger) *ServiceBindingUsageController { + c := &ServiceBindingUsageController{ + appliedSpecStorage: appliedSpecStorage, + bindingUsageClient: bindingUsageClient, + bindingUsageLister: sbuInformer.Lister(), + bindingUsageListerSynced: sbuInformer.Informer().HasSynced, + bindingLister: bindingInformer.Lister(), + bindingListerSynced: bindingInformer.Informer().HasSynced, + kindsSupervisors: kindSupervisors, + podPresetModifier: podPresetModifier, + labelsFetcher: labelsFetcher, + maxRetires: defaultMaxRetries, + log: log.WithField("service", "controller:service-binding-usage"), + queue: workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "ServiceBindingUsage"), + prefixGetter: &envPrefixGetter{}, + } + + sbuInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: c.onAddServiceBindingUsage, + UpdateFunc: c.onUpdateOrRelistServiceBindingUsage, + DeleteFunc: c.onDeleteServiceBindingUsage, + }) + + return c +} + +func (c *ServiceBindingUsageController) onAddServiceBindingUsage(obj interface{}) { + key, err := cache.MetaNamespaceKeyFunc(obj) + if err != nil { + c.log.Errorf("while handling addition event: couldn't get key: %v", err) + return + } + c.queue.Add(key) +} + +func (c *ServiceBindingUsageController) onDeleteServiceBindingUsage(obj interface{}) { + key, err := cache.DeletionHandlingMetaNamespaceKeyFunc(obj) + if err != nil { + c.log.Errorf("while handling deletion event: couldn't get key: %v", err) + return + } + c.queue.Add(key) +} + +func (c *ServiceBindingUsageController) onUpdateOrRelistServiceBindingUsage(old, cur interface{}) { + oldUsage, ok := old.(*sbuTypes.ServiceBindingUsage) + if !ok { + c.log.Warnf("while handling update: cannot covert obj [%+v] of type %T to *ServiceBindingUsage", cur, cur) + return + } + + curUsage, ok := cur.(*sbuTypes.ServiceBindingUsage) + if !ok { + c.log.Warnf("while handling update: cannot covert obj [%+v] of type %T to *ServiceBindingUsage", cur, cur) + return + } + + if !c.isUpdateNeeded(oldUsage, curUsage) { + return + } + + key, err := cache.MetaNamespaceKeyFunc(cur) + if err != nil { + c.log.Errorf("while handling updating event: couldn't get key: %v", err) + return + } + c.queue.Add(key) +} + +// Run begins watching and syncing. +func (c *ServiceBindingUsageController) Run(stopCh <-chan struct{}) { + go func() { + <-stopCh + c.queue.ShutDown() + }() + + c.log.Infof("Starting service binding usage controller") + defer c.log.Infof("Shutting down service binding usage controller") + + if !cache.WaitForCacheSync(stopCh, c.bindingUsageListerSynced, + c.bindingListerSynced, c.kindsSupervisors.HasSynced) { + c.log.Error("Timeout occurred on waiting for caches to sync. Shutdown the controller.") + return + } + + wait.Until(c.worker, time.Second, stopCh) +} + +func (c *ServiceBindingUsageController) worker() { + for c.processNextWorkItem() { + } +} + +func (c *ServiceBindingUsageController) processNextWorkItem() bool { + if c.testHookAsyncOpDone != nil { + defer c.testHookAsyncOpDone() + } + + key, shutdown := c.queue.Get() + if shutdown { + return false + } + defer c.queue.Done(key) + + err := c.syncServiceBindingUsage(key.(string)) + switch { + case err == nil: + c.queue.Forget(key) + + case c.queue.NumRequeues(key) < c.maxRetires: + c.log.Debugf("Error processing %q (will retry - it's %d of %d): %v", key, c.queue.NumRequeues(key), c.maxRetires, err) + c.queue.AddRateLimited(key) + + default: // err != nil and too many retries + c.log.Errorf("Error processing %q (giving up - to many retires): %v", key, err) + c.queue.Forget(key) + } + + return true +} + +func (c *ServiceBindingUsageController) syncServiceBindingUsage(key string) error { + namespace, name, err := cache.SplitMetaNamespaceKey(key) + if err != nil { + return errors.Wrap(err, "while splitting meta namespace key") + } + + // holds the latest ServiceBindingUsage info from apiserver + bindingUsage, err := c.bindingUsageLister.ServiceBindingUsages(namespace).Get(name) + + switch { + case err == nil: + case apiErrors.IsNotFound(err): + // absence in store means watcher caught the deletion + c.log.Debugf("Starting deletion process of ServiceBindingUsage %q", key) + if err := c.reconcileServiceBindingUsageDelete(namespace, name); err != nil { + // TODO(adding finalizer): add a status update in case of error + // in the same way as we have for `reconcileServiceBindingUsageAdd` + return errors.Wrapf(err, "while deleting ServiceBidingUsage %q", key) + } + c.log.Debugf("ServiceBindingUsage %q was successfully deleted", key) + return nil + default: + return errors.Wrapf(err, "while getting ServiceBindingUsage %q", key) + } + + c.log.Debugf("Starting reconcile ServiceBindingUsage add process of %s", pretty.ServiceBindingUsageName(bindingUsage)) + defer c.log.Debugf("Reconcile ServiceBindingUsage add process of %s completed", key) + + if err := c.reconcileServiceBindingUsageAdd(bindingUsage); err != nil { + condition := sbuStatus.NewUsageCondition(sbuTypes.ServiceBindingUsageReady, sbuTypes.ConditionFalse, err.Reason, err.Message) + if err := c.updateStatus(bindingUsage, *condition); err != nil { + return errors.Wrapf(err, "while updating sbu status with condition %+v", condition) + } + return errors.Wrapf(err, "while processing %s", pretty.ServiceBindingUsageName(bindingUsage)) + } + + condition := sbuStatus.NewUsageCondition(sbuTypes.ServiceBindingUsageReady, sbuTypes.ConditionTrue, "", "") + if err := c.updateStatus(bindingUsage, *condition); err != nil { + return errors.Wrapf(err, "while updating sbu status with condition %+v", condition) + } + + return nil +} + +func (c *ServiceBindingUsageController) reconcileServiceBindingUsageAdd(newUsage *sbuTypes.ServiceBindingUsage) *processBindingUsageError { + var ( + workNS = newUsage.Namespace + newBindingName = newUsage.Spec.ServiceBindingRef.Name + ) + + svcBinding, err := c.bindingLister.ServiceBindings(workNS).Get(newBindingName) + if err != nil { + return newProcessServiceBindingError( + sbuStatus.ServiceBindingGetErrorReason, + errors.Wrapf(err, "while getting ServiceBinding %q from namespace %q", newBindingName, workNS), + ) + } + + if svcBinding.Status.AsyncOpInProgress { + return newProcessServiceBindingError( + sbuStatus.ServiceBindingOngoingAsyncOptReason, + fmt.Errorf("cannot use %s which has ongoing asynchronous operation", pretty.ServiceBindingName(svcBinding)), + ) + } + + if !isServiceBindingReady(svcBinding) { + return newProcessServiceBindingError( + sbuStatus.ServiceBindingNotReadyReason, + fmt.Errorf("cannot use %s which is not in ready state", pretty.ServiceBindingName(svcBinding)), + ) + } + + newPodPreset := c.createPodPresetForBindingUsage(newUsage) + // Upsert - thanks to that we always have proper PodPreset in place + if err := c.podPresetModifier.UpsertPodPreset(newPodPreset); err != nil { + return newProcessServiceBindingError( + sbuStatus.PodPresetUpsertErrorReason, + errors.Wrapf(err, "while upserting the %s", pretty.PodPresetName(newPodPreset)), + ) + } + + bindingLabels, err := c.labelsFetcher.Fetch(svcBinding) + if err != nil { + return newProcessServiceBindingError( + sbuStatus.FetchBindingLabelsErrorReason, + errors.Wrapf(err, "while fetching bindings labels for binding [%s]", pretty.ServiceBindingName(svcBinding)), + ) + } + + labelsToApply, err := Merge(newPodPreset.Spec.Selector.MatchLabels, bindingLabels) + if err != nil { + return newProcessServiceBindingError( + sbuStatus.ApplyLabelsConflictErrorReason, + errors.Wrapf(err, "while merging labels: from PodPreset selector[%v] with binding labels [%v]", newPodPreset.Spec.Selector.MatchLabels, bindingLabels), + ) + } + + if err := c.ensureProperKindIsLabeled(newUsage, labelsToApply); err != nil { + return newProcessServiceBindingError( + sbuStatus.EnsureLabelsAppliedErrorReason, + errors.Wrapf(err, "while ensuring proper labels on kind %s", newUsage.Spec.UsedBy.Kind), + ) + } + + return nil +} + +func (c *ServiceBindingUsageController) ensureProperKindIsLabeled(newUsage *sbuTypes.ServiceBindingUsage, labelsToApply map[string]string) error { + var ( + workNs = newUsage.Namespace + usageName = newUsage.Name + ) + + storedSpec, found, err := c.appliedSpecStorage.Get(workNs, usageName) + if err != nil { + return errors.Wrapf(err, "while getting stored Spec for %s", pretty.ServiceBindingUsageName(newUsage)) + } + + if !found { + if err := c.ensureNewLabels(newUsage, labelsToApply); err != nil { + return errors.Wrap(err, "while applying labels") + } + return nil + } + + appliedLabelsOrigin := labelsOrigin{ + UsedBySpec: storedSpec.UsedBy, + Namespace: workNs, + UsageName: usageName, + } + + usedBySpecEqual := c.isUsedBySpecEqual(storedSpec.UsedBy, newUsage.Spec.UsedBy) + labelsEqual, err := c.isLabelsEqual(appliedLabelsOrigin, labelsToApply) + switch { + case err == nil: + case IsNotFoundError(err) && !usedBySpecEqual: + // Scenario: someone created SBU with not exiting deployment, then modified SBU to point to the new deploy, + // so we are receiving event about update and when we checking if labels are equal then the + // previous deployment still does not exits but `spec` was modified so we need to proceed further + default: + return errors.Wrap(err, "while checking if applied labels are equal with current ones") + } + + if !usedBySpecEqual || !labelsEqual { + err := c.revertLabels(workNs, usageName, storedSpec.UsedBy) + if err != nil && !IsNotFoundError(err) { + return errors.Wrap(err, "while reverting old labels") + } + + if err := c.ensureNewLabels(newUsage, labelsToApply); err != nil { + return errors.Wrap(err, "while applying labels") + } + } + + if usedBySpecEqual && labelsEqual && !storedSpec.Applied { + if err := c.ensureNewLabels(newUsage, labelsToApply); err != nil { + return errors.Wrap(err, "while applying labels") + } + } + + return nil +} + +func (c *ServiceBindingUsageController) revertLabels(usageNamespace, usageName string, storedUsedBySpec sbuTypes.LocalReferenceByKindAndName) error { + previousSupervisor, err := c.kindsSupervisors.Get(Kind(storedUsedBySpec.Kind)) + if err != nil { + return errors.Wrapf(err, "while getting supervisor for kind %q", Kind(storedUsedBySpec.Kind)) + } + + // revert + if err := previousSupervisor.EnsureLabelsDeleted(usageNamespace, storedUsedBySpec.Name, usageName); err != nil { + return errors.Wrapf(err, "while trying to revert changes made on %s %s/%s", storedUsedBySpec.Kind, usageNamespace, storedUsedBySpec.Name) + } + + // changes reverted - delete old spec + if err := c.appliedSpecStorage.Delete(usageNamespace, usageName); err != nil { + return errors.Wrap(err, "while deleting from storage the old Spec") + } + + return nil +} + +func (c *ServiceBindingUsageController) ensureNewLabels(newUsage *sbuTypes.ServiceBindingUsage, labelsToApply map[string]string) error { + currentKindSupervisor, err := c.kindsSupervisors.Get(Kind(newUsage.Spec.UsedBy.Kind)) + if err != nil { + return errors.Wrapf(err, "while getting concrete supervisor for kind %q", Kind(newUsage.Spec.UsedBy.Kind)) + } + + if err := c.appliedSpecStorage.Upsert(newUsage, false); err != nil { + return errors.Wrapf(err, "while saving spec for %s", pretty.ServiceBindingUsageName(newUsage)) + } + + if err := currentKindSupervisor.EnsureLabelsCreated(newUsage.Namespace, newUsage.Spec.UsedBy.Name, newUsage.Name, labelsToApply); err != nil { + return errors.Wrapf(err, "while ensuring labels on %q %q in namespace %q", Kind(newUsage.Spec.UsedBy.Kind), newUsage.Spec.UsedBy.Name, newUsage.Namespace) + } + + if err := c.appliedSpecStorage.Upsert(newUsage, true); err != nil { + return errors.Wrapf(err, "while saving spec for %s", pretty.ServiceBindingUsageName(newUsage)) + } + return nil +} + +func (c *ServiceBindingUsageController) isUsedBySpecEqual(specA, specB sbuTypes.LocalReferenceByKindAndName) bool { + return reflect.DeepEqual(specA, specB) +} + +type labelsOrigin struct { + UsedBySpec sbuTypes.LocalReferenceByKindAndName + Namespace string + UsageName string +} + +func (c *ServiceBindingUsageController) isLabelsEqual(lSource labelsOrigin, labels map[string]string) (bool, error) { + concreteSupervisor, err := c.kindsSupervisors.Get(Kind(lSource.UsedBySpec.Kind)) + if err != nil { + return false, errors.Wrapf(err, "while getting concrete supervisor for kind %q", Kind(lSource.UsedBySpec.Kind)) + } + + appliedLabels, err := concreteSupervisor.GetInjectedLabels(lSource.Namespace, lSource.UsedBySpec.Name, lSource.UsageName) + if err != nil { + return false, errors.Wrap(err, "while getting injected labels") + } + + if len(appliedLabels) != len(labels) { + return false, nil + } + + for key, originValue := range appliedLabels { + if toApplyValue, exists := labels[key]; !exists || originValue != toApplyValue { + return false, nil + } + } + + return true, nil +} + +func (c *ServiceBindingUsageController) updateStatus(bUsage *sbuTypes.ServiceBindingUsage, condition sbuTypes.ServiceBindingUsageCondition) error { + copyUsage := bUsage.DeepCopy() + sbuStatus.SetUsageCondition(©Usage.Status, condition) + _, err := c.bindingUsageClient.ServiceBindingUsages(copyUsage.Namespace).Update(copyUsage) + if err != nil { + return errors.Wrapf(err, "while updating status of %s", pretty.ServiceBindingUsageName(copyUsage)) + } + + return nil +} + +func (c *ServiceBindingUsageController) isUpdateNeeded(specA *sbuTypes.ServiceBindingUsage, specB *sbuTypes.ServiceBindingUsage) bool { + return !reflect.DeepEqual(specA.Spec, specB.Spec) +} + +func (c *ServiceBindingUsageController) createPodPresetForBindingUsage(bUsage *sbuTypes.ServiceBindingUsage) *settingsV1alpha1.PodPreset { + return &settingsV1alpha1.PodPreset{ + ObjectMeta: metaV1.ObjectMeta{ + Namespace: bUsage.Namespace, + Name: c.podPresetNameFromBindingUsageName(bUsage.Name), + Annotations: map[string]string{ + podPresetOwnerAnnotationKey: bUsage.Name, + }, + }, + Spec: settingsV1alpha1.PodPresetSpec{ + Selector: metaV1.LabelSelector{ + MatchLabels: c.podPresetMatchLabels(bUsage), + }, + EnvFrom: []coreV1.EnvFromSource{ + { + Prefix: c.prefixGetter.GetPrefix(bUsage), + SecretRef: &coreV1.SecretEnvSource{ + LocalObjectReference: coreV1.LocalObjectReference{ + Name: bUsage.Spec.ServiceBindingRef.Name, + }, + }, + }, + }, + }, + } +} + +func (c *ServiceBindingUsageController) podPresetMatchLabels(bUsage *sbuTypes.ServiceBindingUsage) map[string]string { + key := fmt.Sprintf("use-%s", bUsage.UID) + + return map[string]string{ + key: bUsage.ResourceVersion, + } +} + +func (c *ServiceBindingUsageController) reconcileServiceBindingUsageDelete(usageNamespace, usageName string) *processBindingUsageError { + if err := c.podPresetModifier.EnsurePodPresetDeleted(usageNamespace, c.podPresetNameFromBindingUsageName(usageName)); err != nil { + return newProcessServiceBindingError( + sbuStatus.PodPresetDeleteErrorReason, + errors.Wrap(err, "while ensuring that PodPreset is deleted"), + ) + } + + storedSpec, found, err := c.appliedSpecStorage.Get(usageNamespace, usageName) + if err != nil { + return newProcessServiceBindingError( + sbuStatus.GetStoredSpecError, + errors.Wrapf(err, "while getting stored Spec for %s/%s", usageNamespace, usageName), + ) + } + + if !found { + return nil + } + + if err := c.revertLabels(usageNamespace, usageName, storedSpec.UsedBy); err != nil { + return newProcessServiceBindingError( + sbuStatus.EnsureLabelsDeletedErrorReason, + errors.Wrap(err, "while reverting old labels"), + ) + } + + return nil +} + +func (c *ServiceBindingUsageController) podPresetNameFromBindingUsageName(bindingUsageName string) string { + h := sha1.New() + h.Write([]byte(bindingUsageName)) + return hex.EncodeToString(h.Sum(nil)) +} + +// isServiceBindingReady returns whether the given service binding has a ready condition +// with status true. +// +// I checked that they always updated this status to false if there are some problems. +// see: https://github.com/kubernetes-incubator/service-catalog/blob/v0.1.3/pkg/controller/controller_binding.go#L178 +// +// What's more they doing same thing for checking if given service instance is ready. +// see: https://github.com/kubernetes-incubator/service-catalog/blob/v0.1.3/pkg/controller/controller.go#L606 +func isServiceBindingReady(instance *scTypes.ServiceBinding) bool { + for _, cond := range instance.Status.Conditions { + if cond.Type == scTypes.ServiceBindingConditionReady { + return cond.Status == scTypes.ConditionTrue + } + } + + return false +} diff --git a/components/binding-usage-controller/internal/controller/controller_export_test.go b/components/binding-usage-controller/internal/controller/controller_export_test.go new file mode 100644 index 000000000000..da5ff4db09e8 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/controller_export_test.go @@ -0,0 +1,11 @@ +package controller + +func (c *ServiceBindingUsageController) WithTestHookOnAsyncOpDone(h func()) *ServiceBindingUsageController { + c.testHookAsyncOpDone = h + return c +} + +func (c *ServiceBindingUsageController) WithoutRetries() *ServiceBindingUsageController { + c.maxRetires = 0 + return c +} diff --git a/components/binding-usage-controller/internal/controller/controller_test.go b/components/binding-usage-controller/internal/controller/controller_test.go new file mode 100644 index 000000000000..e58d6328dd1b --- /dev/null +++ b/components/binding-usage-controller/internal/controller/controller_test.go @@ -0,0 +1,281 @@ +package controller_test + +import ( + "fmt" + "testing" + "time" + + scTypes "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + scFake "github.com/kubernetes-incubator/service-catalog/pkg/client/clientset_generated/clientset/fake" + scInformers "github.com/kubernetes-incubator/service-catalog/pkg/client/informers_generated/externalversions" + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller" + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller/automock" + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller/pretty" + sbuStatus "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller/status" + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/platform/logger/spy" + sbuTypes "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + sbuFake "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/clientset/versioned/fake" + bindingUsageInformers "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/informers/externalversions" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/net/context" + coreV1 "k8s.io/api/core/v1" + settingsV1alpha1 "k8s.io/api/settings/v1alpha1" + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestControllerRunAddSuccess(t *testing.T) { + // given + tc := newCtrlTestCase() + defer tc.AssertExpectation(t) + + fixTimeNow := metaV1.Now() + sbuStatus.TimeNowFn = func() metaV1.Time { + return fixTimeNow + } + + fixSBU := tc.fixDeploymentServiceBindingUsage() + fixSB := tc.fixReadyServiceBinding(fixSBU) + fixPP := tc.fixPodPreset(fixSBU) + + expSBU := fixSBU.DeepCopy() + condition := sbuStatus.NewUsageCondition(sbuTypes.ServiceBindingUsageReady, sbuTypes.ConditionTrue, "", "") + expSBU.Status.Conditions = []sbuTypes.ServiceBindingUsageCondition{*condition} + + usageCli := sbuFake.NewSimpleClientset(fixSBU) + scCli := scFake.NewSimpleClientset(fixSB) + + usageInformersFactory := bindingUsageInformers.NewSharedInformerFactory(usageCli, 0) + scInformerFactory := scInformers.NewSharedInformerFactory(scCli, 0) + + tc.deploySupervisorMock. + ExpectOnEnsureLabelsCreated(fixSBU.Namespace, fixSBU.Spec.UsedBy.Name, fixSBU.Name, map[string]string{ + "use-uid-123": "", + "access-label-123": "true", + }). + Once() + + tc.kindsSupervisorsMock.ExpectOnHasSynced(true). + Once() + tc.kindsSupervisorsMock.ExpectOnGet(controller.KindDeployment, tc.deploySupervisorMock). + Once() + + tc.podPresetModifierMock.ExpectOnUpsertPodPreset(fixPP). + Once() + + tc.labelsFetcherMock.ExpectOnFetch(fixSB, map[string]string{"access-label-123": "true"}). + Once() + + tc.sbuSpecStorageMock. + ExpectOnGet(fixSBU.Namespace, fixSBU.Name, nil, false). + Once() + + tc.sbuSpecStorageMock. + ExpectOnUpsert(fixSBU, false). + Once() + tc.sbuSpecStorageMock. + ExpectOnUpsert(fixSBU, true). + Once() + + asyncOpDone := make(chan struct{}) + hookAsyncOp := func() { + asyncOpDone <- struct{}{} + } + + logErrSink := newLogSinkForErrors() + + ctr := controller.NewServiceBindingUsage( + tc.sbuSpecStorageMock, + usageCli.ServicecatalogV1alpha1(), + usageInformersFactory.Servicecatalog().V1alpha1().ServiceBindingUsages(), + scInformerFactory.Servicecatalog().V1beta1().ServiceBindings(), + tc.kindsSupervisorsMock, + tc.podPresetModifierMock, + tc.labelsFetcherMock, + logErrSink.Logger). + WithTestHookOnAsyncOpDone(hookAsyncOp) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + scInformerFactory.Start(ctx.Done()) + usageInformersFactory.Start(ctx.Done()) + + // when + go ctr.Run(ctx.Done()) + + // then + awaitForChanAtMost(t, asyncOpDone, 5*time.Second) // add event processed + awaitForChanAtMost(t, asyncOpDone, 5*time.Second) // update event processed + + performedActions := filterOutInformerActions(usageCli.Actions()) + require.Len(t, performedActions, 1) + checkAction(t, updateUsageAction(expSBU), performedActions[0]) + + assert.Empty(t, logErrSink.DumpAll()) +} + +func TestControllerRunAddFailOnFetchingLabels(t *testing.T) { + // given + tc := newCtrlTestCase() + defer tc.AssertExpectation(t) + + fixTimeNow := metaV1.Now() + sbuStatus.TimeNowFn = func() metaV1.Time { + return fixTimeNow + } + + fixSBU := tc.fixDeploymentServiceBindingUsage() + fixSB := tc.fixReadyServiceBinding(fixSBU) + fixPP := tc.fixPodPreset(fixSBU) + fixErr := errors.New("cannot fetch labels") + expSBU := fixSBU.DeepCopy() + condition := sbuStatus.NewUsageCondition(sbuTypes.ServiceBindingUsageReady, sbuTypes.ConditionFalse, + sbuStatus.FetchBindingLabelsErrorReason, + errors.Wrapf(fixErr, "while fetching bindings labels for binding [%s]", pretty.ServiceBindingName(fixSB)).Error(), + ) + expSBU.Status.Conditions = []sbuTypes.ServiceBindingUsageCondition{*condition} + + usageCli := sbuFake.NewSimpleClientset(fixSBU) + scCli := scFake.NewSimpleClientset(fixSB) + + usageInformersFactory := bindingUsageInformers.NewSharedInformerFactory(usageCli, 0) + scInformerFactory := scInformers.NewSharedInformerFactory(scCli, 0) + + tc.kindsSupervisorsMock.ExpectOnHasSynced(true) + tc.podPresetModifierMock.ExpectOnUpsertPodPreset(fixPP) + tc.labelsFetcherMock.ExpectErrorOnFetch(fixErr) + + logSink := spy.NewLogSink() + + asyncOpDone := make(chan struct{}) + hookAsyncOp := func() { + asyncOpDone <- struct{}{} + } + + ctr := controller.NewServiceBindingUsage( + tc.sbuSpecStorageMock, + usageCli.ServicecatalogV1alpha1(), + usageInformersFactory.Servicecatalog().V1alpha1().ServiceBindingUsages(), + scInformerFactory.Servicecatalog().V1beta1().ServiceBindings(), + tc.kindsSupervisorsMock, + tc.podPresetModifierMock, tc.labelsFetcherMock, + logSink.Logger). + WithTestHookOnAsyncOpDone(hookAsyncOp). + WithoutRetries() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + scInformerFactory.Start(ctx.Done()) + usageInformersFactory.Start(ctx.Done()) + + // when + go ctr.Run(ctx.Done()) + + // then + awaitForChanAtMost(t, asyncOpDone, 5*time.Second) // add event processed + awaitForChanAtMost(t, asyncOpDone, 5*time.Second) // update event processed + + performedActions := filterOutInformerActions(usageCli.Actions()) + require.Len(t, performedActions, 1) + checkAction(t, updateUsageAction(expSBU), performedActions[0]) + + logSink.AssertLogged(t, logrus.ErrorLevel, fixErr.Error()) +} + +type ctrlTestCase struct { + deploySupervisorMock *automock.KubernetesResourceSupervisor + kindsSupervisorsMock *automock.KindsSupervisors + podPresetModifierMock *automock.PodPresetModifier + labelsFetcherMock *automock.BindingLabelsFetcher + sbuCheckerMock *automock.BindingUsageChecker + sbuSpecStorageMock *automock.AppliedSpecStorage +} + +func newCtrlTestCase() *ctrlTestCase { + return &ctrlTestCase{ + deploySupervisorMock: &automock.KubernetesResourceSupervisor{}, + kindsSupervisorsMock: &automock.KindsSupervisors{}, + podPresetModifierMock: &automock.PodPresetModifier{}, + labelsFetcherMock: &automock.BindingLabelsFetcher{}, + sbuCheckerMock: &automock.BindingUsageChecker{}, + sbuSpecStorageMock: &automock.AppliedSpecStorage{}, + } +} + +func (c *ctrlTestCase) AssertExpectation(t *testing.T) { + c.podPresetModifierMock.AssertExpectations(t) + c.deploySupervisorMock.AssertExpectations(t) + c.kindsSupervisorsMock.AssertExpectations(t) + c.labelsFetcherMock.AssertExpectations(t) + c.sbuCheckerMock.AssertExpectations(t) + c.sbuSpecStorageMock.AssertExpectations(t) +} + +func (c *ctrlTestCase) fixDeploymentServiceBindingUsage() *sbuTypes.ServiceBindingUsage { + return &sbuTypes.ServiceBindingUsage{ + ObjectMeta: metaV1.ObjectMeta{ + Namespace: "production", + Name: "sbu-", + UID: "uid-123", + }, + Spec: sbuTypes.ServiceBindingUsageSpec{ + UsedBy: sbuTypes.LocalReferenceByKindAndName{ + Name: "redis-client", + Kind: "Deployment", + }, + ServiceBindingRef: sbuTypes.LocalReferenceByName{ + Name: "redis-client", + }, + }, + } +} + +func (c *ctrlTestCase) fixReadyServiceBinding(usage *sbuTypes.ServiceBindingUsage) *scTypes.ServiceBinding { + return &scTypes.ServiceBinding{ + ObjectMeta: metaV1.ObjectMeta{ + Name: usage.Spec.ServiceBindingRef.Name, + Namespace: usage.Namespace, + }, + Status: scTypes.ServiceBindingStatus{ + AsyncOpInProgress: false, + Conditions: []scTypes.ServiceBindingCondition{ + { + Type: scTypes.ServiceBindingConditionReady, + Status: scTypes.ConditionTrue, + }, + }, + }, + } +} + +func (c *ctrlTestCase) fixPodPreset(usage *sbuTypes.ServiceBindingUsage) *settingsV1alpha1.PodPreset { + return &settingsV1alpha1.PodPreset{ + ObjectMeta: metaV1.ObjectMeta{ + Name: "9e8947c3a22caf7875e80141e91eaf66e07f1bee", // sha1(binding usage name) + Namespace: usage.Namespace, + Annotations: map[string]string{ + "servicebindingusages.servicecatalog.kyma.cx/owner-name": usage.Name, + }, + }, + Spec: settingsV1alpha1.PodPresetSpec{ + Selector: metaV1.LabelSelector{ + MatchLabels: map[string]string{ + fmt.Sprintf("use-%s", usage.UID): usage.ResourceVersion, + }, + }, + EnvFrom: []coreV1.EnvFromSource{ + { + SecretRef: &coreV1.SecretEnvSource{ + LocalObjectReference: coreV1.LocalObjectReference{ + Name: usage.Spec.ServiceBindingRef.Name, + }, + }, + }, + }, + }, + } +} diff --git a/components/binding-usage-controller/internal/controller/deployment_supervisor.go b/components/binding-usage-controller/internal/controller/deployment_supervisor.go new file mode 100644 index 000000000000..1bc6333d21d0 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/deployment_supervisor.go @@ -0,0 +1,187 @@ +package controller + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller/pretty" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + appsV1beta2 "k8s.io/api/apps/v1beta2" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/strategicpatch" + informerV1beta2 "k8s.io/client-go/informers/apps/v1beta2" + clientAppsV1beta2 "k8s.io/client-go/kubernetes/typed/apps/v1beta2" + listerV1beta2 "k8s.io/client-go/listers/apps/v1beta2" + "k8s.io/client-go/tools/cache" +) + +// DeploymentSupervisor validates if Deployment can be modified by given ServiceBindingUsage. If yes +// it can ensure that labels are present or deleted on Deployment +type DeploymentSupervisor struct { + deploymentLister listerV1beta2.DeploymentLister + deploymenHasSynced cache.InformerSynced + appsClient clientAppsV1beta2.AppsV1beta2Interface + log logrus.FieldLogger + + tracer usageBindingAnnotationTracer +} + +// NewDeploymentSupervisor creates a new DeploymentSupervisor. +func NewDeploymentSupervisor(deploymentInformer informerV1beta2.DeploymentInformer, appsClient clientAppsV1beta2.AppsV1beta2Interface, log logrus.FieldLogger) *DeploymentSupervisor { + return &DeploymentSupervisor{ + deploymentLister: deploymentInformer.Lister(), + deploymenHasSynced: deploymentInformer.Informer().HasSynced, + appsClient: appsClient, + log: log.WithField("service", "controller:deployment-supervisor"), + + tracer: &usageAnnotationTracer{}, + } +} + +// EnsureLabelsCreated ensures that given labels are added to Deployment +func (m *DeploymentSupervisor) EnsureLabelsCreated(deployNS, deployName, usageName string, labels map[string]string) error { + cacheDeploy, err := m.deploymentLister.Deployments(deployNS).Get(deployName) + if err != nil { + return errors.Wrap(err, "while getting Deployment") + } + + oldDeploy := cacheDeploy.DeepCopy() + newDeploy := cacheDeploy.DeepCopy() + + // check labels conflicts + if conflictsKeys, found := detectLabelsConflicts(m.convertToLabeler(newDeploy), labels); found { + err := fmt.Errorf("found conflicts in %s under 'Spec.Template.Labels' field: %s keys already exists [override forbidden]", + pretty.DeploymentName(newDeploy), strings.Join(conflictsKeys, ",")) + return err + } + + // apply new labels + newDeploy.Spec.Template.Labels = EnsureMapIsInitiated(newDeploy.Spec.Template.Labels) + for k, v := range labels { + newDeploy.Spec.Template.Labels[k] = v + } + + if err := m.tracer.SetAnnotationAboutBindingUsage(&newDeploy.ObjectMeta, usageName, labels); err != nil { + return errors.Wrap(err, "while setting annotation tracing data") + } + + if err := m.patchDeployment(oldDeploy, newDeploy); err != nil { + return errors.Wrap(err, "while patching deployment") + } + + return nil +} + +// EnsureLabelsDeleted ensures that given labels are deleted on Deployment +func (m *DeploymentSupervisor) EnsureLabelsDeleted(deployNs, deployName, usageName string) error { + cacheDeploy, err := m.deploymentLister.Deployments(deployNs).Get(deployName) + switch { + case err == nil: + case apiErrors.IsNotFound(err): + return nil + default: + return errors.Wrap(err, "while getting Deployment") + } + + oldDeploy := cacheDeploy.DeepCopy() + newDeploy := cacheDeploy.DeepCopy() + + // remove old labels + err = m.ensureOldLabelsAreRemovedOnNewDeploy(oldDeploy, newDeploy, usageName) + if err != nil { + return errors.Wrap(err, "while trying to delete old labels") + } + + // remove annotations + err = m.tracer.DeleteAnnotationAboutBindingUsage(&newDeploy.ObjectMeta, usageName) + if err != nil { + return errors.Wrap(err, "while deleting annotation tracing data") + } + + if err := m.patchDeployment(oldDeploy, newDeploy); err != nil { + return errors.Wrap(err, "while patching deployment") + } + + return nil +} + +// GetInjectedLabels returns labels applied on given Deployment by usage controller +func (m *DeploymentSupervisor) GetInjectedLabels(deployNS, deployName, usageName string) (map[string]string, error) { + deployments, err := m.deploymentLister.Deployments(deployNS).Get(deployName) + switch { + case err == nil: + case apiErrors.IsNotFound(err): + return nil, NewNotFoundError(err.Error()) + default: + return nil, errors.Wrap(err, "while listing Deployments") + } + + labels, err := m.tracer.GetInjectedLabels(deployments.ObjectMeta, usageName) + if err != nil { + return nil, errors.Wrap(err, "while getting injected labels keys") + } + + return labels, nil +} + +// HasSynced returns true if the shared informer's store has synced +func (m *DeploymentSupervisor) HasSynced() bool { + return m.deploymenHasSynced() +} + +func (m *DeploymentSupervisor) ensureOldLabelsAreRemovedOnNewDeploy(oldDeploy, newDeploy *appsV1beta2.Deployment, usageName string) error { + labels, err := m.tracer.GetInjectedLabels(oldDeploy.ObjectMeta, usageName) + if err != nil { + return errors.Wrap(err, "while getting injected labels") + } + if len(labels) == 0 { + return nil + } + + if newDeploy.Spec.Template.Labels == nil { + m.log.Warningf("missing labels from previous modification") + return nil + } + + for lk := range labels { + delete(newDeploy.Spec.Template.Labels, lk) + } + return nil +} + +func (m *DeploymentSupervisor) patchDeployment(oldDeploy, newDeploy *appsV1beta2.Deployment) error { + oldData, err := json.Marshal(oldDeploy) + if err != nil { + return errors.Wrap(err, "while marshaling old deployment") + } + + newData, err := json.Marshal(newDeploy) + if err != nil { + return errors.Wrap(err, "while marshaling new deployment") + } + + patchBytes, err := strategicpatch.CreateTwoWayMergePatch(oldData, newData, appsV1beta2.Deployment{}) + if err != nil { + return errors.Wrap(err, "while creating marge patch") + } + + _, err = m.appsClient.Deployments(newDeploy.Namespace).Patch(newDeploy.Name, types.StrategicMergePatchType, patchBytes) + if err != nil { + return errors.Wrapf(err, "while patching %s", pretty.DeploymentName(newDeploy)) + } + + return nil +} + +func (m *DeploymentSupervisor) convertToLabeler(deploy *appsV1beta2.Deployment) *labelledDeployment { + return (*labelledDeployment)(deploy) +} + +type labelledDeployment appsV1beta2.Deployment + +func (l *labelledDeployment) Labels() map[string]string { + return l.Spec.Template.Labels +} diff --git a/components/binding-usage-controller/internal/controller/deployment_supervisor_export_test.go b/components/binding-usage-controller/internal/controller/deployment_supervisor_export_test.go new file mode 100644 index 000000000000..e2e87411a291 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/deployment_supervisor_export_test.go @@ -0,0 +1,6 @@ +package controller + +func (m *DeploymentSupervisor) WithUsageAnnotationTracer(tracer usageBindingAnnotationTracer) *DeploymentSupervisor { + m.tracer = tracer + return m +} diff --git a/components/binding-usage-controller/internal/controller/deployment_supervisor_test.go b/components/binding-usage-controller/internal/controller/deployment_supervisor_test.go new file mode 100644 index 000000000000..8b7b282e3c16 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/deployment_supervisor_test.go @@ -0,0 +1,224 @@ +package controller_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller" + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller/automock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsV1beta2 "k8s.io/api/apps/v1beta2" + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes/fake" +) + +func TestDeploymentSupervisorEnsureLabelsCreatedSuccess(t *testing.T) { + // given + fixLabels := map[string]string{ + "label-key": "label-val", + } + fixDeploy := fixDeployment() + expDeploy := fixDeploy.DeepCopy() + expDeploy.Spec.Template.Labels = fixLabels + + usageTracerMock := &automock.UsageBindingAnnotationTracer{} + defer usageTracerMock.AssertExpectations(t) + usageTracerMock.On("SetAnnotationAboutBindingUsage", &fixDeploy.ObjectMeta, fixUsageName, fixLabels). + Return(nil). + Once() + + k8sCli := fake.NewSimpleClientset(fixDeploy) + k8sInformersFactory := informers.NewSharedInformerFactory(k8sCli, time.Second) + + logErrSink := newLogSinkForErrors() + ctrl := controller.NewDeploymentSupervisor( + k8sInformersFactory.Apps().V1beta2().Deployments(), + k8sCli.AppsV1beta2(), + logErrSink.Logger). + WithUsageAnnotationTracer(usageTracerMock) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + k8sInformersFactory.Start(ctx.Done()) + k8sInformersFactory.WaitForCacheSync(ctx.Done()) + + // when + err := ctrl.EnsureLabelsCreated(fixDeploy.Namespace, fixDeploy.Name, fixUsageName, fixLabels) + + // then + assert.NoError(t, err) + + performedActions := filterOutInformerActions(k8sCli.Actions()) + require.Len(t, performedActions, 1) + checkAction(t, patchDeploymentAction(fixDeploy, expDeploy), performedActions[0]) + + assert.Empty(t, logErrSink.DumpAll()) +} + +func TestDeploymentSupervisorEnsureLabelsDeletedSuccess(t *testing.T) { + // given + fixInjectedLabels := map[string]string{"label-key-1": "label-val-1"} + fixDeploy := fixDeployment() + fixDeploy.Spec.Template.Labels = map[string]string{ + "label-key-1": "label-val-1", + "label-key-2": "label-val-2", + } + + expDeploy := fixDeploy.DeepCopy() + expDeploy.Spec.Template.Labels = map[string]string{ + "label-key-2": "label-val-2", + } + + usageTracerMock := &automock.UsageBindingAnnotationTracer{} + defer usageTracerMock.AssertExpectations(t) + usageTracerMock.On("GetInjectedLabels", fixDeploy.ObjectMeta, fixUsageName). + Return(fixInjectedLabels, nil). + Once() + usageTracerMock.On("DeleteAnnotationAboutBindingUsage", &fixDeploy.ObjectMeta, fixUsageName). + Return(nil). + Once() + + k8sCli := fake.NewSimpleClientset(fixDeploy) + k8sInformersFactory := informers.NewSharedInformerFactory(k8sCli, time.Second) + + logErrSink := newLogSinkForErrors() + ctrl := controller.NewDeploymentSupervisor( + k8sInformersFactory.Apps().V1beta2().Deployments(), + k8sCli.AppsV1beta2(), + logErrSink.Logger). + WithUsageAnnotationTracer(usageTracerMock) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + k8sInformersFactory.Start(ctx.Done()) + k8sInformersFactory.WaitForCacheSync(ctx.Done()) + + // when + err := ctrl.EnsureLabelsDeleted(fixDeploy.Namespace, fixDeploy.Name, fixUsageName) + + // then + assert.NoError(t, err) + + performedActions := filterOutInformerActions(k8sCli.Actions()) + require.Len(t, performedActions, 1) + checkAction(t, patchDeploymentAction(fixDeploy, expDeploy), performedActions[0]) + + assert.Empty(t, logErrSink.DumpAll()) +} + +func TestDeploymentSupervisorGetInjectedLabelsKeysSuccess(t *testing.T) { + // given + fixLabels := map[string]string{"label-key": "label-val"} + fixDeploy := fixDeployment() + + usageTracerMock := &automock.UsageBindingAnnotationTracer{} + defer usageTracerMock.AssertExpectations(t) + usageTracerMock.On("GetInjectedLabels", fixDeploy.ObjectMeta, fixUsageName). + Return(fixLabels, nil). + Once() + + k8sCli := fake.NewSimpleClientset(fixDeploy) + k8sInformersFactory := informers.NewSharedInformerFactory(k8sCli, time.Second) + + logErrSink := newLogSinkForErrors() + ctrl := controller.NewDeploymentSupervisor( + k8sInformersFactory.Apps().V1beta2().Deployments(), + k8sCli.AppsV1beta2(), + logErrSink.Logger). + WithUsageAnnotationTracer(usageTracerMock) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + k8sInformersFactory.Start(ctx.Done()) + k8sInformersFactory.WaitForCacheSync(ctx.Done()) + + // when + foundLabelsKeys, err := ctrl.GetInjectedLabels(fixDeploy.Namespace, fixDeploy.Name, fixUsageName) + + // then + require.NoError(t, err) + assert.Equal(t, foundLabelsKeys, fixLabels) + + assert.Empty(t, logErrSink.DumpAll()) +} + +func TestDeploymentSupervisorGetInjectedLabelsFailure(t *testing.T) { + t.Run("Deployment not found", func(t *testing.T) { + // given + k8sCli := fake.NewSimpleClientset() + k8sInformersFactory := informers.NewSharedInformerFactory(k8sCli, time.Second) + + logErrSink := newLogSinkForErrors() + ctrl := controller.NewDeploymentSupervisor( + k8sInformersFactory.Apps().V1beta2().Deployments(), + k8sCli.AppsV1beta2(), + logErrSink.Logger) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + k8sInformersFactory.Start(ctx.Done()) + k8sInformersFactory.WaitForCacheSync(ctx.Done()) + + // when + foundLabelsKeys, err := ctrl.GetInjectedLabels("ns-not-found", "deploy-not-found", fixUsageName) + + // then + assert.True(t, controller.IsNotFoundError(err)) + + assert.Nil(t, foundLabelsKeys) + assert.Empty(t, logErrSink.DumpAll()) + }) + + t.Run("GetInjectedLabels error", func(t *testing.T) { + // given + fixDeploy := fixDeployment() + + usageTracerMock := &automock.UsageBindingAnnotationTracer{} + defer usageTracerMock.AssertExpectations(t) + usageTracerMock.On("GetInjectedLabels", fixDeploy.ObjectMeta, fixUsageName). + Return(map[string]string{}, errors.New("fix ERR")). + Once() + + k8sCli := fake.NewSimpleClientset(fixDeploy) + k8sInformersFactory := informers.NewSharedInformerFactory(k8sCli, time.Second) + + logErrSink := newLogSinkForErrors() + ctrl := controller.NewDeploymentSupervisor( + k8sInformersFactory.Apps().V1beta2().Deployments(), + k8sCli.AppsV1beta2(), + logErrSink.Logger). + WithUsageAnnotationTracer(usageTracerMock) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + k8sInformersFactory.Start(ctx.Done()) + k8sInformersFactory.WaitForCacheSync(ctx.Done()) + + // when + foundLabelsKeys, err := ctrl.GetInjectedLabels(fixDeploy.Namespace, fixDeploy.Name, fixUsageName) + + // then + require.EqualError(t, err, "while getting injected labels keys: fix ERR") + assert.Nil(t, foundLabelsKeys) + + assert.Empty(t, logErrSink.DumpAll()) + }) +} + +func fixDeployment() *appsV1beta2.Deployment { + return &appsV1beta2.Deployment{ + ObjectMeta: metaV1.ObjectMeta{ + Namespace: "production", + Name: "pico-bello-deploy", + }, + } +} diff --git a/components/binding-usage-controller/internal/controller/env_prefix.go b/components/binding-usage-controller/internal/controller/env_prefix.go new file mode 100644 index 000000000000..a4e91824884a --- /dev/null +++ b/components/binding-usage-controller/internal/controller/env_prefix.go @@ -0,0 +1,15 @@ +package controller + +import ( + sbuTypes "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" +) + +// envPrefixGetter get prefix from `ServiceBindingUsage` +type envPrefixGetter struct{} + +func (e *envPrefixGetter) GetPrefix(bUsage *sbuTypes.ServiceBindingUsage) string { + if bUsage.Spec.Parameters != nil && bUsage.Spec.Parameters.EnvPrefix != nil { + return bUsage.Spec.Parameters.EnvPrefix.Name + } + return "" +} diff --git a/components/binding-usage-controller/internal/controller/env_prefix_test.go b/components/binding-usage-controller/internal/controller/env_prefix_test.go new file mode 100644 index 000000000000..befb7a5bcc6a --- /dev/null +++ b/components/binding-usage-controller/internal/controller/env_prefix_test.go @@ -0,0 +1,76 @@ +package controller + +import ( + "testing" + + sbuTypes "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + "github.com/stretchr/testify/assert" + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestEnvPrefixGetterGetPrefix(t *testing.T) { + tests := map[string]struct { + givenSBU sbuTypes.ServiceBindingUsage + expectedPrefix string + }{ + "Parameters on ServiceBindingUsage not provided": { + givenSBU: sbuTypes.ServiceBindingUsage{ + ObjectMeta: metaV1.ObjectMeta{ + Name: "fix-sbu-name", + }, + Spec: sbuTypes.ServiceBindingUsageSpec{}, + }, + expectedPrefix: "", + }, + "Empty Parameters on ServiceBindingUsage": { + givenSBU: sbuTypes.ServiceBindingUsage{ + ObjectMeta: metaV1.ObjectMeta{ + Name: "fix-sbu-name", + }, + Spec: sbuTypes.ServiceBindingUsageSpec{ + Parameters: &sbuTypes.Parameters{}, + }, + }, + expectedPrefix: "", + }, + "Empty EnvPrefix passed in Parameters on ServiceBindingUsage": { + givenSBU: sbuTypes.ServiceBindingUsage{ + ObjectMeta: metaV1.ObjectMeta{ + Name: "aol-1", + }, + Spec: sbuTypes.ServiceBindingUsageSpec{ + Parameters: &sbuTypes.Parameters{ + EnvPrefix: &sbuTypes.EnvPrefix{}, + }, + }}, + expectedPrefix: "", + }, + "Not empty EnvPrefix passed in Parameters on ServiceBindingUsage": { + givenSBU: sbuTypes.ServiceBindingUsage{ + ObjectMeta: metaV1.ObjectMeta{ + Name: "aol-1", + }, + Spec: sbuTypes.ServiceBindingUsageSpec{ + Parameters: &sbuTypes.Parameters{ + EnvPrefix: &sbuTypes.EnvPrefix{ + Name: "TEST_PREFIX_", + }, + }, + }}, + expectedPrefix: "TEST_PREFIX_", + }, + } + + for tn, tc := range tests { + t.Run(tn, func(t *testing.T) { + // given + prefixGetter := envPrefixGetter{} + + // when + prefix := prefixGetter.GetPrefix(&tc.givenSBU) + + // then + assert.Equal(t, tc.expectedPrefix, prefix) + }) + } +} diff --git a/components/binding-usage-controller/internal/controller/errors.go b/components/binding-usage-controller/internal/controller/errors.go new file mode 100644 index 000000000000..857cdbbbdcb2 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/errors.go @@ -0,0 +1,55 @@ +package controller + +import ( + "fmt" + + "github.com/pkg/errors" +) + +// NotFoundError indicates situation when given resource was not found +type NotFoundError struct { + msg string +} + +func (e NotFoundError) Error() string { return fmt.Sprintf("not found error: %s", e.msg) } + +// NotFound returns information if such error should be treated as not found +func (NotFoundError) NotFound() bool { return true } + +// NewNotFoundError returns a new not found error with given message +func NewNotFoundError(format string, args ...interface{}) NotFoundError { + return NotFoundError{msg: fmt.Sprintf(format, args)} +} + +// IsNotFoundError checks if given error is NotFound error +func IsNotFoundError(err error) bool { + err = errors.Cause(err) + nfe, ok := err.(interface { + NotFound() bool + }) + return ok && nfe.NotFound() +} + +// ConflictError indicates situation when conflict occurs +type ConflictError struct { + ConflictingResource string +} + +func (e *ConflictError) Error() string { + return fmt.Sprintf("Conflict Error for [%s]", e.ConflictingResource) +} + +type processBindingUsageError struct { + Reason, Message string +} + +func (s *processBindingUsageError) Error() string { + return fmt.Sprintf("Reason: %s, details: %s", s.Reason, s.Message) +} + +func newProcessServiceBindingError(reason string, err error) *processBindingUsageError { + return &processBindingUsageError{ + Reason: reason, + Message: err.Error(), + } +} diff --git a/components/binding-usage-controller/internal/controller/helpers_test.go b/components/binding-usage-controller/internal/controller/helpers_test.go new file mode 100644 index 000000000000..86da78c0f5c7 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/helpers_test.go @@ -0,0 +1,165 @@ +package controller_test + +import ( + "encoding/json" + "errors" + "fmt" + "testing" + "time" + + kubelessTypes "github.com/kubeless/kubeless/pkg/apis/kubeless/v1beta1" + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/platform/logger/spy" + sbuTypes "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + appsV1beta2 "k8s.io/api/apps/v1beta2" + coreV1 "k8s.io/api/core/v1" + k8sSettings "k8s.io/api/settings/v1alpha1" + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/strategicpatch" + k8sTesting "k8s.io/client-go/testing" +) + +var deploymentsResource = schema.GroupVersionResource{Group: "apps", Version: "v1beta2", Resource: "deployments"} + +func patchDeploymentAction(oldDeploy, newDeploy *appsV1beta2.Deployment) k8sTesting.PatchAction { + oldData, err := json.Marshal(oldDeploy) + if err != nil { + panic(err) + } + + newData, err := json.Marshal(newDeploy) + if err != nil { + panic(err) + } + + patchBytes, err := strategicpatch.CreateTwoWayMergePatch(oldData, newData, appsV1beta2.Deployment{}) + if err != nil { + panic(err) + } + + return k8sTesting.NewPatchAction(deploymentsResource, oldDeploy.Namespace, oldDeploy.Name, patchBytes) +} + +var functionsResource = schema.GroupVersionResource{Group: "kubeless.io", Version: "v1beta1", Resource: "functions"} + +func updateFunctionAction(fn *kubelessTypes.Function) k8sTesting.UpdateAction { + return k8sTesting.NewUpdateAction(functionsResource, fn.Namespace, fn) +} + +var podpresetsResource = schema.GroupVersionResource{Group: "settings.k8s.io", Version: "v1alpha1", Resource: "podpresets"} + +func createPodPresetAction(pp *k8sSettings.PodPreset) k8sTesting.Action { + return k8sTesting.NewCreateAction(podpresetsResource, pp.Namespace, pp) +} + +func updatePodPresetAction(pp *k8sSettings.PodPreset) k8sTesting.Action { + return k8sTesting.NewUpdateAction(podpresetsResource, pp.Namespace, pp) +} + +func deletePodPresetAction(pp *k8sSettings.PodPreset) k8sTesting.Action { + return k8sTesting.NewDeleteAction(podpresetsResource, pp.Namespace, pp.Name) +} + +var servicebindingusagesResource = schema.GroupVersionResource{Group: "servicecatalog.ysf.io", Version: "v1alpha1", Resource: "servicebindingusages"} + +func updateUsageAction(sbu *sbuTypes.ServiceBindingUsage) k8sTesting.UpdateAction { + return k8sTesting.NewUpdateAction(servicebindingusagesResource, sbu.Namespace, sbu) +} + +var configmapsResource = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "configmaps"} + +func updateConfigMapAction(sbu *coreV1.ConfigMap) k8sTesting.UpdateAction { + return k8sTesting.NewUpdateAction(configmapsResource, sbu.Namespace, sbu) +} + +func getConfigMapAction(sbu *coreV1.ConfigMap) k8sTesting.GetAction { + return k8sTesting.NewGetAction(configmapsResource, sbu.Namespace, sbu.Name) +} + +func filterOutInformerActions(actions []k8sTesting.Action) []k8sTesting.Action { + var ret []k8sTesting.Action + for _, action := range actions { + if action.GetVerb() == "list" || action.GetVerb() == "watch" { + continue + } + ret = append(ret, action) + } + + return ret +} + +func checkAction(t *testing.T, expected, actual k8sTesting.Action) { + assert.Truef(t, expected.Matches(actual.GetVerb(), actual.GetResource().Resource), + "actions not matched: expected [%#v] got [%#v]", expected, actual) + + switch a := actual.(type) { + case k8sTesting.CreateAction: + e, ok := expected.(k8sTesting.CreateAction) + assert.True(t, ok) + + expObject := e.GetObject() + object := a.GetObject() + + assert.Equal(t, expObject, object) + case k8sTesting.UpdateAction: + e, ok := expected.(k8sTesting.UpdateAction) + assert.True(t, ok) + + expObject := e.GetObject() + object := a.GetObject() + + assert.Equal(t, expObject, object) + case k8sTesting.PatchAction: + e, ok := expected.(k8sTesting.PatchAction) + assert.True(t, ok) + + expPatch := e.GetPatch() + patch := a.GetPatch() + + assert.Equal(t, expPatch, patch) + } +} + +func failingReactor(action k8sTesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, errors.New("custom error") +} + +func newLogSinkForErrors() *spy.LogSink { + logSink := spy.NewLogSink() + logSink.Logger.Logger.Level = logrus.ErrorLevel + return logSink +} + +func awaitForChanAtMost(t *testing.T, ch <-chan struct{}, timeout time.Duration) { + select { + case <-ch: + case <-time.After(timeout): + t.Fatalf("timeout occured when waiting for channel") + } +} + +func fixConfigMap(data map[string]string) *coreV1.ConfigMap { + return &coreV1.ConfigMap{ + ObjectMeta: metaV1.ObjectMeta{ + Namespace: "system", + Name: "system-cm", + }, + Data: data, + } +} + +func assertErrorContainsStatement(t *testing.T, err error, contains string) { + assert.Contains(t, err.Error(), contains) +} + +func mustMarshal(v interface{}) string { + marshaled, err := json.Marshal(v) + if err != nil { + panic(fmt.Sprintf("while marshaling, got err: %v", err)) + } + + return string(marshaled) +} diff --git a/components/binding-usage-controller/internal/controller/kubeless_function_supervisor.go b/components/binding-usage-controller/internal/controller/kubeless_function_supervisor.go new file mode 100644 index 000000000000..bd6203033ed6 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/kubeless_function_supervisor.go @@ -0,0 +1,169 @@ +package controller + +import ( + "fmt" + "strings" + + kubelessTypes "github.com/kubeless/kubeless/pkg/apis/kubeless/v1beta1" + kubelessClient "github.com/kubeless/kubeless/pkg/client/clientset/versioned/typed/kubeless/v1beta1" + kubelessInformer "github.com/kubeless/kubeless/pkg/client/informers/externalversions/kubeless/v1beta1" + kubelessLister "github.com/kubeless/kubeless/pkg/client/listers/kubeless/v1beta1" + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller/pretty" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/client-go/tools/cache" +) + +// KubelessFunctionSupervisor validates if Function can be modified by given ServiceBindingUsage. If yes +// it can ensure that labels are present or deleted on Deployment for given Function +type KubelessFunctionSupervisor struct { + functionLister kubelessLister.FunctionLister + functionListerSynced cache.InformerSynced + kubelessClient kubelessClient.KubelessV1beta1Interface + log logrus.FieldLogger + tracer usageBindingAnnotationTracer +} + +// NewKubelessFunctionSupervisor creates a new KubelessFunctionSupervisor. +func NewKubelessFunctionSupervisor(fnInformer kubelessInformer.FunctionInformer, kubelessClient kubelessClient.KubelessV1beta1Interface, log logrus.FieldLogger) *KubelessFunctionSupervisor { + return &KubelessFunctionSupervisor{ + functionLister: fnInformer.Lister(), + functionListerSynced: fnInformer.Informer().HasSynced, + kubelessClient: kubelessClient, + log: log.WithField("service", "controller:kubeless-function-supervisor"), + + tracer: &usageAnnotationTracer{}, + } +} + +// EnsureLabelsCreated ensures that given labels are added to Deployment for given Function +func (m *KubelessFunctionSupervisor) EnsureLabelsCreated(fnNs, fnName, usageName string, labels map[string]string) error { + cacheFn, err := m.functionLister.Functions(fnNs).Get(fnName) + if err != nil { + return errors.Wrap(err, "while getting Function") + } + + newFn := cacheFn.DeepCopy() + + // check labels conflicts + if conflictsKeys, found := detectLabelsConflicts(m.convertToLabeler(newFn), labels); found { + err := fmt.Errorf("found conflicts in %s under 'Spec.Deployment.Spec.Template.Labels' field: %s keys already exists [override forbidden]", + pretty.FunctionName(newFn), strings.Join(conflictsKeys, ",")) + return err + } + + // apply new labels + newFn.Spec.Deployment.Spec.Template.Labels = EnsureMapIsInitiated(newFn.Spec.Deployment.Spec.Template.Labels) + for k, v := range labels { + newFn.Spec.Deployment.Spec.Template.Labels[k] = v + } + + if err := m.tracer.SetAnnotationAboutBindingUsage(&newFn.ObjectMeta, usageName, labels); err != nil { + return errors.Wrap(err, "while setting annotation tracing data") + } + + if err := m.updateFunction(newFn); err != nil { + return errors.Wrap(err, "while patching Function") + } + + return nil +} + +// EnsureLabelsDeleted ensures that given labels are deleted on Deployment for given Function +func (m *KubelessFunctionSupervisor) EnsureLabelsDeleted(fnNs, fnName, usageName string) error { + cacheFn, err := m.functionLister.Functions(fnNs).Get(fnName) + switch { + case err == nil: + case apiErrors.IsNotFound(err): + return nil + default: + return errors.Wrap(err, "while getting Function") + } + + oldFn := cacheFn.DeepCopy() + newFn := cacheFn.DeepCopy() + + // remove old labels + err = m.ensureOldLabelsAreRemovedOnNewFunction(oldFn, newFn, usageName) + if err != nil { + return errors.Wrap(err, "while trying to delete old labels") + } + + // remove annotations + err = m.tracer.DeleteAnnotationAboutBindingUsage(&newFn.ObjectMeta, usageName) + if err != nil { + return errors.Wrap(err, "while deleting annotation tracing data") + } + + if err := m.updateFunction(newFn); err != nil { + return errors.Wrap(err, "while patching Function") + } + + return nil +} + +// GetInjectedLabels returns labels applied on given Function +func (m *KubelessFunctionSupervisor) GetInjectedLabels(fnNS, fnName, usageName string) (map[string]string, error) { + fn, err := m.functionLister.Functions(fnNS).Get(fnName) + switch { + case err == nil: + case apiErrors.IsNotFound(err): + return nil, NewNotFoundError(err.Error()) + default: + return nil, errors.Wrap(err, "while listing Deployments") + } + + labels, err := m.tracer.GetInjectedLabels(fn.ObjectMeta, usageName) + if err != nil { + return nil, errors.Wrap(err, "while getting injected labels keys") + } + + return labels, nil +} + +// HasSynced returns true if the shared informer's store has synced +func (m *KubelessFunctionSupervisor) HasSynced() bool { + return m.functionListerSynced() +} + +func (m *KubelessFunctionSupervisor) ensureOldLabelsAreRemovedOnNewFunction(oldFn, newFn *kubelessTypes.Function, usageName string) error { + labels, err := m.tracer.GetInjectedLabels(oldFn.ObjectMeta, usageName) + if err != nil { + return errors.Wrap(err, "while getting injected labels") + } + if len(labels) == 0 { + return nil + } + + if newFn.Spec.Deployment.Spec.Template.Labels == nil { + m.log.Warningf("missing labels from previous modification") + return nil + } + + for lk := range labels { + delete(newFn.Spec.Deployment.Spec.Template.Labels, lk) + } + + return nil +} + +// CreateTwoWayMergePatch is not supported on custom resources +func (m *KubelessFunctionSupervisor) updateFunction(newFn *kubelessTypes.Function) error { + _, err := m.kubelessClient.Functions(newFn.Namespace).Update(newFn) + if err != nil && !apiErrors.IsAlreadyExists(err) { + return errors.Wrapf(err, "while updating %s", pretty.FunctionName(newFn)) + } + + return nil +} + +func (m *KubelessFunctionSupervisor) convertToLabeler(fn *kubelessTypes.Function) *labelledFunction { + return (*labelledFunction)(fn) +} + +type labelledFunction kubelessTypes.Function + +func (l *labelledFunction) Labels() map[string]string { + return l.Spec.Deployment.Spec.Template.Labels +} diff --git a/components/binding-usage-controller/internal/controller/kubeless_function_supervisor_export_test.go b/components/binding-usage-controller/internal/controller/kubeless_function_supervisor_export_test.go new file mode 100644 index 000000000000..5c12f3f09975 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/kubeless_function_supervisor_export_test.go @@ -0,0 +1,6 @@ +package controller + +func (m *KubelessFunctionSupervisor) WithUsageAnnotationTracer(tracer usageBindingAnnotationTracer) *KubelessFunctionSupervisor { + m.tracer = tracer + return m +} diff --git a/components/binding-usage-controller/internal/controller/kubeless_function_supervisor_test.go b/components/binding-usage-controller/internal/controller/kubeless_function_supervisor_test.go new file mode 100644 index 000000000000..8630cdeeef89 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/kubeless_function_supervisor_test.go @@ -0,0 +1,225 @@ +package controller_test + +import ( + "context" + "testing" + "time" + + kubelessTypes "github.com/kubeless/kubeless/pkg/apis/kubeless/v1beta1" + "github.com/kubeless/kubeless/pkg/client/clientset/versioned/fake" + kubelessInformers "github.com/kubeless/kubeless/pkg/client/informers/externalversions" + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller" + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller/automock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestFunctionSupervisorEnsureLabelsCreatedSuccess(t *testing.T) { + // given + fixLabels := map[string]string{ + "label-key": "label-val", + } + fixFn := fixKubelessFunction() + expFn := fixFn.DeepCopy() + expFn.Spec.Deployment.Spec.Template.Labels = fixLabels + + usageTracerMock := &automock.UsageBindingAnnotationTracer{} + defer usageTracerMock.AssertExpectations(t) + usageTracerMock.On("SetAnnotationAboutBindingUsage", &fixFn.ObjectMeta, fixUsageName, fixLabels). + Return(nil). + Once() + + fnCli := fake.NewSimpleClientset(fixFn) + fnInformersFactory := kubelessInformers.NewSharedInformerFactory(fnCli, time.Second) + + logErrSink := newLogSinkForErrors() + ctrl := controller.NewKubelessFunctionSupervisor( + fnInformersFactory.Kubeless().V1beta1().Functions(), + fnCli.KubelessV1beta1(), + logErrSink.Logger). + WithUsageAnnotationTracer(usageTracerMock) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + fnInformersFactory.Start(ctx.Done()) + fnInformersFactory.WaitForCacheSync(ctx.Done()) + + // when + err := ctrl.EnsureLabelsCreated(fixFn.Namespace, fixFn.Name, fixUsageName, fixLabels) + + // then + assert.NoError(t, err) + + performedActions := filterOutInformerActions(fnCli.Actions()) + require.Len(t, performedActions, 1) + checkAction(t, updateFunctionAction(expFn), performedActions[0]) + + assert.Empty(t, logErrSink.DumpAll()) +} + +func TestFunctionSupervisorEnsureLabelsDeletedSuccess(t *testing.T) { + // given + fixInjectedLabels := map[string]string{"label-key-1": "label-val-1"} + fixFn := fixKubelessFunction() + fixFn.Spec.Deployment.Spec.Template.Labels = map[string]string{ + "label-key-1": "label-val-1", + "label-key-2": "label-val-2", + } + + expFn := fixFn.DeepCopy() + expFn.Spec.Deployment.Spec.Template.Labels = map[string]string{ + "label-key-2": "label-val-2", + } + + usageTracerMock := &automock.UsageBindingAnnotationTracer{} + defer usageTracerMock.AssertExpectations(t) + usageTracerMock.On("GetInjectedLabels", fixFn.ObjectMeta, fixUsageName). + Return(fixInjectedLabels, nil). + Once() + usageTracerMock.On("DeleteAnnotationAboutBindingUsage", &fixFn.ObjectMeta, fixUsageName). + Return(nil). + Once() + + fnCli := fake.NewSimpleClientset(fixFn) + fnInformersFactory := kubelessInformers.NewSharedInformerFactory(fnCli, time.Second) + + logErrSink := newLogSinkForErrors() + ctrl := controller.NewKubelessFunctionSupervisor( + fnInformersFactory.Kubeless().V1beta1().Functions(), + fnCli.KubelessV1beta1(), + logErrSink.Logger). + WithUsageAnnotationTracer(usageTracerMock) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + fnInformersFactory.Start(ctx.Done()) + fnInformersFactory.WaitForCacheSync(ctx.Done()) + + // when + err := ctrl.EnsureLabelsDeleted(fixFn.Namespace, fixFn.Name, fixUsageName) + + // then + assert.NoError(t, err) + + performedActions := filterOutInformerActions(fnCli.Actions()) + require.Len(t, performedActions, 1) + checkAction(t, updateFunctionAction(expFn), performedActions[0]) + + assert.Empty(t, logErrSink.DumpAll()) +} + +func TestFunctionSupervisorGetInjectedLabelsKeysSuccess(t *testing.T) { + // given + fixLabels := map[string]string{"label-key": "label-val"} + fixFn := fixKubelessFunction() + + usageTracerMock := &automock.UsageBindingAnnotationTracer{} + defer usageTracerMock.AssertExpectations(t) + usageTracerMock.On("GetInjectedLabels", fixFn.ObjectMeta, fixUsageName). + Return(fixLabels, nil). + Once() + + fnCli := fake.NewSimpleClientset(fixFn) + fnInformersFactory := kubelessInformers.NewSharedInformerFactory(fnCli, time.Second) + + logErrSink := newLogSinkForErrors() + ctrl := controller.NewKubelessFunctionSupervisor( + fnInformersFactory.Kubeless().V1beta1().Functions(), + fnCli.KubelessV1beta1(), + logErrSink.Logger). + WithUsageAnnotationTracer(usageTracerMock) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + fnInformersFactory.Start(ctx.Done()) + fnInformersFactory.WaitForCacheSync(ctx.Done()) + + // when + foundLabels, err := ctrl.GetInjectedLabels(fixFn.Namespace, fixFn.Name, fixUsageName) + + // then + require.NoError(t, err) + assert.Equal(t, foundLabels, fixLabels) + + assert.Empty(t, logErrSink.DumpAll()) +} + +func TestFunctionSupervisorGetInjectedLabelsFailure(t *testing.T) { + + t.Run("Function not found", func(t *testing.T) { + // given + fnCli := fake.NewSimpleClientset() + fnInformersFactory := kubelessInformers.NewSharedInformerFactory(fnCli, time.Second) + + logErrSink := newLogSinkForErrors() + ctrl := controller.NewKubelessFunctionSupervisor( + fnInformersFactory.Kubeless().V1beta1().Functions(), + fnCli.KubelessV1beta1(), + logErrSink.Logger) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + fnInformersFactory.Start(ctx.Done()) + fnInformersFactory.WaitForCacheSync(ctx.Done()) + + // when + foundLabelsKeys, err := ctrl.GetInjectedLabels("ns-not-found", "fn-not-found", fixUsageName) + + // then + assert.True(t, controller.IsNotFoundError(err)) + assert.Nil(t, foundLabelsKeys) + + assert.Empty(t, logErrSink.DumpAll()) + }) + + t.Run("GetInjectedLabels error", func(t *testing.T) { + // given + fixLabels := map[string]string{"label-key": "label-val"} + fixFn := fixKubelessFunction() + + usageTracerMock := &automock.UsageBindingAnnotationTracer{} + defer usageTracerMock.AssertExpectations(t) + usageTracerMock.On("GetInjectedLabels", fixFn.ObjectMeta, fixUsageName). + Return(fixLabels, nil). + Once() + + fnCli := fake.NewSimpleClientset(fixFn) + fnInformersFactory := kubelessInformers.NewSharedInformerFactory(fnCli, time.Second) + + logErrSink := newLogSinkForErrors() + ctrl := controller.NewKubelessFunctionSupervisor( + fnInformersFactory.Kubeless().V1beta1().Functions(), + fnCli.KubelessV1beta1(), + logErrSink.Logger). + WithUsageAnnotationTracer(usageTracerMock) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + fnInformersFactory.Start(ctx.Done()) + fnInformersFactory.WaitForCacheSync(ctx.Done()) + + // when + foundLabelsKeys, err := ctrl.GetInjectedLabels(fixFn.Namespace, fixFn.Name, fixUsageName) + + // then + require.NoError(t, err) + assert.Equal(t, foundLabelsKeys, fixLabels) + + assert.Empty(t, logErrSink.DumpAll()) + }) +} + +func fixKubelessFunction() *kubelessTypes.Function { + return &kubelessTypes.Function{ + ObjectMeta: metaV1.ObjectMeta{ + Namespace: "production", + Name: "pico-bello-deploy", + }, + } +} diff --git a/components/binding-usage-controller/internal/controller/label_checker.go b/components/binding-usage-controller/internal/controller/label_checker.go new file mode 100644 index 000000000000..9af750b87d55 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/label_checker.go @@ -0,0 +1,20 @@ +package controller + +type labelled interface { + Labels() map[string]string +} + +func detectLabelsConflicts(source labelled, labels map[string]string) ([]string, bool) { + dLabels := source.Labels() + if dLabels == nil { + return nil, false + } + var conflicts []string + for k := range labels { + if _, exists := dLabels[k]; exists { + conflicts = append(conflicts, k) + } + } + + return conflicts, len(conflicts) != 0 +} diff --git a/components/binding-usage-controller/internal/controller/label_checker_test.go b/components/binding-usage-controller/internal/controller/label_checker_test.go new file mode 100644 index 000000000000..c50a091ada7e --- /dev/null +++ b/components/binding-usage-controller/internal/controller/label_checker_test.go @@ -0,0 +1,52 @@ +package controller + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +type testedLabeler struct { + labels map[string]string +} + +func (l *testedLabeler) Labels() map[string]string { + return l.labels +} + +func TestDetectLabelsConflictsNotFound(t *testing.T) { + // then + source := &testedLabeler{ + labels: map[string]string{ + "key": "val", + }, + } + + // when + conflicts, found := detectLabelsConflicts(source, map[string]string{ + "key1": "val", + }) + + // then + assert.False(t, found) + assert.Empty(t, conflicts) +} + +func TestDetectLabelsConflictsFound(t *testing.T) { + // given + fixLabels := map[string]string{ + "key": "val", + } + + source := &testedLabeler{ + labels: fixLabels, + } + + // when + conflicts, found := detectLabelsConflicts(source, fixLabels) + + // then + assert.True(t, found) + assert.Len(t, conflicts, 1) + assert.Equal(t, conflicts[0], "key") +} diff --git a/components/binding-usage-controller/internal/controller/labels_fetcher.go b/components/binding-usage-controller/internal/controller/labels_fetcher.go new file mode 100644 index 000000000000..6836f73bc4af --- /dev/null +++ b/components/binding-usage-controller/internal/controller/labels_fetcher.go @@ -0,0 +1,91 @@ +package controller + +import ( + "encoding/json" + "fmt" + + scTypes "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + scListers "github.com/kubernetes-incubator/service-catalog/pkg/client/listers_generated/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller/pretty" + "github.com/pkg/errors" +) + +// BindingLabelsFetcher extracts binding labels defined in ClusterServiceClass for given ServiceBinding +type BindingLabelsFetcher struct { + siLister scListers.ServiceInstanceLister + scLister scListers.ClusterServiceClassLister +} + +// NewBindingLabelsFetcher returnd BindingLabelsFetcher +func NewBindingLabelsFetcher(siLister scListers.ServiceInstanceLister, + scLister scListers.ClusterServiceClassLister) *BindingLabelsFetcher { + return &BindingLabelsFetcher{ + siLister: siLister, + scLister: scLister, + } +} + +// Fetch returns binding labels defined in ClusterServiceClass +func (c *BindingLabelsFetcher) Fetch(svcBinding *scTypes.ServiceBinding) (map[string]string, error) { + if svcBinding == nil { + return nil, errors.New("cannot fetch labels from ClusterServiceClass because binding is nil") + } + + svcInstanceName := svcBinding.Spec.ServiceInstanceRef.Name + svcInstance, err := c.siLister.ServiceInstances(svcBinding.Namespace).Get(svcInstanceName) + + if err != nil { + return nil, errors.Wrapf(err, "while fetching ServiceInstance [%s] from namespace [%s] indicated by ServiceBinding", svcInstanceName, svcBinding.Namespace) + } + + svcClassName := c.getClassNameFromInstance(svcInstance) + if svcClassName == "" { + return nil, fmt.Errorf("cannot fetch ClusterServiceClass from [%s]", pretty.ServiceInstanceName(svcInstance)) + } + + svcClass, err := c.scLister.Get(svcClassName) + if err != nil { + return nil, errors.Wrapf(err, "while fetching ClusterServiceClass [%s]", svcClassName) + } + + return c.getBindingLabelsFromClass(svcClass) +} + +func (c *BindingLabelsFetcher) getClassNameFromInstance(svcInstance *scTypes.ServiceInstance) string { + if svcInstance.Spec.ClusterServiceClassExternalName != "" { + return svcInstance.Spec.ClusterServiceClassRef.Name + } + return svcInstance.Spec.ClusterServiceClassName +} + +func (c *BindingLabelsFetcher) getBindingLabelsFromClass(svcClass *scTypes.ClusterServiceClass) (map[string]string, error) { + var jsonDataAsAMap map[string]interface{} + + if svcClass.Spec.ExternalMetadata == nil { + return nil, nil + } + + if err := json.Unmarshal(svcClass.Spec.ExternalMetadata.Raw, &jsonDataAsAMap); err != nil { + return nil, errors.Wrapf(err, "while unmarshalling raw metadata to json from [%s]", pretty.ClusterServiceClassName(svcClass)) + } + + rawBindingLabels, exists := jsonDataAsAMap["bindingLabels"] + if !exists { + return nil, nil + } + + bindingLabels, ok := rawBindingLabels.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("cannot cast bindingLabels to map[string]interface{} from [%s]", pretty.ClusterServiceClassName(svcClass)) + } + + out := make(map[string]string) + for label, value := range bindingLabels { + strValue, ok := value.(string) + if !ok { + return nil, fmt.Errorf("cannot cast bindingLabels[%s]=[%v] to string value from [%s] ", label, value, pretty.ClusterServiceClassName(svcClass)) + } + out[label] = strValue + } + return out, nil +} diff --git a/components/binding-usage-controller/internal/controller/labels_fetcher_test.go b/components/binding-usage-controller/internal/controller/labels_fetcher_test.go new file mode 100644 index 000000000000..e69b748dbe4a --- /dev/null +++ b/components/binding-usage-controller/internal/controller/labels_fetcher_test.go @@ -0,0 +1,176 @@ +package controller_test + +import ( + "context" + "testing" + "time" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kubernetes-incubator/service-catalog/pkg/client/clientset_generated/clientset/fake" + serviceCatalogInformers "github.com/kubernetes-incubator/service-catalog/pkg/client/informers_generated/externalversions" + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/cache" +) + +const defaultCacheSyncTimeout = time.Second * 5 + +func TestBindingLabelsFetcherHappyPath(t *testing.T) { + + type testCase struct { + testName string + givenServiceInstance *v1beta1.ServiceInstance + } + + for _, tc := range []testCase{ + { + testName: "instance refers to external class name", + givenServiceInstance: fixPromotionsServiceWithClassExternalName(), + }, + { + testName: "instance refers to direct class name", + givenServiceInstance: fixPromotionsServiceWithDirectServiceClassName(), + }, + } { + t.Run(tc.testName, func(t *testing.T) { + // GIVEN + givenServiceClass := fixPromotionsServiceClass() + + givenServiceBinding := fixPromotionsServiceBinding() + fakeClientSet := fake.NewSimpleClientset(tc.givenServiceInstance, givenServiceClass) + + informerFactory := serviceCatalogInformers.NewSharedInformerFactory(fakeClientSet, time.Hour) + + sut := controller.NewBindingLabelsFetcher(informerFactory.Servicecatalog().V1beta1().ServiceInstances().Lister(), informerFactory.Servicecatalog().V1beta1().ClusterServiceClasses().Lister()) + + ctx, cancel := context.WithTimeout(context.Background(), defaultCacheSyncTimeout) + defer cancel() + + informerFactory.Start(ctx.Done()) + + cache.WaitForCacheSync(ctx.Done(), informerFactory.Servicecatalog().V1beta1().ServiceInstances().Informer().HasSynced, informerFactory.Servicecatalog().V1beta1().ClusterServiceClasses().Informer().HasSynced) + + // WHEN + actualLabels, err := sut.Fetch(givenServiceBinding) + assert.NoError(t, err) + expectedLabels := map[string]string{ + "access-label-1": "true", + } + // THEN + assert.Equal(t, expectedLabels, actualLabels) + }) + + } +} + +func TestBindingLabelsFetcherErrors(t *testing.T) { + + type testCase struct { + testName string + givenScObjects []runtime.Object + givenBinding *v1beta1.ServiceBinding + expectedErrorMsg string + } + + for _, tc := range []testCase{ + { + testName: "when binding not provided", + expectedErrorMsg: "cannot fetch labels from ClusterServiceClass because binding is nil", + }, + { + testName: "when cannot find service instance pointed by ServiceBinding", + givenBinding: fixPromotionsServiceBinding(), + expectedErrorMsg: "while fetching ServiceInstance [promotions-service] from namespace [production] indicated by ServiceBinding:" + + " serviceinstance.servicecatalog.k8s.io \"promotions-service\" not found", + }, + { + testName: "when cannot find service class", + givenBinding: fixPromotionsServiceBinding(), + givenScObjects: []runtime.Object{fixPromotionsServiceWithClassExternalName()}, + expectedErrorMsg: "while fetching ClusterServiceClass [ac031e8c-9aa4-4cb7-8999-0d358726ffaa]:" + + " clusterserviceclass.servicecatalog.k8s.io \"ac031e8c-9aa4-4cb7-8999-0d358726ffaa\" not found", + }, + } { + t.Run(tc.testName, func(t *testing.T) { + // GIVEN + fakeClientSet := fake.NewSimpleClientset(tc.givenScObjects...) + informerFactory := serviceCatalogInformers.NewSharedInformerFactory(fakeClientSet, time.Hour) + sut := controller.NewBindingLabelsFetcher(informerFactory.Servicecatalog().V1beta1().ServiceInstances().Lister(), informerFactory.Servicecatalog().V1beta1().ClusterServiceClasses().Lister()) + + ctx, cancel := context.WithTimeout(context.Background(), defaultCacheSyncTimeout) + defer cancel() + informerFactory.Start(ctx.Done()) + + cache.WaitForCacheSync(ctx.Done(), informerFactory.Servicecatalog().V1beta1().ServiceInstances().Informer().HasSynced, informerFactory.Servicecatalog().V1beta1().ClusterServiceClasses().Informer().HasSynced) + + // WHEN + _, err := sut.Fetch(tc.givenBinding) + // THEN + assert.Error(t, err) + assert.EqualError(t, err, tc.expectedErrorMsg) + }) + } +} + +func fixPromotionsServiceBinding() *v1beta1.ServiceBinding { + return &v1beta1.ServiceBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "promotions-service-binding", + Namespace: "production", + }, + Spec: v1beta1.ServiceBindingSpec{ + ServiceInstanceRef: v1beta1.LocalObjectReference{ + Name: "promotions-service", + }, + }, + } +} + +func fixPromotionsServiceClass() *v1beta1.ClusterServiceClass { + return &v1beta1.ClusterServiceClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ac031e8c-9aa4-4cb7-8999-0d358726ffaa", + }, + Spec: v1beta1.ClusterServiceClassSpec{ + CommonServiceClassSpec: v1beta1.CommonServiceClassSpec{ + ExternalName: "promotions", + ExternalMetadata: &runtime.RawExtension{ + Raw: []byte(`{"bindingLabels": {"access-label-1":"true"} }`), + }, + }, + }, + } +} + +func fixPromotionsServiceWithClassExternalName() *v1beta1.ServiceInstance { + return &v1beta1.ServiceInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "promotions-service", + Namespace: "production", + }, + Spec: v1beta1.ServiceInstanceSpec{ + PlanReference: v1beta1.PlanReference{ + ClusterServiceClassExternalName: "promotions", + }, + ClusterServiceClassRef: &v1beta1.ClusterObjectReference{ + Name: "ac031e8c-9aa4-4cb7-8999-0d358726ffaa", + }, + }, + } +} + +func fixPromotionsServiceWithDirectServiceClassName() *v1beta1.ServiceInstance { + return &v1beta1.ServiceInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "promotions-service", + Namespace: "production", + }, + Spec: v1beta1.ServiceInstanceSpec{ + PlanReference: v1beta1.PlanReference{ + ClusterServiceClassName: "ac031e8c-9aa4-4cb7-8999-0d358726ffaa", + }, + }, + } +} diff --git a/components/binding-usage-controller/internal/controller/map_helpers.go b/components/binding-usage-controller/internal/controller/map_helpers.go new file mode 100644 index 000000000000..20722e4b6d64 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/map_helpers.go @@ -0,0 +1,29 @@ +package controller + +// EnsureMapIsInitiated ensures that given map is initiated. +// - returns given map if it's already allocated +// - otherwise returns empty map +func EnsureMapIsInitiated(m map[string]string) map[string]string { + if m == nil { + empty := make(map[string]string) + return empty + } + + return m +} + +// Merge returns map which is union of input maps. In case of of the same key defined in both maps, ConflictError will be returned +func Merge(m1, m2 map[string]string) (map[string]string, error) { + out := make(map[string]string) + for k, v := range m1 { + out[k] = v + } + + for k, v := range m2 { + if _, ex := out[k]; ex { + return nil, &ConflictError{ConflictingResource: k} + } + out[k] = v + } + return out, nil +} diff --git a/components/binding-usage-controller/internal/controller/map_helpers_test.go b/components/binding-usage-controller/internal/controller/map_helpers_test.go new file mode 100644 index 000000000000..6913bc088098 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/map_helpers_test.go @@ -0,0 +1,59 @@ +package controller_test + +import ( + "testing" + + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller" + "github.com/stretchr/testify/assert" +) + +func TestEnsureMapIsInitiated(t *testing.T) { + t.Run("Not Initiated map", func(t *testing.T) { + // given + var notInitiatedMap map[string]string + + // when + got := controller.EnsureMapIsInitiated(notInitiatedMap) + + // then + assert.NotNil(t, got) + assert.Empty(t, got) + }) + + t.Run("Already Initiated map", func(t *testing.T) { + // given + initiated := map[string]string{ + "key": "val", + } + + // when + got := controller.EnsureMapIsInitiated(initiated) + + // then + assert.Equal(t, got, initiated) + }) +} + +func TestMerge(t *testing.T) { + // GIVEN + m1 := map[string]string{"a": "1", "b": "2"} + m2 := map[string]string{"c": "3"} + // WHEN + actual, err := controller.Merge(m1, m2) + // THEN + assert.NoError(t, err) + assert.Equal(t, map[string]string{"a": "1", "b": "2"}, m1) + assert.Equal(t, map[string]string{"c": "3"}, m2) + assert.Equal(t, map[string]string{"a": "1", "b": "2", "c": "3"}, actual) +} + +func TestMergeOnConflict(t *testing.T) { + // GIVEN + m1 := map[string]string{"a": "1"} + m2 := map[string]string{"a": "1"} + // WHEN + _, err := controller.Merge(m1, m2) + // THEN + assert.EqualError(t, err, "Conflict Error for [a]") + +} diff --git a/components/binding-usage-controller/internal/controller/podpreset_modifer.go b/components/binding-usage-controller/internal/controller/podpreset_modifer.go new file mode 100644 index 000000000000..59dcbb26791e --- /dev/null +++ b/components/binding-usage-controller/internal/controller/podpreset_modifer.go @@ -0,0 +1,51 @@ +package controller + +import ( + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller/pretty" + "github.com/pkg/errors" + k8sSettings "k8s.io/api/settings/v1alpha1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clientSettingsV1alpha1 "k8s.io/client-go/kubernetes/typed/settings/v1alpha1" +) + +// PodPresetModifier provides functionality needed to create and delete PodPreset +type PodPresetModifier struct { + settingsClient clientSettingsV1alpha1.SettingsV1alpha1Interface +} + +// NewPodPresetModifier creates a new PodPresetModifier +func NewPodPresetModifier(settingsClient clientSettingsV1alpha1.SettingsV1alpha1Interface) *PodPresetModifier { + return &PodPresetModifier{ + settingsClient: settingsClient, + } +} + +// UpsertPodPreset creates a new PodPreset or update it if needed +func (m *PodPresetModifier) UpsertPodPreset(podPreset *k8sSettings.PodPreset) error { + // TODO consider to add support for `ownerReferences` and then use v1.IsControlledBy method + _, err := m.settingsClient.PodPresets(podPreset.Namespace).Create(podPreset) + switch { + case err == nil: + case apiErrors.IsAlreadyExists(err): + if err := m.settingsClient.PodPresets(podPreset.Namespace).Delete(podPreset.Name, &metaV1.DeleteOptions{}); err != nil { + return errors.Wrapf(err, "while deleting %s", pretty.PodPresetName(podPreset)) + } + if _, err := m.settingsClient.PodPresets(podPreset.Namespace).Create(podPreset); err != nil { + return errors.Wrapf(err, "while re-creating %s", pretty.PodPresetName(podPreset)) + } + default: + return errors.Wrapf(err, "while creating %s", pretty.PodPresetName(podPreset)) + } + + return nil +} + +// EnsurePodPresetDeleted deletes a PodPreset if needed +func (m *PodPresetModifier) EnsurePodPresetDeleted(namespace, name string) error { + err := m.settingsClient.PodPresets(namespace).Delete(name, &metaV1.DeleteOptions{}) + if err != nil && !apiErrors.IsNotFound(err) { + return errors.Wrapf(err, "while deleting PodPreset %s in namespace %s", name, namespace) + } + return nil +} diff --git a/components/binding-usage-controller/internal/controller/podpreset_modifer_test.go b/components/binding-usage-controller/internal/controller/podpreset_modifer_test.go new file mode 100644 index 000000000000..86465392079a --- /dev/null +++ b/components/binding-usage-controller/internal/controller/podpreset_modifer_test.go @@ -0,0 +1,112 @@ +package controller_test + +import ( + "testing" + + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + k8sSettings "k8s.io/api/settings/v1alpha1" + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + k8sTesting "k8s.io/client-go/testing" +) + +func TestPodPresetModifierUpsertPodPreset(t *testing.T) { + tests := map[string]struct { + objAlreadyInK8s []runtime.Object + ppToCreate *k8sSettings.PodPreset + expActions []k8sTesting.Action + }{ + "create existing PodPreset": { + objAlreadyInK8s: []runtime.Object{ + fixPodPreset(), + }, + ppToCreate: fixPodPreset(), + + expActions: []k8sTesting.Action{ + createPodPresetAction(fixPodPreset()), + deletePodPresetAction(fixPodPreset()), + createPodPresetAction(fixPodPreset()), + }, + }, + "create not existing PodPreset": { + objAlreadyInK8s: []runtime.Object{}, + ppToCreate: fixPodPreset(), + + expActions: []k8sTesting.Action{ + createPodPresetAction(fixPodPreset()), + }, + }, + } + for tn, tc := range tests { + t.Run(tn, func(t *testing.T) { + // given + fakeCli := fake.NewSimpleClientset(tc.objAlreadyInK8s...) + ppModifier := controller.NewPodPresetModifier(fakeCli.SettingsV1alpha1()) + + // when + err := ppModifier.UpsertPodPreset(tc.ppToCreate) + + // then + assert.NoError(t, err) + performedActions := fakeCli.Actions() + require.Equal(t, len(performedActions), len(tc.expActions)) + for idx, expAction := range tc.expActions { + checkAction(t, performedActions[idx], expAction) + } + + }) + } +} + +func TestPodPresetModifierEnsurePodPresetDeleted(t *testing.T) { + tests := map[string]struct { + objAlreadyInK8s []runtime.Object + ppToDelete *k8sSettings.PodPreset + }{ + "delete existing PodPreset": { + objAlreadyInK8s: []runtime.Object{ + fixPodPreset(), + }, + ppToDelete: fixPodPreset(), + }, + "delete not existing PodPreset": { + objAlreadyInK8s: []runtime.Object{}, + ppToDelete: fixPodPreset(), + }, + } + for tn, tc := range tests { + t.Run(tn, func(t *testing.T) { + // given + fakeCli := fake.NewSimpleClientset(tc.objAlreadyInK8s...) + ppModifier := controller.NewPodPresetModifier(fakeCli.SettingsV1alpha1()) + + // when + err := ppModifier.EnsurePodPresetDeleted(tc.ppToDelete.Namespace, tc.ppToDelete.Name) + + // then + assert.NoError(t, err) + performedActions := fakeCli.Actions() + require.Len(t, performedActions, 1) + checkAction(t, deletePodPresetAction(tc.ppToDelete), performedActions[0]) + }) + } +} + +func fixPodPreset() *k8sSettings.PodPreset { + return &k8sSettings.PodPreset{ + ObjectMeta: metaV1.ObjectMeta{ + Name: "pp-test", + Namespace: "ns-test", + }, + Spec: k8sSettings.PodPresetSpec{ + Selector: metaV1.LabelSelector{ + MatchLabels: map[string]string{ + "test-key": "test-value", + }, + }, + }, + } +} diff --git a/components/binding-usage-controller/internal/controller/pretty/pretty.go b/components/binding-usage-controller/internal/controller/pretty/pretty.go new file mode 100644 index 000000000000..2b3e36151062 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/pretty/pretty.go @@ -0,0 +1,47 @@ +package pretty + +import ( + "fmt" + + kubelessTypes "github.com/kubeless/kubeless/pkg/apis/kubeless/v1beta1" + scTypes "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kubernetes-incubator/service-catalog/pkg/pretty" + sbuTypes "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + appsV1beta2 "k8s.io/api/apps/v1beta2" + settingsV1alpha1 "k8s.io/api/settings/v1alpha1" +) + +// ClusterServiceClassName returns string with type and name of ClusterServiceClass +func ClusterServiceClassName(obj *scTypes.ClusterServiceClass) string { + return fmt.Sprintf("%s %s", pretty.ClusterServiceClass, obj.Name) +} + +// ServiceInstanceName returns string with type, namespace and name of ServiceInstance +func ServiceInstanceName(obj *scTypes.ServiceInstance) string { + return fmt.Sprintf("%s %s/%s", pretty.ServiceInstance, obj.Namespace, obj.Name) +} + +// ServiceBindingName returns string with the type, namespace and name of ServiceBinding. +func ServiceBindingName(obj *scTypes.ServiceBinding) string { + return fmt.Sprintf(`%s "%s/%s"`, pretty.ServiceBinding, obj.Namespace, obj.Name) +} + +// ServiceBindingUsageName returns string with the type, namespace and name of ServiceBindingUsage. +func ServiceBindingUsageName(obj *sbuTypes.ServiceBindingUsage) string { + return fmt.Sprintf(`ServiceBindingUsage "%s/%s"`, obj.Namespace, obj.Name) +} + +// PodPresetName returns string with the type, namespace and name of PodPreset. +func PodPresetName(obj *settingsV1alpha1.PodPreset) string { + return fmt.Sprintf(`PodPreset "%s/%s"`, obj.Namespace, obj.Name) +} + +// DeploymentName returns string with the type, namespace and name of Deployment. +func DeploymentName(obj *appsV1beta2.Deployment) string { + return fmt.Sprintf(`Deployment "%s/%s"`, obj.Namespace, obj.Name) +} + +// FunctionName returns string with the type, namespace and name of Function. +func FunctionName(obj *kubelessTypes.Function) string { + return fmt.Sprintf(`Deployment "%s/%s"`, obj.Namespace, obj.Name) +} diff --git a/components/binding-usage-controller/internal/controller/sbu_spec_storage.go b/components/binding-usage-controller/internal/controller/sbu_spec_storage.go new file mode 100644 index 000000000000..e65b35e7eef8 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/sbu_spec_storage.go @@ -0,0 +1,109 @@ +package controller + +import ( + "encoding/json" + "fmt" + + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller/pretty" + sbuTypes "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + "github.com/pkg/errors" + coreV1 "k8s.io/api/core/v1" + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type configMapClient interface { + Get(name string, options metaV1.GetOptions) (*coreV1.ConfigMap, error) + Update(*coreV1.ConfigMap) (*coreV1.ConfigMap, error) +} + +// BindingUsageSpecStorage provides functionality to get/delete/save ServiceBindingUsage.Spec +type BindingUsageSpecStorage struct { + cfgMapClient configMapClient + cfgMapName string +} + +// NewBindingUsageSpecStorage returns new instance of BindingUsageSpecStorage +func NewBindingUsageSpecStorage(cfgMapClient configMapClient, cfgMapName string) *BindingUsageSpecStorage { + return &BindingUsageSpecStorage{ + cfgMapClient: cfgMapClient, + cfgMapName: cfgMapName, + } +} + +// Get returns stored spec for given ServiceBindingUsage +func (c *BindingUsageSpecStorage) Get(usageNS, usageName string) (*UsageSpec, bool, error) { + cfg, err := c.cfgMapClient.Get(c.cfgMapName, metaV1.GetOptions{}) + if err != nil { + return nil, false, errors.Wrapf(err, "while getting config map with stored Spec for %s/%s", usageNS, usageName) + } + + rawSpec, exists := cfg.Data[c.specUsedByKey(usageNS, usageName)] + if !exists { + return nil, false, nil + } + + var spec UsageSpec + if err := json.Unmarshal([]byte(rawSpec), &spec); err != nil { + return nil, false, errors.Wrap(err, "while unmarshalling spec") + } + + return &spec, true, nil +} + +// Delete deletes stored spec for given ServiceBindingUsage +func (c *BindingUsageSpecStorage) Delete(namespace, name string) error { + cfg, err := c.cfgMapClient.Get(c.cfgMapName, metaV1.GetOptions{}) + if err != nil { + return errors.Wrapf(err, "while getting config map with stored Spec for %s/%s", namespace, name) + } + + cfgCopy := cfg.DeepCopy() + cfgKey := c.specUsedByKey(namespace, name) + + delete(cfgCopy.Data, cfgKey) + + if _, err := c.cfgMapClient.Update(cfgCopy); err != nil { + return errors.Wrapf(err, "while updating config map %q", c.cfgMapName) + } + + return nil +} + +// Upsert upserts spec for given ServiceBindingUsage +func (c *BindingUsageSpecStorage) Upsert(bUsage *sbuTypes.ServiceBindingUsage, applied bool) error { + cfg, err := c.cfgMapClient.Get(c.cfgMapName, metaV1.GetOptions{}) + if err != nil { + return errors.Wrapf(err, "while getting config map with stored Spec for %s", pretty.ServiceBindingUsageName(bUsage)) + } + + storedSpec := UsageSpec{ + UsedBy: bUsage.Spec.UsedBy, + Applied: applied, + } + marshaledSpec, err := json.Marshal(storedSpec) + if err != nil { + return errors.Wrapf(err, "while marshaling Spec.UsedBy from %s", pretty.ServiceBindingUsageName(bUsage)) + } + + cfgCopy := cfg.DeepCopy() + cfgKey := c.specUsedByKey(bUsage.Namespace, bUsage.Name) + + cfgCopy.Data = EnsureMapIsInitiated(cfgCopy.Data) + cfgCopy.Data[cfgKey] = string(marshaledSpec) + + if _, err := c.cfgMapClient.Update(cfgCopy); err != nil { + return errors.Wrapf(err, "while updating config map %q", c.cfgMapName) + } + + return nil +} + +func (c *BindingUsageSpecStorage) specUsedByKey(namespace, name string) string { + return fmt.Sprintf("%s.%s.spec.usedBy", namespace, name) +} + +// UsageSpec represents DTO which is used to store information about applied sbu in config map +type UsageSpec struct { + UsedBy sbuTypes.LocalReferenceByKindAndName + Applied bool +} diff --git a/components/binding-usage-controller/internal/controller/sbu_spec_storage_test.go b/components/binding-usage-controller/internal/controller/sbu_spec_storage_test.go new file mode 100644 index 000000000000..effd046e0076 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/sbu_spec_storage_test.go @@ -0,0 +1,348 @@ +package controller_test + +import ( + "fmt" + "testing" + + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller" + sbuTypes "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" + k8sTesting "k8s.io/client-go/testing" +) + +func TestBindingUsageSpecStorageGetSuccess(t *testing.T) { + // given + var ( + fixSBUSpec = fixUsageSpec() + fixMarshaledSBUSpec = mustMarshal(fixSBUSpec) + + fixUsageName = "test-usage" + fixUsageNamespace = "test-usage-ns" + fixUsageSpecCfgMapKey = fmt.Sprintf("%s.%s.spec.usedBy", fixUsageNamespace, fixUsageName) + cfgData = map[string]string{fixUsageSpecCfgMapKey: fixMarshaledSBUSpec} + fixCfgMap = fixConfigMap(cfgData) + + k8sCli = fake.NewSimpleClientset(fixCfgMap) + cfgMapClient = k8sCli.CoreV1().ConfigMaps(fixCfgMap.Namespace) + ) + + specStorage := controller.NewBindingUsageSpecStorage( + cfgMapClient, + fixCfgMap.Name) + + // when + storedSpec, found, err := specStorage.Get(fixUsageNamespace, fixUsageName) + + // then + require.NoError(t, err) + + assert.True(t, found) + assert.Equal(t, fixSBUSpec, storedSpec) +} + +func TestBindingUsageSpecStorageGetFailure(t *testing.T) { + t.Run("given usage was not found in config map", func(t *testing.T) { + // given + var ( + fixUsageName = "test-usage" + fixUsageNamespace = "test-usage-ns" + emptyCfgData = map[string]string{} + fixCfgMap = fixConfigMap(emptyCfgData) + + k8sCli = fake.NewSimpleClientset(fixCfgMap) + cfgMapClient = k8sCli.CoreV1().ConfigMaps(fixCfgMap.Namespace) + ) + + specStorage := controller.NewBindingUsageSpecStorage( + cfgMapClient, + fixCfgMap.Name) + + // when + storedSpec, found, err := specStorage.Get(fixUsageNamespace, fixUsageName) + + // then + require.NoError(t, err) + + assert.False(t, found) + assert.Nil(t, storedSpec) + }) + + t.Run("config map does not exists", func(t *testing.T) { + // given + var ( + fixUsageName = "test-usage" + fixUsageNamespace = "test-usage-ns" + + k8sCli = fake.NewSimpleClientset() + cfgMapClient = k8sCli.CoreV1().ConfigMaps("system") + ) + + specStorage := controller.NewBindingUsageSpecStorage( + cfgMapClient, + "not-existing-cm") + + // when + storedSpec, found, err := specStorage.Get(fixUsageNamespace, fixUsageName) + + // then + assertErrorContainsStatement(t, err, `configmaps "not-existing-cm" not found`) + + assert.False(t, found) + assert.Nil(t, storedSpec) + }) + + t.Run("malformed data in config map", func(t *testing.T) { + // given + var ( + fixMarshaledSBUSpec = `wrong config data for given usage` + + fixUsageName = "test-usage" + fixUsageNamespace = "test-usage-ns" + fixUsageSpecCfgMapKey = fmt.Sprintf("%s.%s.spec.usedBy", fixUsageNamespace, fixUsageName) + cfgData = map[string]string{fixUsageSpecCfgMapKey: fixMarshaledSBUSpec} + fixCfgMap = fixConfigMap(cfgData) + + k8sCli = fake.NewSimpleClientset(fixCfgMap) + cfgMapClient = k8sCli.CoreV1().ConfigMaps(fixCfgMap.Namespace) + ) + + specStorage := controller.NewBindingUsageSpecStorage( + cfgMapClient, + fixCfgMap.Name) + + // when + storedSpec, found, err := specStorage.Get(fixUsageNamespace, fixUsageName) + + // then + assertErrorContainsStatement(t, err, "while unmarshalling spec") + + assert.False(t, found) + assert.Nil(t, storedSpec) + }) +} + +func TestBindingUsageSpecStorageDeleteSuccess(t *testing.T) { + // given + fixSBUSpec := fixUsageSpec() + fixMarshaledSBUSpec := mustMarshal(fixSBUSpec) + + fixUsageName := "test-usage" + fixUsageNamespace := "test-usage-ns" + fixUsageSpecCfgMapKey := fmt.Sprintf("%s.%s.spec.usedBy", fixUsageNamespace, fixUsageName) + + fixCfgData := map[string]string{ + fixUsageSpecCfgMapKey: fixMarshaledSBUSpec, + "another-entry": "another-entry-value", + } + expCfgData := map[string]string{ + "another-entry": "another-entry-value", + } + + fixCfgMap := fixConfigMap(fixCfgData) + expCfgMap := fixConfigMap(expCfgData) + + k8sCli := fake.NewSimpleClientset(fixCfgMap) + cfgMapClient := k8sCli.CoreV1().ConfigMaps(fixCfgMap.Namespace) + + specStorage := controller.NewBindingUsageSpecStorage( + cfgMapClient, + fixCfgMap.Name) + + // when + err := specStorage.Delete(fixUsageNamespace, fixUsageName) + + // then + require.NoError(t, err) + + performedActions := k8sCli.Actions() + require.Len(t, performedActions, 2) + + checkAction(t, getConfigMapAction(fixCfgMap), performedActions[0]) + checkAction(t, updateConfigMapAction(expCfgMap), performedActions[1]) +} + +func TestBindingUsageSpecStorageDeleteFailure(t *testing.T) { + t.Run("config map does not exists", func(t *testing.T) { + // given + var ( + fixUsageName = "test-usage" + fixUsageNamespace = "test-usage-ns" + + k8sCli = fake.NewSimpleClientset() + cfgMapClient = k8sCli.CoreV1().ConfigMaps("system") + ) + + specStorage := controller.NewBindingUsageSpecStorage( + cfgMapClient, + "not-existing-cm") + + // when + err := specStorage.Delete(fixUsageNamespace, fixUsageName) + + // then + assertErrorContainsStatement(t, err, `configmaps "not-existing-cm" not found`) + }) + + t.Run("entry deletion failed because error occurred on ConfigMap update", func(t *testing.T) { + // given + fixSBUSpec := fixUsageSpec() + fixMarshaledSBUSpec := mustMarshal(fixSBUSpec) + + fixUsageName := "test-usage" + fixUsageNamespace := "test-usage-ns" + fixUsageSpecCfgMapKey := fmt.Sprintf("%s.%s.spec.usedBy", fixUsageNamespace, fixUsageName) + + fixCfgData := map[string]string{ + fixUsageSpecCfgMapKey: fixMarshaledSBUSpec, + "another-entry": "another-entry-value", + } + + fixCfgMap := fixConfigMap(fixCfgData) + + k8sCli := fake.NewSimpleClientset(fixCfgMap) + k8sCli.PrependReactor(failOnUpdateConfigMap()) + + cfgMapClient := k8sCli.CoreV1().ConfigMaps(fixCfgMap.Namespace) + + specStorage := controller.NewBindingUsageSpecStorage( + cfgMapClient, + fixCfgMap.Name) + + // when + err := specStorage.Delete(fixUsageNamespace, fixUsageName) + + // then + assertErrorContainsStatement(t, err, "while updating config map") + }) +} + +func TestBindingUsageSpecStorageUpsertSuccess(t *testing.T) { + tests := map[string]struct { + givenCfgData map[string]string + expCfgData map[string]string + }{ + "update existing entry": { + givenCfgData: map[string]string{ + "another-entry": "another-entry-value", + fixUsageSpecCfgMapKey(): `old-spec`, + }, + expCfgData: map[string]string{ + "another-entry": "another-entry-value", + fixUsageSpecCfgMapKey(): mustMarshal(fixUsageSpec()), + }, + }, + "add new entry": { + givenCfgData: map[string]string{ + "another-entry": "another-entry-value", + }, + expCfgData: map[string]string{ + "another-entry": "another-entry-value", + fixUsageSpecCfgMapKey(): mustMarshal(fixUsageSpec()), + }, + }, + } + + for tn, tc := range tests { + t.Run(tn, func(t *testing.T) { + // given + fixCfgMap := fixConfigMap(tc.givenCfgData) + expCfgMap := fixConfigMap(tc.expCfgData) + + k8sCli := fake.NewSimpleClientset(fixCfgMap) + cfgMapClient := k8sCli.CoreV1().ConfigMaps(fixCfgMap.Namespace) + + specStorage := controller.NewBindingUsageSpecStorage( + cfgMapClient, + fixCfgMap.Name) + + // when + err := specStorage.Upsert(fixServiceBindingUsage(), fixUsageSpec().Applied) + + // then + require.NoError(t, err) + + performedActions := k8sCli.Actions() + require.Len(t, performedActions, 2) + + checkAction(t, getConfigMapAction(fixCfgMap), performedActions[0]) + checkAction(t, updateConfigMapAction(expCfgMap), performedActions[1]) + }) + } +} + +func TestBindingUsageSpecStorageUpsertFailure(t *testing.T) { + t.Run("config map does not exists", func(t *testing.T) { + // given + k8sCli := fake.NewSimpleClientset() + cfgMapClient := k8sCli.CoreV1().ConfigMaps("system") + + specStorage := controller.NewBindingUsageSpecStorage( + cfgMapClient, + "not-existing-cm") + + // when + err := specStorage.Upsert(fixServiceBindingUsage(), false) + + // then + assertErrorContainsStatement(t, err, `configmaps "not-existing-cm" not found`) + }) + + t.Run("entry insertion failed because error occurred on ConfigMap update", func(t *testing.T) { + // given + fixCfgData := map[string]string{ + "another-entry": "another-entry-value", + } + + fixCfgMap := fixConfigMap(fixCfgData) + + k8sCli := fake.NewSimpleClientset(fixCfgMap) + k8sCli.PrependReactor(failOnUpdateConfigMap()) + cfgMapClient := k8sCli.CoreV1().ConfigMaps(fixCfgMap.Namespace) + + specStorage := controller.NewBindingUsageSpecStorage( + cfgMapClient, + fixCfgMap.Name) + + // when + err := specStorage.Upsert(fixServiceBindingUsage(), false) + + // then + assertErrorContainsStatement(t, err, "while updating config map") + }) +} + +func failOnUpdateConfigMap() (string, string, k8sTesting.ReactionFunc) { + return "update", "configmaps", failingReactor +} + +func fixUsageSpecCfgMapKey() string { + return fmt.Sprintf("%s.%s.spec.usedBy", fixServiceBindingUsage().Namespace, fixServiceBindingUsage().Name) +} + +func fixUsageSpec() *controller.UsageSpec { + return &controller.UsageSpec{ + UsedBy: fixServiceBindingUsage().Spec.UsedBy, + Applied: true, + } +} + +func fixServiceBindingUsage() *sbuTypes.ServiceBindingUsage { + return &sbuTypes.ServiceBindingUsage{ + ObjectMeta: metaV1.ObjectMeta{ + Name: "fix-sbu", + Namespace: "fix-sub-ns", + }, + Spec: sbuTypes.ServiceBindingUsageSpec{ + UsedBy: sbuTypes.LocalReferenceByKindAndName{ + Kind: "Deployment", + Name: "used-by-name", + }, + ServiceBindingRef: sbuTypes.LocalReferenceByName{ + Name: "binding-ref-name", + }, + }, + } +} diff --git a/components/binding-usage-controller/internal/controller/sbu_tracer.go b/components/binding-usage-controller/internal/controller/sbu_tracer.go new file mode 100644 index 000000000000..b3cd5fe8e465 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/sbu_tracer.go @@ -0,0 +1,115 @@ +package controller + +import ( + "encoding/json" + + "github.com/pkg/errors" + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + tracingAnnotationKey = "servicebindingusages.servicecatalog.kyma.cx/tracing-information" +) + +//go:generate mockery -name=usageBindingAnnotationTracer -output=automock -outpkg=automock -case=underscore + +type usageBindingAnnotationTracer interface { + GetInjectedLabels(objMeta metaV1.ObjectMeta, usageName string) (map[string]string, error) + DeleteAnnotationAboutBindingUsage(objMeta *metaV1.ObjectMeta, usageName string) error + SetAnnotationAboutBindingUsage(objMeta *metaV1.ObjectMeta, usageName string, labels map[string]string) error +} + +// usageAnnotationTracer adds information in Kubernetes resources that they have been modified +// by given ServiceBindingUsage +type usageAnnotationTracer struct{} + +// GetInjectedLabels returns all labels that have been added to given resources +func (c *usageAnnotationTracer) GetInjectedLabels(objMeta metaV1.ObjectMeta, usageName string) (map[string]string, error) { + if objMeta.Annotations == nil { + return map[string]string{}, nil + } + + data, found, err := c.readAnnotationData(&objMeta) + if err != nil { + return map[string]string{}, errors.Wrapf(err, "while reading binding usage annotation tracing data") + } + if !found { + return map[string]string{}, nil + } + + info, found := data[usageName] + if !found { + return map[string]string{}, nil + } + + return info.InjectedLabelKeys, nil +} + +func (c *usageAnnotationTracer) DeleteAnnotationAboutBindingUsage(objMeta *metaV1.ObjectMeta, usageName string) error { + data, found, err := c.readAnnotationData(objMeta) + if err != nil { + return errors.Wrap(err, "while reading annotation tracing data") + } + if !found { + return nil + } + delete(data, usageName) + err = c.saveAnnotationData(objMeta, data) + if err != nil { + return errors.Wrap(err, "while saving annotation tracing data") + } + return nil +} + +// SetAnnotationAboutBindingUsage sets annotations about injected labels keys +func (c *usageAnnotationTracer) SetAnnotationAboutBindingUsage(objMeta *metaV1.ObjectMeta, usageName string, labels map[string]string) error { + + data, _, err := c.readAnnotationData(objMeta) + if err != nil { + return errors.Wrap(err, "while reading annotation tracing data") + } + + info := data[usageName] + info.InjectedLabelKeys = labels + data[usageName] = info + + err = c.saveAnnotationData(objMeta, data) + if err != nil { + return errors.Wrap(err, "while saving annotation tracing data") + } + return nil +} + +// sbuTracingInfo represents stored (in annotation) data about applied service binding usage +type sbuTracingInfo struct { + InjectedLabelKeys map[string]string `json:"injectedLabels"` +} + +func (c *usageAnnotationTracer) readAnnotationData(objMeta *metaV1.ObjectMeta) (map[string]sbuTracingInfo, bool, error) { + value, found := objMeta.Annotations[tracingAnnotationKey] + if !found { + return map[string]sbuTracingInfo{}, false, nil + } + + var data map[string]sbuTracingInfo + err := json.Unmarshal([]byte(value), &data) + if err != nil { + return map[string]sbuTracingInfo{}, false, errors.Wrapf(err, "while unmarshalling annotation tracing data") + } + + return data, true, nil +} + +func (c *usageAnnotationTracer) saveAnnotationData(objMeta *metaV1.ObjectMeta, info map[string]sbuTracingInfo) error { + bytes, err := json.Marshal(info) + if err != nil { + return errors.Wrapf(err, "while marshalling annotation tracing data %+v for object (%s/%s)", info, objMeta.Namespace, objMeta.Name) + } + + objMeta.Annotations = EnsureMapIsInitiated(objMeta.Annotations) + objMeta.Annotations[tracingAnnotationKey] = string(bytes) + if len(info) == 0 { + delete(objMeta.Annotations, tracingAnnotationKey) + } + return nil +} diff --git a/components/binding-usage-controller/internal/controller/sbu_tracer_export_test.go b/components/binding-usage-controller/internal/controller/sbu_tracer_export_test.go new file mode 100644 index 000000000000..c18248f500c5 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/sbu_tracer_export_test.go @@ -0,0 +1,9 @@ +package controller + +const ( + TracingAnnotationKey = tracingAnnotationKey +) + +func NewUsageAnnotationTracer() *usageAnnotationTracer { + return &usageAnnotationTracer{} +} diff --git a/components/binding-usage-controller/internal/controller/sbu_tracer_test.go b/components/binding-usage-controller/internal/controller/sbu_tracer_test.go new file mode 100644 index 000000000000..46c978ba5d84 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/sbu_tracer_test.go @@ -0,0 +1,69 @@ +package controller_test + +import ( + "testing" + + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + fixUsageName = "test-usage" +) + +func TestUsageAnnotationTracerInjectedLabels(t *testing.T) { + // given + tracer := controller.NewUsageAnnotationTracer() + testedObjMeta := metaV1.ObjectMeta{} + + fixLabels := make(map[string]string) + + // when + tracer.SetAnnotationAboutBindingUsage(&testedObjMeta, fixUsageName, fixLabels) + + // then + got, err := tracer.GetInjectedLabels(testedObjMeta, fixUsageName) + require.NoError(t, err) + assert.Len(t, got, len(fixLabels)) + for k, v := range fixLabels { + gotValue, found := got[k] + assert.True(t, found) + assert.Equal(t, v, gotValue) + } + + // when + tracer.DeleteAnnotationAboutBindingUsage(&testedObjMeta, fixUsageName) + + // then + got, err = tracer.GetInjectedLabels(testedObjMeta, fixUsageName) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestUsageAnnotationTracerWithCorruptedAnnotation(t *testing.T) { + // given + tracer := controller.NewUsageAnnotationTracer() + testedObjMeta := metaV1.ObjectMeta{ + Annotations: map[string]string{ + controller.TracingAnnotationKey: "corrupted", + }, + } + fixLabels := map[string]string{ + "lab1": "val1", + } + + // when + gotKeys, err := tracer.GetInjectedLabels(testedObjMeta, fixUsageName) + + // then + assert.Empty(t, gotKeys) + assert.NotNil(t, err) + + // when + err = tracer.SetAnnotationAboutBindingUsage(&testedObjMeta, fixUsageName, fixLabels) + + // then + assert.Error(t, err) +} diff --git a/components/binding-usage-controller/internal/controller/status/usage.go b/components/binding-usage-controller/internal/controller/status/usage.go new file mode 100644 index 000000000000..8fded8a6c8d0 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/status/usage.go @@ -0,0 +1,87 @@ +package status + +import ( + sbuTypes "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + // -- Reasons which can occur during add reconciliation of ServiceBindingUsage + + // ServiceBindingGetErrorReason is added in an usage when we cannot get a given ServiceBinding + ServiceBindingGetErrorReason = "ServiceBindingGetError" + // ServiceBindingOngoingAsyncOptReason is added in an usage when given ServiceBinding has ongoing async operation + ServiceBindingOngoingAsyncOptReason = "ServiceBindingAsyncOperationInProgressError" + // ServiceBindingNotReadyReason id added in an usage when given ServiceBinding is not in ready state + ServiceBindingNotReadyReason = "ServiceBindingNotReadyError" + // PodPresetUpsertErrorReason is added in an usage when we cannot create a new PodPreset + PodPresetUpsertErrorReason = "PodPresetUpsertError" + // FetchBindingLabelsErrorReason is added in an usage when we cannot fetch labels from given ClusterServiceClass + FetchBindingLabelsErrorReason = "ServiceClassGetBindingLabelsError" + // ApplyLabelsConflictErrorReason is added in a usage when we cannot add labels to the given resource because they already exists + ApplyLabelsConflictErrorReason = "ApplyLabelsConflictError" + // EnsureLabelsAppliedErrorReason is added in a usage when we cannot add labels to the given resource, e.g. given resource does not exits + EnsureLabelsAppliedErrorReason = "EnsureLabelsAppliedError" + + // -- Reasons which can occur during delete reconciliation of ServiceBindingUsage + + // EnsureLabelsDeletedErrorReason is added in a usage when we cannot deleted labels from the given resource + EnsureLabelsDeletedErrorReason = "EnsureLabelsDeletedError" + // PodPresetDeleteErrorReason is added in an usage when we cannot delete a new PodPreset + PodPresetDeleteErrorReason = "PodPresetDeleteError" + // GetStoredSpecError is added in an usage when we cannot get stored spec for given ServiceBindingUsage + GetStoredSpecError = "GetStoredSBUSpecError" +) + +// TimeNowFn is used for getting time for a new condition. +// It's exported to allow client to mock it in tests. +var TimeNowFn = metaV1.Now + +// NewUsageCondition creates a new usage condition. +func NewUsageCondition(condType sbuTypes.ServiceBindingUsageConditionType, status sbuTypes.ConditionStatus, reason, message string) *sbuTypes.ServiceBindingUsageCondition { + return &sbuTypes.ServiceBindingUsageCondition{ + Type: condType, + Status: status, + LastUpdateTime: TimeNowFn(), + LastTransitionTime: TimeNowFn(), + Reason: reason, + Message: message, + } +} + +// GetUsageCondition returns the condition with the provided type or nil if not found. +func GetUsageCondition(status sbuTypes.ServiceBindingUsageStatus, condType sbuTypes.ServiceBindingUsageConditionType) *sbuTypes.ServiceBindingUsageCondition { + for i := range status.Conditions { + c := status.Conditions[i] + if c.Type == condType { + return &c + } + } + return nil +} + +// SetUsageCondition updates the usage to include the provided condition. +// If the condition that we are about to add already exists and has the same status then +// we are not going to update the LastTransitionTime. +func SetUsageCondition(status *sbuTypes.ServiceBindingUsageStatus, condition sbuTypes.ServiceBindingUsageCondition) { + currentCond := GetUsageCondition(*status, condition.Type) + + // Do not update lastTransitionTime if the status of the condition doesn't change + if currentCond != nil && currentCond.Status == condition.Status { + condition.LastTransitionTime = currentCond.LastTransitionTime + } + newConditions := filterOutCondition(status.Conditions, condition.Type) + status.Conditions = append(newConditions, condition) +} + +// filterOutCondition returns a new slice without conditions with the given type +func filterOutCondition(conditions []sbuTypes.ServiceBindingUsageCondition, condType sbuTypes.ServiceBindingUsageConditionType) []sbuTypes.ServiceBindingUsageCondition { + var newConditions []sbuTypes.ServiceBindingUsageCondition + for _, c := range conditions { + if c.Type == condType { + continue + } + newConditions = append(newConditions, c) + } + return newConditions +} diff --git a/components/binding-usage-controller/internal/controller/status/usage_test.go b/components/binding-usage-controller/internal/controller/status/usage_test.go new file mode 100644 index 000000000000..1bd9887f4852 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/status/usage_test.go @@ -0,0 +1,109 @@ +package status_test + +import ( + "testing" + "time" + + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller/status" + sbuTypes "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var ( + condReady = func() *sbuTypes.ServiceBindingUsageCondition { + return status.NewUsageCondition( + sbuTypes.ServiceBindingUsageReady, sbuTypes.ConditionTrue, + "", "") + } + + condNotReady = func() *sbuTypes.ServiceBindingUsageCondition { + return status.NewUsageCondition( + sbuTypes.ServiceBindingUsageReady, sbuTypes.ConditionTrue, + "NotSoAwesome", "Failed") + } + + statusWithoutCond = func() sbuTypes.ServiceBindingUsageStatus { + return sbuTypes.ServiceBindingUsageStatus{ + Conditions: []sbuTypes.ServiceBindingUsageCondition{}, + } + } + + statusWithCond = func(cond *sbuTypes.ServiceBindingUsageCondition) sbuTypes.ServiceBindingUsageStatus { + return sbuTypes.ServiceBindingUsageStatus{ + Conditions: []sbuTypes.ServiceBindingUsageCondition{*cond}, + } + } +) + +func TestGetUsageCondition(t *testing.T) { + tests := map[string]struct { + givenStatus sbuTypes.ServiceBindingUsageStatus + + condExpected bool + }{ + "condition exists": { + givenStatus: statusWithCond(condReady()), + + condExpected: true, + }, + "condition does not exist": { + givenStatus: statusWithoutCond(), + + condExpected: false, + }, + } + + for tn, tc := range tests { + t.Run(tn, func(t *testing.T) { + // when + cond := status.GetUsageCondition(tc.givenStatus, sbuTypes.ServiceBindingUsageReady) + + // then + exists := cond != nil + assert.Equal(t, tc.condExpected, exists) + }) + } +} + +func TestSetUsageConditionSetCondForTheFirstTime(t *testing.T) { + // given + var ( + usageStatus = statusWithoutCond() + givenCond = condReady() + expectedStatus = statusWithCond(givenCond) + ) + // when + status.SetUsageCondition(&usageStatus, *givenCond) + + // then + assert.Equal(t, usageStatus, expectedStatus) +} + +func TestSetUsageConditionSetCondWhichAlreadyExistsInStatus(t *testing.T) { + // given + fixCond := condNotReady() + usageStatus := statusWithCond(fixCond) + + newCond := fixCond.DeepCopy() + newCond.Message = "Still failing" + newCond.LastUpdateTime = metaV1.NewTime(fixCond.LastUpdateTime.Add(time.Hour)) + newCond.LastTransitionTime = metaV1.NewTime(fixCond.LastTransitionTime.Add(2 * time.Hour)) + + // when + status.SetUsageCondition(&usageStatus, *newCond) + + // then + require.Len(t, usageStatus.Conditions, 1) + + assert.Equal(t, newCond.Status, usageStatus.Conditions[0].Status) + assert.Equal(t, newCond.Reason, usageStatus.Conditions[0].Reason) + assert.Equal(t, newCond.Message, usageStatus.Conditions[0].Message) + assert.Equal(t, newCond.LastUpdateTime, usageStatus.Conditions[0].LastUpdateTime) + + // LastTransitionTime should not be update by set method, when cond already exists and has the same status + assert.NotEqual(t, newCond.LastTransitionTime, usageStatus.Conditions[0].LastTransitionTime) + assert.Equal(t, fixCond.LastTransitionTime, usageStatus.Conditions[0].LastTransitionTime) + +} diff --git a/components/binding-usage-controller/internal/controller/supervisor_aggregator.go b/components/binding-usage-controller/internal/controller/supervisor_aggregator.go new file mode 100644 index 000000000000..6d3a42e4ae55 --- /dev/null +++ b/components/binding-usage-controller/internal/controller/supervisor_aggregator.go @@ -0,0 +1,67 @@ +package controller + +import "fmt" + +//go:generate mockery -name=KubernetesResourceSupervisor -output=automock -outpkg=automock -case=underscore + +// KubernetesResourceSupervisor validates if given Kubernetes resource can be modified by ServiceBindingUsage. If yes +// it can ensure that labels are present or deleted on previous validated resource. +type KubernetesResourceSupervisor interface { + HasSynced() bool + EnsureLabelsCreated(resourceNs, resourceName, usageName string, labels map[string]string) error + EnsureLabelsDeleted(resourceNs, resourceName, usageName string) error + GetInjectedLabels(resourceNs, resourceName, usageName string) (map[string]string, error) +} + +// Kind represents Kubernetes Kind name +type Kind string + +const ( + // KindDeployment represents Deployment resource + KindDeployment Kind = "Deployment" + // KindKubelessFunction represents Kubeless Function resource + KindKubelessFunction Kind = "Function" +) + +// ResourceSupervisorAggregator aggregates all defined resources supervisors +type ResourceSupervisorAggregator struct { + registered map[Kind]KubernetesResourceSupervisor +} + +// NewResourceSupervisorAggregator returns new instance of ResourceSupervisorAggregator +func NewResourceSupervisorAggregator() *ResourceSupervisorAggregator { + return &ResourceSupervisorAggregator{ + registered: make(map[Kind]KubernetesResourceSupervisor), + } +} + +// Register adds new resource supervisor +func (f *ResourceSupervisorAggregator) Register(k Kind, supervisor KubernetesResourceSupervisor) error { + if _, exists := f.registered[k]; exists { + return fmt.Errorf("supervisor for kind %q is already registered", k) + } + + f.registered[k] = supervisor + return nil +} + +// HasSynced returns true if all registered supervisors are synced +func (f *ResourceSupervisorAggregator) HasSynced() bool { + for _, supervisor := range f.registered { + if !supervisor.HasSynced() { + return false + } + } + + return true +} + +// Get returns supervisor for given kind +func (f *ResourceSupervisorAggregator) Get(k Kind) (KubernetesResourceSupervisor, error) { + concreteSupervisor, exists := f.registered[k] + if !exists { + return nil, NewNotFoundError("supervisor for kind %s was not found", k) + } + + return concreteSupervisor, nil +} diff --git a/components/binding-usage-controller/internal/controller/supervisor_aggregator_test.go b/components/binding-usage-controller/internal/controller/supervisor_aggregator_test.go new file mode 100644 index 000000000000..af4f9c3b1d8f --- /dev/null +++ b/components/binding-usage-controller/internal/controller/supervisor_aggregator_test.go @@ -0,0 +1,121 @@ +package controller_test + +import ( + "testing" + + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller" + "github.com/kyma-project/kyma/components/binding-usage-controller/internal/controller/automock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResourceSupervisorAggregatorGetSuccess(t *testing.T) { + // given + registeredKinds := []controller.Kind{ + controller.KindKubelessFunction, + controller.KindDeployment, + } + + // setup mock for all kinds + mocks := make(map[controller.Kind]*automock.KubernetesResourceSupervisor) + for _, k := range registeredKinds { + m := &automock.KubernetesResourceSupervisor{} + m.ExpectOnHasSynced(true).Once() // this method will be executed to ensure that given mock was called + mocks[k] = m + } + + // register all mock + aggregator := controller.NewResourceSupervisorAggregator() + for kind, mock := range mocks { + aggregator.Register(kind, mock) + } + + for _, k := range registeredKinds { + // when + concrete, err := aggregator.Get(k) + + // then + require.NoError(t, err) + assert.True(t, concrete.HasSynced()) + mocks[k].AssertExpectations(t) + } +} + +func TestResourceSupervisorAggregatorHasSynced(t *testing.T) { + tests := map[string]struct { + givenMocks map[controller.Kind]*automock.KubernetesResourceSupervisor + expSynced bool + }{ + "all synced": { + givenMocks: func() map[controller.Kind]*automock.KubernetesResourceSupervisor { + registeredKinds := []controller.Kind{ + controller.KindKubelessFunction, + controller.KindDeployment, + } + mocks := make(map[controller.Kind]*automock.KubernetesResourceSupervisor) + for _, k := range registeredKinds { + m := &automock.KubernetesResourceSupervisor{} + m.ExpectOnHasSynced(true).Once() + mocks[k] = m + } + return mocks + }(), + expSynced: true, + }, + "one not synced": { + givenMocks: func() map[controller.Kind]*automock.KubernetesResourceSupervisor { + mocks := make(map[controller.Kind]*automock.KubernetesResourceSupervisor) + d := &automock.KubernetesResourceSupervisor{} + d.ExpectOnHasSynced(false).Once() + mocks[controller.KindDeployment] = d + + fn := &automock.KubernetesResourceSupervisor{} + fn.ExpectOnHasSynced(true).Once() + mocks[controller.KindKubelessFunction] = fn + return mocks + }(), + expSynced: false, + }, + "all not synced": { + givenMocks: func() map[controller.Kind]*automock.KubernetesResourceSupervisor { + registeredKinds := []controller.Kind{ + controller.KindKubelessFunction, + controller.KindDeployment, + } + mocks := make(map[controller.Kind]*automock.KubernetesResourceSupervisor) + for _, k := range registeredKinds { + m := &automock.KubernetesResourceSupervisor{} + m.ExpectOnHasSynced(false).Once() + mocks[k] = m + } + return mocks + }(), + expSynced: false, + }, + } + for tn, tc := range tests { + t.Run(tn, func(t *testing.T) { + // given + // register all mock + aggregator := controller.NewResourceSupervisorAggregator() + for kind, mock := range tc.givenMocks { + aggregator.Register(kind, mock) + } + + assert.Equal(t, tc.expSynced, aggregator.HasSynced()) + }) + } + +} + +func TestResourceSupervisorAggregatorGetNotFound(t *testing.T) { + // given - empty aggregator + aggregator := controller.NewResourceSupervisorAggregator() + + // when + concrete, err := aggregator.Get(controller.KindDeployment) + + // then + assert.True(t, controller.IsNotFoundError(err)) + assert.Nil(t, concrete) +} diff --git a/components/binding-usage-controller/internal/platform/logger/config.go b/components/binding-usage-controller/internal/platform/logger/config.go new file mode 100644 index 000000000000..eaf24490dab3 --- /dev/null +++ b/components/binding-usage-controller/internal/platform/logger/config.go @@ -0,0 +1,37 @@ +package logger + +import ( + "fmt" + + "github.com/sirupsen/logrus" +) + +type ( + // LogLevel is a config field type holding minimal log level. + // It's compatible with logrus level types. + LogLevel logrus.Level + + // Config is responsible for configuring logger. + Config struct { + // Level sets the minimal logging level + // values: debug, info (default), warn, warning, error, fatal, panic + Level LogLevel `envconfig:"default=info"` + + // BuildHash holds hash of the git commit from which binary was build. + BuildHash string `envconfig:"-"` + } +) + +// Unmarshal provides custom parsing of Log Level. +// Implements envconfig.Unmarshal interface. +func (m *LogLevel) Unmarshal(in string) error { + out, err := logrus.ParseLevel(in) + + if err != nil { + return fmt.Errorf("unable to unmarshal %s", in) + } + + *m = LogLevel(out) + + return nil +} diff --git a/components/binding-usage-controller/internal/platform/logger/doc.go b/components/binding-usage-controller/internal/platform/logger/doc.go new file mode 100644 index 000000000000..de2b9ff76124 --- /dev/null +++ b/components/binding-usage-controller/internal/platform/logger/doc.go @@ -0,0 +1,2 @@ +// Package logger is responsible for logging. +package logger diff --git a/components/binding-usage-controller/internal/platform/logger/logger.go b/components/binding-usage-controller/internal/platform/logger/logger.go new file mode 100644 index 000000000000..d22d808acbc3 --- /dev/null +++ b/components/binding-usage-controller/internal/platform/logger/logger.go @@ -0,0 +1,95 @@ +package logger + +//noinspection SpellCheckingInspection +import ( + "encoding/json" + "fmt" + "os" + + "github.com/sirupsen/logrus" +) + +const ( + timestampFormat = "2006-01-02T15:04:05.999Z" + fieldBuildHash = "buildHash" + + // FieldCtx is a key of logged context + FieldCtx = "ctx" +) + +// reservedFields defines all fields keys which cannot be used when +// we logging additional fields with YaaS formatter +var reservedFields = map[string]struct{}{ + "hop": {}, + "requestId": {}, + "time": {}, + "message": {}, + "requestOrg": {}, + "requestUser": {}, +} + +// Formatter is a log formatter +type Formatter struct{} + +// Format formats log entry to adhere to YaaS specs +func (f *Formatter) Format(entry *logrus.Entry) ([]byte, error) { + data := make(logrus.Fields, len(entry.Data)+3) + + for k, v := range entry.Data { + if shouldSkipThisField(k) { + continue + } + + switch v := v.(type) { + case error: + data[k] = v.Error() + default: + data[k] = v + } + } + + data["time"] = entry.Time.Format(timestampFormat) + data["message"] = entry.Message + + logEntry := map[string]interface{}{ + "log": data, + "level": entry.Level.String(), + } + + serialized, err := json.Marshal(logEntry) + if err != nil { + return nil, fmt.Errorf("Failed to marshal fields to JSON, %v", err) + } + return append(serialized, '\n'), nil +} + +func shouldSkipThisField(name string) bool { + // skips because is should be process by another function + if name == FieldCtx { + return true + } + + // skip because this fields are reserved + _, found := reservedFields[name] + return found +} + +// New creates new instance of logging apparatus. +// Logrus.Entry is returned as it's always decorated with fields. +func New(cfg *Config) *logrus.Entry { + + lgr := &logrus.Logger{ + Out: os.Stdout, + Formatter: new(Formatter), + Hooks: make(logrus.LevelHooks), + Level: logrus.Level(cfg.Level), + } + + fields := logrus.Fields{} + + if cfg.BuildHash != "" { + fields[fieldBuildHash] = cfg.BuildHash + } + + return lgr.WithFields(fields) +} diff --git a/components/binding-usage-controller/internal/platform/logger/logger_mock.go b/components/binding-usage-controller/internal/platform/logger/logger_mock.go new file mode 100644 index 000000000000..b2e4c12b1be2 --- /dev/null +++ b/components/binding-usage-controller/internal/platform/logger/logger_mock.go @@ -0,0 +1,34 @@ +package logger + +import ( + "time" + + "github.com/sirupsen/logrus" +) + +// THTimeForcedFormatter is a Logrus compatible formatter which wraps original formatter and forces specific time. +// Designed to be used in testing. +type THTimeForcedFormatter struct { + // OrigFormatter is an original formatter + OrigFormatter logrus.Formatter + + // Time is a time to be forces in entries. + Time time.Time +} + +// Format entry but forces time for testing purposes +func (f *THTimeForcedFormatter) Format(entry *logrus.Entry) ([]byte, error) { + data := make(logrus.Fields, len(entry.Data)) + for k, v := range entry.Data { + data[k] = v + } + entryModified := &logrus.Entry{ + Logger: entry.Logger, + Data: data, + Time: f.Time, + Level: entry.Level, + Message: entry.Message, + } + + return f.OrigFormatter.Format(entryModified) +} diff --git a/components/binding-usage-controller/internal/platform/logger/spy/formatter.go b/components/binding-usage-controller/internal/platform/logger/spy/formatter.go new file mode 100644 index 000000000000..14494b7c4913 --- /dev/null +++ b/components/binding-usage-controller/internal/platform/logger/spy/formatter.go @@ -0,0 +1,56 @@ +package spy + +import ( + "sync" + + "github.com/sirupsen/logrus" +) + +// EntryAssertFormatter is a log formatter, which gather all logged entries +type EntryAssertFormatter struct { + entries []logrus.Entry + Underlying logrus.Formatter + mu sync.RWMutex +} + +// Format appends each entry to entries slice +func (f *EntryAssertFormatter) Format(entry *logrus.Entry) ([]byte, error) { + f.appendThreadSafe(*entry) + return f.Underlying.Format(entry) +} + +// AnyMatches iterates over all stored entries and execute given matcher on it. +// Return true if any entries was successful matched +func (f *EntryAssertFormatter) AnyMatches(matcher func(entry logrus.Entry) bool) bool { + f.mu.RLock() + copyOfEntries := make([]logrus.Entry, len(f.entries)) + copy(copyOfEntries, f.entries) + f.mu.RUnlock() + for _, entry := range copyOfEntries { + if matcher(entry) { + return true + } + } + return false +} + +// AllEntriesMatches iterates over all stored entries and execute given matcher on it. +// Return true only if all entries was successful matched +func (f *EntryAssertFormatter) AllEntriesMatches(matcher func(entry logrus.Entry) bool) bool { + f.mu.RLock() + copyOfEntries := make([]logrus.Entry, len(f.entries)) + copy(copyOfEntries, f.entries) + f.mu.RUnlock() + for _, entry := range copyOfEntries { + if !matcher(entry) { + return false + } + } + return true +} + +func (f *EntryAssertFormatter) appendThreadSafe(entry logrus.Entry) { + f.mu.Lock() + defer f.mu.Unlock() + f.entries = append(f.entries, entry) +} diff --git a/components/binding-usage-controller/internal/platform/logger/spy/logger.go b/components/binding-usage-controller/internal/platform/logger/spy/logger.go new file mode 100644 index 000000000000..45ed1f468737 --- /dev/null +++ b/components/binding-usage-controller/internal/platform/logger/spy/logger.go @@ -0,0 +1,133 @@ +// Package spy provides an implementation of go-sdk.logger that helps test logging. +package spy + +import ( + "bufio" + "bytes" + "encoding/json" + "io/ioutil" + "strings" + "testing" + + "github.com/sirupsen/logrus" +) + +// LogSink is a helper construct for testing logging in unit tests. +// Beware: all methods are working on copies of of original messages buffer and are safe for multiple uses. +type LogSink struct { + buffer *bytes.Buffer + RawLogger *logrus.Logger + Logger *logrus.Entry +} + +// NewLogSink is a factory for LogSink +func NewLogSink() *LogSink { + buffer := bytes.NewBuffer([]byte("")) + + rawLgr := &logrus.Logger{ + Out: buffer, + // standard json formatter is used to ease testing + Formatter: new(logrus.JSONFormatter), + Hooks: make(logrus.LevelHooks), + Level: logrus.DebugLevel, + } + + lgr := rawLgr.WithField("testing", true) + + return &LogSink{ + buffer: buffer, + RawLogger: rawLgr, + Logger: lgr, + } +} + +// AssertErrorLogged checks whatever a specific string was logged as error. +// +// Compared elements: level, message +// +// Wrapped errors are supported as long as original error message ends up in resulting one. +func (s *LogSink) AssertErrorLogged(t *testing.T, errorExpected error) { + if !s.wasLogged(t, logrus.ErrorLevel, errorExpected.Error()) { + t.Errorf("error was not logged, expected: \"%s\"", errorExpected.Error()) + } +} + +// AssertLogged checks whatever a specific string was logged at a specific level. +// +// Compared elements: level, message +// +// Beware: we are checking for sub-strings and not for the exact match. +func (s *LogSink) AssertLogged(t *testing.T, level logrus.Level, message string) { + if !s.wasLogged(t, level, message) { + t.Errorf("message was not logged, message: \"%s\", level: %s", message, level) + } +} + +// AssertNotLogged checks whatever a specific string was not logged at a specific level. +// +// Compared elements: level, message +// +// Beware: we are checking for sub-strings and not for the exact match. +func (s *LogSink) AssertNotLogged(t *testing.T, level logrus.Level, message string) { + if s.wasLogged(t, level, message) { + t.Errorf("message was logged, message: \"%s\", level: %s", message, level) + } +} + +// wasLogged checks whatever a message was logged. +// +// Compared elements: level, message +func (s *LogSink) wasLogged(t *testing.T, level logrus.Level, message string) bool { + // new reader is created so we are safe for multiple reads + buf := bytes.NewReader(s.buffer.Bytes()) + scanner := bufio.NewScanner(buf) + var entryPartial struct { + Level string `json:"level"` + Msg string `json:"msg"` + } + + for scanner.Scan() { + line := scanner.Text() + + err := json.Unmarshal([]byte(line), &entryPartial) + if err != nil { + t.Fatalf("unexpected error on log line unmarshalling, line: %s", line) + } + + levelMatches := entryPartial.Level == level.String() + + // We are looking only if expected is contained (as opposed to exact match check), + // so that e.g. errors wrapping is supported. + containsMessage := strings.Contains(entryPartial.Msg, message) + + if levelMatches && containsMessage { + return true + } + } + + return false +} + +// DumpAll returns all logged messages. +func (s *LogSink) DumpAll() []string { + // new reader is created so we are safe for multiple reads + buf := bytes.NewReader(s.buffer.Bytes()) + scanner := bufio.NewScanner(buf) + + out := []string{} + for scanner.Scan() { + out = append(out, scanner.Text()) + } + + return out +} + +// NewLogDummy returns dummy logger which discards logged messages on the fly. +// Useful when logger is required as dependency in unit testing. +func NewLogDummy() *logrus.Entry { + rawLgr := logrus.New() + rawLgr.Out = ioutil.Discard + lgr := rawLgr.WithField("testing", true) + + return lgr +} diff --git a/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1/doc.go b/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1/doc.go new file mode 100644 index 000000000000..20f8dd3c5348 --- /dev/null +++ b/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1/doc.go @@ -0,0 +1,4 @@ +// +k8s:deepcopy-gen=package,register + +// +groupName=servicecatalog.kyma.cx +package v1alpha1 diff --git a/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1/register.go b/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1/register.go new file mode 100644 index 000000000000..15abd8e0e716 --- /dev/null +++ b/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1/register.go @@ -0,0 +1,39 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// SchemeGroupVersion is group version used to register these objects +var SchemeGroupVersion = schema.GroupVersion{Group: "servicecatalog.kyma.cx", Version: "v1alpha1"} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} + +var ( + // localSchemeBuilder and AddToScheme will stay in k8s.io/kubernetes. + SchemeBuilder runtime.SchemeBuilder + localSchemeBuilder = &SchemeBuilder + AddToScheme = localSchemeBuilder.AddToScheme +) + +func init() { + // We only register manually written functions here. The registration of the + // generated functions takes place in the generated files. The separation + // makes the code compile even when the generated files are missing. + localSchemeBuilder.Register(addKnownTypes) +} + +// Adds the list of known types to api.Scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &ServiceBindingUsage{}, + &ServiceBindingUsageList{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1/types.go b/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1/types.go new file mode 100644 index 000000000000..6be5ed50e250 --- /dev/null +++ b/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1/types.go @@ -0,0 +1,114 @@ +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +genclient +// +genclient:noStatus +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +type ServiceBindingUsage struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` + Spec ServiceBindingUsageSpec `json:"spec"` + Status ServiceBindingUsageStatus `json:"status"` +} + +func (pw *ServiceBindingUsage) GetObjectKind() schema.ObjectKind { + return &ServiceBindingUsage{} +} + +// Status represents the current and past information about binding usage +type ServiceBindingUsageStatus struct { + // Conditions represents the observations of a ServiceBindingUsage's state. + Conditions []ServiceBindingUsageCondition `json:"conditions,omitempty"` +} + +// ServiceBindingUsageCondition describes the state of a ServiceBindingUsage at a certain point. +type ServiceBindingUsageCondition struct { + // Type is the type of the condition. + Type ServiceBindingUsageConditionType `json:"type"` + // Status is the status of the condition. + Status ConditionStatus `json:"status"` + // The last time this condition was updated. + // LastUpdateTime will be updated even when the status of the condition is not changed + LastUpdateTime metav1.Time `json:"lastUpdateTime,omitempty"` + // Last time the condition transitioned from one status to another. + LastTransitionTime metav1.Time `json:"lastTransitionTime,omitempty"` + // Unique, one-word, CamelCase reason for the condition's last transition. + Reason string `json:"reason,omitempty"` + // Human-readable message indicating details about last transition. + Message string `json:"message,omitempty"` +} + +// ServiceBindingUsageConditionType represents a usage condition value. +type ServiceBindingUsageConditionType string + +const ( + // ServiceBindingUsageReady represents the fact that a given usage is in ready state. + ServiceBindingUsageReady ServiceBindingUsageConditionType = "Ready" +) + +// ConditionStatus represents a condition's status. +type ConditionStatus string + +const ( + // ConditionTrue means a resource is in the condition. + ConditionTrue ConditionStatus = "True" + // ConditionFalse means a resource is not in the condition + ConditionFalse ConditionStatus = "False" + // ConditionUnknown means controller can't decide if a usage is in the condition or not. + ConditionUnknown ConditionStatus = "Unknown" +) + +// ServiceBindingUsageSpec represents a description of the ServiceBindingUsage +type ServiceBindingUsageSpec struct { + // ServiceBindingRef is the reference to the ServiceBinding and + // need to be in the same namespace where ServiceBindingUsage was created. + ServiceBindingRef LocalReferenceByName `json:"serviceBindingRef"` + // UsedBy is the reference to the application which should be configured to use ServiceInstance pointed by serviceBindingRef. + // Pointed resource should be available in the same namespace where ServiceBindingUsage was created. + UsedBy LocalReferenceByKindAndName `json:"usedBy"` + // Parameters is a set of the parameters passed to the controller + Parameters *Parameters `json:"parameters,omitempty"` +} + +// LocalReferenceByName contains enough information to let you locate the +// referenced object inside the same namespace. +type LocalReferenceByName struct { + // Name of the referent. + Name string `json:"name"` +} + +// Parameters contain all parameters which are used by controller +type Parameters struct { + // EnvPrefix defines the prefix for environment variables injected from ServiceBinding + EnvPrefix *EnvPrefix `json:"envPrefix,omitempty"` +} + +// EnvPrefix defines the prefixing of environment variables +type EnvPrefix struct { + // Name of the prefix + Name string `json:"name"` +} + +// LocalReferenceByKindAndName contains enough information to let you locate the +// referenced to generic object inside the same namespace. +type LocalReferenceByKindAndName struct { + // Name of the referent + Name string `json:"name"` + // Kind of the referent + Kind string `json:"kind"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +type ServiceBindingUsageList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + Items []ServiceBindingUsage `json:"items"` +} diff --git a/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1/zz_generated.deepcopy.go b/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 000000000000..d7819c4d63ea --- /dev/null +++ b/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,211 @@ +// +build !ignore_autogenerated + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + 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 *EnvPrefix) DeepCopyInto(out *EnvPrefix) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EnvPrefix. +func (in *EnvPrefix) DeepCopy() *EnvPrefix { + if in == nil { + return nil + } + out := new(EnvPrefix) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LocalReferenceByKindAndName) DeepCopyInto(out *LocalReferenceByKindAndName) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalReferenceByKindAndName. +func (in *LocalReferenceByKindAndName) DeepCopy() *LocalReferenceByKindAndName { + if in == nil { + return nil + } + out := new(LocalReferenceByKindAndName) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LocalReferenceByName) DeepCopyInto(out *LocalReferenceByName) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalReferenceByName. +func (in *LocalReferenceByName) DeepCopy() *LocalReferenceByName { + if in == nil { + return nil + } + out := new(LocalReferenceByName) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Parameters) DeepCopyInto(out *Parameters) { + *out = *in + if in.EnvPrefix != nil { + in, out := &in.EnvPrefix, &out.EnvPrefix + if *in == nil { + *out = nil + } else { + *out = new(EnvPrefix) + **out = **in + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Parameters. +func (in *Parameters) DeepCopy() *Parameters { + if in == nil { + return nil + } + out := new(Parameters) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceBindingUsage) DeepCopyInto(out *ServiceBindingUsage) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceBindingUsage. +func (in *ServiceBindingUsage) DeepCopy() *ServiceBindingUsage { + if in == nil { + return nil + } + out := new(ServiceBindingUsage) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServiceBindingUsage) 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 *ServiceBindingUsageCondition) DeepCopyInto(out *ServiceBindingUsageCondition) { + *out = *in + in.LastUpdateTime.DeepCopyInto(&out.LastUpdateTime) + in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceBindingUsageCondition. +func (in *ServiceBindingUsageCondition) DeepCopy() *ServiceBindingUsageCondition { + if in == nil { + return nil + } + out := new(ServiceBindingUsageCondition) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceBindingUsageList) DeepCopyInto(out *ServiceBindingUsageList) { + *out = *in + out.TypeMeta = in.TypeMeta + out.ListMeta = in.ListMeta + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ServiceBindingUsage, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceBindingUsageList. +func (in *ServiceBindingUsageList) DeepCopy() *ServiceBindingUsageList { + if in == nil { + return nil + } + out := new(ServiceBindingUsageList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServiceBindingUsageList) 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 *ServiceBindingUsageSpec) DeepCopyInto(out *ServiceBindingUsageSpec) { + *out = *in + out.ServiceBindingRef = in.ServiceBindingRef + out.UsedBy = in.UsedBy + if in.Parameters != nil { + in, out := &in.Parameters, &out.Parameters + if *in == nil { + *out = nil + } else { + *out = new(Parameters) + (*in).DeepCopyInto(*out) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceBindingUsageSpec. +func (in *ServiceBindingUsageSpec) DeepCopy() *ServiceBindingUsageSpec { + if in == nil { + return nil + } + out := new(ServiceBindingUsageSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceBindingUsageStatus) DeepCopyInto(out *ServiceBindingUsageStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]ServiceBindingUsageCondition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceBindingUsageStatus. +func (in *ServiceBindingUsageStatus) DeepCopy() *ServiceBindingUsageStatus { + if in == nil { + return nil + } + out := new(ServiceBindingUsageStatus) + in.DeepCopyInto(out) + return out +} diff --git a/components/binding-usage-controller/pkg/client/clientset/versioned/clientset.go b/components/binding-usage-controller/pkg/client/clientset/versioned/clientset.go new file mode 100644 index 000000000000..a9231c3cf48b --- /dev/null +++ b/components/binding-usage-controller/pkg/client/clientset/versioned/clientset.go @@ -0,0 +1,84 @@ +// Code generated by client-gen. DO NOT EDIT. + +package versioned + +import ( + glog "github.com/golang/glog" + servicecatalogv1alpha1 "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1" + discovery "k8s.io/client-go/discovery" + rest "k8s.io/client-go/rest" + flowcontrol "k8s.io/client-go/util/flowcontrol" +) + +type Interface interface { + Discovery() discovery.DiscoveryInterface + ServicecatalogV1alpha1() servicecatalogv1alpha1.ServicecatalogV1alpha1Interface + // Deprecated: please explicitly pick a version if possible. + Servicecatalog() servicecatalogv1alpha1.ServicecatalogV1alpha1Interface +} + +// Clientset contains the clients for groups. Each group has exactly one +// version included in a Clientset. +type Clientset struct { + *discovery.DiscoveryClient + servicecatalogV1alpha1 *servicecatalogv1alpha1.ServicecatalogV1alpha1Client +} + +// ServicecatalogV1alpha1 retrieves the ServicecatalogV1alpha1Client +func (c *Clientset) ServicecatalogV1alpha1() servicecatalogv1alpha1.ServicecatalogV1alpha1Interface { + return c.servicecatalogV1alpha1 +} + +// Deprecated: Servicecatalog retrieves the default version of ServicecatalogClient. +// Please explicitly pick a version. +func (c *Clientset) Servicecatalog() servicecatalogv1alpha1.ServicecatalogV1alpha1Interface { + return c.servicecatalogV1alpha1 +} + +// Discovery retrieves the DiscoveryClient +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + if c == nil { + return nil + } + return c.DiscoveryClient +} + +// NewForConfig creates a new Clientset for the given config. +func NewForConfig(c *rest.Config) (*Clientset, error) { + configShallowCopy := *c + if configShallowCopy.RateLimiter == nil && configShallowCopy.QPS > 0 { + configShallowCopy.RateLimiter = flowcontrol.NewTokenBucketRateLimiter(configShallowCopy.QPS, configShallowCopy.Burst) + } + var cs Clientset + var err error + cs.servicecatalogV1alpha1, err = servicecatalogv1alpha1.NewForConfig(&configShallowCopy) + if err != nil { + return nil, err + } + + cs.DiscoveryClient, err = discovery.NewDiscoveryClientForConfig(&configShallowCopy) + if err != nil { + glog.Errorf("failed to create the DiscoveryClient: %v", err) + return nil, err + } + return &cs, nil +} + +// NewForConfigOrDie creates a new Clientset for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *Clientset { + var cs Clientset + cs.servicecatalogV1alpha1 = servicecatalogv1alpha1.NewForConfigOrDie(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClientForConfigOrDie(c) + return &cs +} + +// New creates a new Clientset for the given RESTClient. +func New(c rest.Interface) *Clientset { + var cs Clientset + cs.servicecatalogV1alpha1 = servicecatalogv1alpha1.New(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClient(c) + return &cs +} diff --git a/components/binding-usage-controller/pkg/client/clientset/versioned/doc.go b/components/binding-usage-controller/pkg/client/clientset/versioned/doc.go new file mode 100644 index 000000000000..0e0c2a8900e2 --- /dev/null +++ b/components/binding-usage-controller/pkg/client/clientset/versioned/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated clientset. +package versioned diff --git a/components/binding-usage-controller/pkg/client/clientset/versioned/fake/clientset_generated.go b/components/binding-usage-controller/pkg/client/clientset/versioned/fake/clientset_generated.go new file mode 100644 index 000000000000..c4b781b263dd --- /dev/null +++ b/components/binding-usage-controller/pkg/client/clientset/versioned/fake/clientset_generated.go @@ -0,0 +1,65 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + clientset "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/clientset/versioned" + servicecatalogv1alpha1 "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1" + fakeservicecatalogv1alpha1 "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/fake" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/discovery" + fakediscovery "k8s.io/client-go/discovery/fake" + "k8s.io/client-go/testing" +) + +// NewSimpleClientset returns a clientset that will respond with the provided objects. +// It's backed by a very simple object tracker that processes creates, updates and deletions as-is, +// without applying any validations and/or defaults. It shouldn't be considered a replacement +// for a real clientset and is mostly useful in simple unit tests. +func NewSimpleClientset(objects ...runtime.Object) *Clientset { + o := testing.NewObjectTracker(scheme, codecs.UniversalDecoder()) + for _, obj := range objects { + if err := o.Add(obj); err != nil { + panic(err) + } + } + + fakePtr := testing.Fake{} + fakePtr.AddReactor("*", "*", testing.ObjectReaction(o)) + fakePtr.AddWatchReactor("*", func(action testing.Action) (handled bool, ret watch.Interface, err error) { + gvr := action.GetResource() + ns := action.GetNamespace() + watch, err := o.Watch(gvr, ns) + if err != nil { + return false, nil, err + } + return true, watch, nil + }) + + return &Clientset{fakePtr, &fakediscovery.FakeDiscovery{Fake: &fakePtr}} +} + +// Clientset implements clientset.Interface. Meant to be embedded into a +// struct to get a default implementation. This makes faking out just the method +// you want to test easier. +type Clientset struct { + testing.Fake + discovery *fakediscovery.FakeDiscovery +} + +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + return c.discovery +} + +var _ clientset.Interface = &Clientset{} + +// ServicecatalogV1alpha1 retrieves the ServicecatalogV1alpha1Client +func (c *Clientset) ServicecatalogV1alpha1() servicecatalogv1alpha1.ServicecatalogV1alpha1Interface { + return &fakeservicecatalogv1alpha1.FakeServicecatalogV1alpha1{Fake: &c.Fake} +} + +// Servicecatalog retrieves the ServicecatalogV1alpha1Client +func (c *Clientset) Servicecatalog() servicecatalogv1alpha1.ServicecatalogV1alpha1Interface { + return &fakeservicecatalogv1alpha1.FakeServicecatalogV1alpha1{Fake: &c.Fake} +} diff --git a/components/binding-usage-controller/pkg/client/clientset/versioned/fake/doc.go b/components/binding-usage-controller/pkg/client/clientset/versioned/fake/doc.go new file mode 100644 index 000000000000..3630ed1cd17d --- /dev/null +++ b/components/binding-usage-controller/pkg/client/clientset/versioned/fake/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated fake clientset. +package fake diff --git a/components/binding-usage-controller/pkg/client/clientset/versioned/fake/register.go b/components/binding-usage-controller/pkg/client/clientset/versioned/fake/register.go new file mode 100644 index 000000000000..71853f25bd28 --- /dev/null +++ b/components/binding-usage-controller/pkg/client/clientset/versioned/fake/register.go @@ -0,0 +1,38 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + servicecatalogv1alpha1 "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var scheme = runtime.NewScheme() +var codecs = serializer.NewCodecFactory(scheme) +var parameterCodec = runtime.NewParameterCodec(scheme) + +func init() { + v1.AddToGroupVersion(scheme, schema.GroupVersion{Version: "v1"}) + AddToScheme(scheme) +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +func AddToScheme(scheme *runtime.Scheme) { + servicecatalogv1alpha1.AddToScheme(scheme) +} diff --git a/components/binding-usage-controller/pkg/client/clientset/versioned/scheme/doc.go b/components/binding-usage-controller/pkg/client/clientset/versioned/scheme/doc.go new file mode 100644 index 000000000000..14db57a58f8d --- /dev/null +++ b/components/binding-usage-controller/pkg/client/clientset/versioned/scheme/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package contains the scheme of the automatically generated clientset. +package scheme diff --git a/components/binding-usage-controller/pkg/client/clientset/versioned/scheme/register.go b/components/binding-usage-controller/pkg/client/clientset/versioned/scheme/register.go new file mode 100644 index 000000000000..b9cd95bb63c5 --- /dev/null +++ b/components/binding-usage-controller/pkg/client/clientset/versioned/scheme/register.go @@ -0,0 +1,38 @@ +// Code generated by client-gen. DO NOT EDIT. + +package scheme + +import ( + servicecatalogv1alpha1 "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var Scheme = runtime.NewScheme() +var Codecs = serializer.NewCodecFactory(Scheme) +var ParameterCodec = runtime.NewParameterCodec(Scheme) + +func init() { + v1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"}) + AddToScheme(Scheme) +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +func AddToScheme(scheme *runtime.Scheme) { + servicecatalogv1alpha1.AddToScheme(scheme) +} diff --git a/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/doc.go b/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/doc.go new file mode 100644 index 000000000000..93a7ca4e0e2b --- /dev/null +++ b/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated typed clients. +package v1alpha1 diff --git a/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/fake/doc.go b/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/fake/doc.go new file mode 100644 index 000000000000..2b5ba4c8e442 --- /dev/null +++ b/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/fake/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// Package fake has the automatically generated clients. +package fake diff --git a/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/fake/fake_servicebindingusage.go b/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/fake/fake_servicebindingusage.go new file mode 100644 index 000000000000..4f132cca9fcb --- /dev/null +++ b/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/fake/fake_servicebindingusage.go @@ -0,0 +1,112 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeServiceBindingUsages implements ServiceBindingUsageInterface +type FakeServiceBindingUsages struct { + Fake *FakeServicecatalogV1alpha1 + ns string +} + +var servicebindingusagesResource = schema.GroupVersionResource{Group: "servicecatalog.kyma.cx", Version: "v1alpha1", Resource: "servicebindingusages"} + +var servicebindingusagesKind = schema.GroupVersionKind{Group: "servicecatalog.kyma.cx", Version: "v1alpha1", Kind: "ServiceBindingUsage"} + +// Get takes name of the serviceBindingUsage, and returns the corresponding serviceBindingUsage object, and an error if there is any. +func (c *FakeServiceBindingUsages) Get(name string, options v1.GetOptions) (result *v1alpha1.ServiceBindingUsage, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(servicebindingusagesResource, c.ns, name), &v1alpha1.ServiceBindingUsage{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.ServiceBindingUsage), err +} + +// List takes label and field selectors, and returns the list of ServiceBindingUsages that match those selectors. +func (c *FakeServiceBindingUsages) List(opts v1.ListOptions) (result *v1alpha1.ServiceBindingUsageList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(servicebindingusagesResource, servicebindingusagesKind, c.ns, opts), &v1alpha1.ServiceBindingUsageList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha1.ServiceBindingUsageList{} + for _, item := range obj.(*v1alpha1.ServiceBindingUsageList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested serviceBindingUsages. +func (c *FakeServiceBindingUsages) Watch(opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(servicebindingusagesResource, c.ns, opts)) + +} + +// Create takes the representation of a serviceBindingUsage and creates it. Returns the server's representation of the serviceBindingUsage, and an error, if there is any. +func (c *FakeServiceBindingUsages) Create(serviceBindingUsage *v1alpha1.ServiceBindingUsage) (result *v1alpha1.ServiceBindingUsage, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(servicebindingusagesResource, c.ns, serviceBindingUsage), &v1alpha1.ServiceBindingUsage{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.ServiceBindingUsage), err +} + +// Update takes the representation of a serviceBindingUsage and updates it. Returns the server's representation of the serviceBindingUsage, and an error, if there is any. +func (c *FakeServiceBindingUsages) Update(serviceBindingUsage *v1alpha1.ServiceBindingUsage) (result *v1alpha1.ServiceBindingUsage, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(servicebindingusagesResource, c.ns, serviceBindingUsage), &v1alpha1.ServiceBindingUsage{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.ServiceBindingUsage), err +} + +// Delete takes name of the serviceBindingUsage and deletes it. Returns an error if one occurs. +func (c *FakeServiceBindingUsages) Delete(name string, options *v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(servicebindingusagesResource, c.ns, name), &v1alpha1.ServiceBindingUsage{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeServiceBindingUsages) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(servicebindingusagesResource, c.ns, listOptions) + + _, err := c.Fake.Invokes(action, &v1alpha1.ServiceBindingUsageList{}) + return err +} + +// Patch applies the patch and returns the patched serviceBindingUsage. +func (c *FakeServiceBindingUsages) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.ServiceBindingUsage, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(servicebindingusagesResource, c.ns, name, data, subresources...), &v1alpha1.ServiceBindingUsage{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.ServiceBindingUsage), err +} diff --git a/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/fake/fake_servicecatalog_client.go b/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/fake/fake_servicecatalog_client.go new file mode 100644 index 000000000000..4eb68701e9bd --- /dev/null +++ b/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/fake/fake_servicecatalog_client.go @@ -0,0 +1,24 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1" + rest "k8s.io/client-go/rest" + testing "k8s.io/client-go/testing" +) + +type FakeServicecatalogV1alpha1 struct { + *testing.Fake +} + +func (c *FakeServicecatalogV1alpha1) ServiceBindingUsages(namespace string) v1alpha1.ServiceBindingUsageInterface { + return &FakeServiceBindingUsages{c, namespace} +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *FakeServicecatalogV1alpha1) RESTClient() rest.Interface { + var ret *rest.RESTClient + return ret +} diff --git a/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/generated_expansion.go b/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/generated_expansion.go new file mode 100644 index 000000000000..5ab03bd7cdf1 --- /dev/null +++ b/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/generated_expansion.go @@ -0,0 +1,5 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +type ServiceBindingUsageExpansion interface{} diff --git a/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/servicebindingusage.go b/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/servicebindingusage.go new file mode 100644 index 000000000000..8c6fe8122cfc --- /dev/null +++ b/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/servicebindingusage.go @@ -0,0 +1,141 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + scheme "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// ServiceBindingUsagesGetter has a method to return a ServiceBindingUsageInterface. +// A group's client should implement this interface. +type ServiceBindingUsagesGetter interface { + ServiceBindingUsages(namespace string) ServiceBindingUsageInterface +} + +// ServiceBindingUsageInterface has methods to work with ServiceBindingUsage resources. +type ServiceBindingUsageInterface interface { + Create(*v1alpha1.ServiceBindingUsage) (*v1alpha1.ServiceBindingUsage, error) + Update(*v1alpha1.ServiceBindingUsage) (*v1alpha1.ServiceBindingUsage, error) + Delete(name string, options *v1.DeleteOptions) error + DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error + Get(name string, options v1.GetOptions) (*v1alpha1.ServiceBindingUsage, error) + List(opts v1.ListOptions) (*v1alpha1.ServiceBindingUsageList, error) + Watch(opts v1.ListOptions) (watch.Interface, error) + Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.ServiceBindingUsage, err error) + ServiceBindingUsageExpansion +} + +// serviceBindingUsages implements ServiceBindingUsageInterface +type serviceBindingUsages struct { + client rest.Interface + ns string +} + +// newServiceBindingUsages returns a ServiceBindingUsages +func newServiceBindingUsages(c *ServicecatalogV1alpha1Client, namespace string) *serviceBindingUsages { + return &serviceBindingUsages{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the serviceBindingUsage, and returns the corresponding serviceBindingUsage object, and an error if there is any. +func (c *serviceBindingUsages) Get(name string, options v1.GetOptions) (result *v1alpha1.ServiceBindingUsage, err error) { + result = &v1alpha1.ServiceBindingUsage{} + err = c.client.Get(). + Namespace(c.ns). + Resource("servicebindingusages"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of ServiceBindingUsages that match those selectors. +func (c *serviceBindingUsages) List(opts v1.ListOptions) (result *v1alpha1.ServiceBindingUsageList, err error) { + result = &v1alpha1.ServiceBindingUsageList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("servicebindingusages"). + VersionedParams(&opts, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested serviceBindingUsages. +func (c *serviceBindingUsages) Watch(opts v1.ListOptions) (watch.Interface, error) { + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("servicebindingusages"). + VersionedParams(&opts, scheme.ParameterCodec). + Watch() +} + +// Create takes the representation of a serviceBindingUsage and creates it. Returns the server's representation of the serviceBindingUsage, and an error, if there is any. +func (c *serviceBindingUsages) Create(serviceBindingUsage *v1alpha1.ServiceBindingUsage) (result *v1alpha1.ServiceBindingUsage, err error) { + result = &v1alpha1.ServiceBindingUsage{} + err = c.client.Post(). + Namespace(c.ns). + Resource("servicebindingusages"). + Body(serviceBindingUsage). + Do(). + Into(result) + return +} + +// Update takes the representation of a serviceBindingUsage and updates it. Returns the server's representation of the serviceBindingUsage, and an error, if there is any. +func (c *serviceBindingUsages) Update(serviceBindingUsage *v1alpha1.ServiceBindingUsage) (result *v1alpha1.ServiceBindingUsage, err error) { + result = &v1alpha1.ServiceBindingUsage{} + err = c.client.Put(). + Namespace(c.ns). + Resource("servicebindingusages"). + Name(serviceBindingUsage.Name). + Body(serviceBindingUsage). + Do(). + Into(result) + return +} + +// Delete takes name of the serviceBindingUsage and deletes it. Returns an error if one occurs. +func (c *serviceBindingUsages) Delete(name string, options *v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("servicebindingusages"). + Name(name). + Body(options). + Do(). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *serviceBindingUsages) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("servicebindingusages"). + VersionedParams(&listOptions, scheme.ParameterCodec). + Body(options). + Do(). + Error() +} + +// Patch applies the patch and returns the patched serviceBindingUsage. +func (c *serviceBindingUsages) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.ServiceBindingUsage, err error) { + result = &v1alpha1.ServiceBindingUsage{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("servicebindingusages"). + SubResource(subresources...). + Name(name). + Body(data). + Do(). + Into(result) + return +} diff --git a/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/servicecatalog_client.go b/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/servicecatalog_client.go new file mode 100644 index 000000000000..d25568953ce2 --- /dev/null +++ b/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/servicecatalog_client.go @@ -0,0 +1,74 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/clientset/versioned/scheme" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + rest "k8s.io/client-go/rest" +) + +type ServicecatalogV1alpha1Interface interface { + RESTClient() rest.Interface + ServiceBindingUsagesGetter +} + +// ServicecatalogV1alpha1Client is used to interact with features provided by the servicecatalog.kyma.cx group. +type ServicecatalogV1alpha1Client struct { + restClient rest.Interface +} + +func (c *ServicecatalogV1alpha1Client) ServiceBindingUsages(namespace string) ServiceBindingUsageInterface { + return newServiceBindingUsages(c, namespace) +} + +// NewForConfig creates a new ServicecatalogV1alpha1Client for the given config. +func NewForConfig(c *rest.Config) (*ServicecatalogV1alpha1Client, error) { + config := *c + if err := setConfigDefaults(&config); err != nil { + return nil, err + } + client, err := rest.RESTClientFor(&config) + if err != nil { + return nil, err + } + return &ServicecatalogV1alpha1Client{client}, nil +} + +// NewForConfigOrDie creates a new ServicecatalogV1alpha1Client for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *ServicecatalogV1alpha1Client { + client, err := NewForConfig(c) + if err != nil { + panic(err) + } + return client +} + +// New creates a new ServicecatalogV1alpha1Client for the given RESTClient. +func New(c rest.Interface) *ServicecatalogV1alpha1Client { + return &ServicecatalogV1alpha1Client{c} +} + +func setConfigDefaults(config *rest.Config) error { + gv := v1alpha1.SchemeGroupVersion + config.GroupVersion = &gv + config.APIPath = "/apis" + config.NegotiatedSerializer = serializer.DirectCodecFactory{CodecFactory: scheme.Codecs} + + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } + + return nil +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *ServicecatalogV1alpha1Client) RESTClient() rest.Interface { + if c == nil { + return nil + } + return c.restClient +} diff --git a/components/binding-usage-controller/pkg/client/informers/externalversions/factory.go b/components/binding-usage-controller/pkg/client/informers/externalversions/factory.go new file mode 100644 index 000000000000..bae8e689cce0 --- /dev/null +++ b/components/binding-usage-controller/pkg/client/informers/externalversions/factory.go @@ -0,0 +1,115 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + reflect "reflect" + sync "sync" + time "time" + + versioned "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/informers/externalversions/internalinterfaces" + servicecatalog "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/informers/externalversions/servicecatalog" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +type sharedInformerFactory struct { + client versioned.Interface + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc + lock sync.Mutex + defaultResync time.Duration + + informers map[reflect.Type]cache.SharedIndexInformer + // startedInformers is used for tracking which informers have been started. + // This allows Start() to be called multiple times safely. + startedInformers map[reflect.Type]bool +} + +// NewSharedInformerFactory constructs a new instance of sharedInformerFactory +func NewSharedInformerFactory(client versioned.Interface, defaultResync time.Duration) SharedInformerFactory { + return NewFilteredSharedInformerFactory(client, defaultResync, v1.NamespaceAll, nil) +} + +// NewFilteredSharedInformerFactory constructs a new instance of sharedInformerFactory. +// Listers obtained via this SharedInformerFactory will be subject to the same filters +// as specified here. +func NewFilteredSharedInformerFactory(client versioned.Interface, defaultResync time.Duration, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) SharedInformerFactory { + return &sharedInformerFactory{ + client: client, + namespace: namespace, + tweakListOptions: tweakListOptions, + defaultResync: defaultResync, + informers: make(map[reflect.Type]cache.SharedIndexInformer), + startedInformers: make(map[reflect.Type]bool), + } +} + +// Start initializes all requested informers. +func (f *sharedInformerFactory) Start(stopCh <-chan struct{}) { + f.lock.Lock() + defer f.lock.Unlock() + + for informerType, informer := range f.informers { + if !f.startedInformers[informerType] { + go informer.Run(stopCh) + f.startedInformers[informerType] = true + } + } +} + +// WaitForCacheSync waits for all started informers' cache were synced. +func (f *sharedInformerFactory) WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool { + informers := func() map[reflect.Type]cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informers := map[reflect.Type]cache.SharedIndexInformer{} + for informerType, informer := range f.informers { + if f.startedInformers[informerType] { + informers[informerType] = informer + } + } + return informers + }() + + res := map[reflect.Type]bool{} + for informType, informer := range informers { + res[informType] = cache.WaitForCacheSync(stopCh, informer.HasSynced) + } + return res +} + +// InternalInformerFor returns the SharedIndexInformer for obj using an internal +// client. +func (f *sharedInformerFactory) InformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informerType := reflect.TypeOf(obj) + informer, exists := f.informers[informerType] + if exists { + return informer + } + informer = newFunc(f.client, f.defaultResync) + f.informers[informerType] = informer + + return informer +} + +// SharedInformerFactory provides shared informers for resources in all known +// API group versions. +type SharedInformerFactory interface { + internalinterfaces.SharedInformerFactory + ForResource(resource schema.GroupVersionResource) (GenericInformer, error) + WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool + + Servicecatalog() servicecatalog.Interface +} + +func (f *sharedInformerFactory) Servicecatalog() servicecatalog.Interface { + return servicecatalog.New(f, f.namespace, f.tweakListOptions) +} diff --git a/components/binding-usage-controller/pkg/client/informers/externalversions/generic.go b/components/binding-usage-controller/pkg/client/informers/externalversions/generic.go new file mode 100644 index 000000000000..e87045c90f40 --- /dev/null +++ b/components/binding-usage-controller/pkg/client/informers/externalversions/generic.go @@ -0,0 +1,46 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + "fmt" + + v1alpha1 "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +// GenericInformer is type of SharedIndexInformer which will locate and delegate to other +// sharedInformers based on type +type GenericInformer interface { + Informer() cache.SharedIndexInformer + Lister() cache.GenericLister +} + +type genericInformer struct { + informer cache.SharedIndexInformer + resource schema.GroupResource +} + +// Informer returns the SharedIndexInformer. +func (f *genericInformer) Informer() cache.SharedIndexInformer { + return f.informer +} + +// Lister returns the GenericLister. +func (f *genericInformer) Lister() cache.GenericLister { + return cache.NewGenericLister(f.Informer().GetIndexer(), f.resource) +} + +// ForResource gives generic access to a shared informer of the matching type +// TODO extend this to unknown resources with a client pool +func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { + switch resource { + // Group=servicecatalog.kyma.cx, Version=v1alpha1 + case v1alpha1.SchemeGroupVersion.WithResource("servicebindingusages"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Servicecatalog().V1alpha1().ServiceBindingUsages().Informer()}, nil + + } + + return nil, fmt.Errorf("no informer found for %v", resource) +} diff --git a/components/binding-usage-controller/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go b/components/binding-usage-controller/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go new file mode 100644 index 000000000000..5721a57bebfa --- /dev/null +++ b/components/binding-usage-controller/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go @@ -0,0 +1,22 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package internalinterfaces + +import ( + time "time" + + versioned "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/clientset/versioned" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + cache "k8s.io/client-go/tools/cache" +) + +type NewInformerFunc func(versioned.Interface, time.Duration) cache.SharedIndexInformer + +// SharedInformerFactory a small interface to allow for adding an informer without an import cycle +type SharedInformerFactory interface { + Start(stopCh <-chan struct{}) + InformerFor(obj runtime.Object, newFunc NewInformerFunc) cache.SharedIndexInformer +} + +type TweakListOptionsFunc func(*v1.ListOptions) diff --git a/components/binding-usage-controller/pkg/client/informers/externalversions/servicecatalog/interface.go b/components/binding-usage-controller/pkg/client/informers/externalversions/servicecatalog/interface.go new file mode 100644 index 000000000000..514e1a79ce49 --- /dev/null +++ b/components/binding-usage-controller/pkg/client/informers/externalversions/servicecatalog/interface.go @@ -0,0 +1,30 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package servicecatalog + +import ( + internalinterfaces "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/informers/externalversions/internalinterfaces" + v1alpha1 "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/informers/externalversions/servicecatalog/v1alpha1" +) + +// Interface provides access to each of this group's versions. +type Interface interface { + // V1alpha1 provides access to shared informers for resources in V1alpha1. + V1alpha1() v1alpha1.Interface +} + +type group struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// V1alpha1 returns a new v1alpha1.Interface. +func (g *group) V1alpha1() v1alpha1.Interface { + return v1alpha1.New(g.factory, g.namespace, g.tweakListOptions) +} diff --git a/components/binding-usage-controller/pkg/client/informers/externalversions/servicecatalog/v1alpha1/interface.go b/components/binding-usage-controller/pkg/client/informers/externalversions/servicecatalog/v1alpha1/interface.go new file mode 100644 index 000000000000..4dffa8786012 --- /dev/null +++ b/components/binding-usage-controller/pkg/client/informers/externalversions/servicecatalog/v1alpha1/interface.go @@ -0,0 +1,29 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + internalinterfaces "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/informers/externalversions/internalinterfaces" +) + +// Interface provides access to all the informers in this group version. +type Interface interface { + // ServiceBindingUsages returns a ServiceBindingUsageInformer. + ServiceBindingUsages() ServiceBindingUsageInformer +} + +type version struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// ServiceBindingUsages returns a ServiceBindingUsageInformer. +func (v *version) ServiceBindingUsages() ServiceBindingUsageInformer { + return &serviceBindingUsageInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} diff --git a/components/binding-usage-controller/pkg/client/informers/externalversions/servicecatalog/v1alpha1/servicebindingusage.go b/components/binding-usage-controller/pkg/client/informers/externalversions/servicecatalog/v1alpha1/servicebindingusage.go new file mode 100644 index 000000000000..bb427839fd26 --- /dev/null +++ b/components/binding-usage-controller/pkg/client/informers/externalversions/servicecatalog/v1alpha1/servicebindingusage.go @@ -0,0 +1,73 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + time "time" + + servicecatalog_v1alpha1 "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + versioned "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/informers/externalversions/internalinterfaces" + v1alpha1 "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/listers/servicecatalog/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// ServiceBindingUsageInformer provides access to a shared informer and lister for +// ServiceBindingUsages. +type ServiceBindingUsageInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha1.ServiceBindingUsageLister +} + +type serviceBindingUsageInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewServiceBindingUsageInformer constructs a new informer for ServiceBindingUsage type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewServiceBindingUsageInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredServiceBindingUsageInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredServiceBindingUsageInformer constructs a new informer for ServiceBindingUsage type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredServiceBindingUsageInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ServicecatalogV1alpha1().ServiceBindingUsages(namespace).List(options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ServicecatalogV1alpha1().ServiceBindingUsages(namespace).Watch(options) + }, + }, + &servicecatalog_v1alpha1.ServiceBindingUsage{}, + resyncPeriod, + indexers, + ) +} + +func (f *serviceBindingUsageInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredServiceBindingUsageInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *serviceBindingUsageInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&servicecatalog_v1alpha1.ServiceBindingUsage{}, f.defaultInformer) +} + +func (f *serviceBindingUsageInformer) Lister() v1alpha1.ServiceBindingUsageLister { + return v1alpha1.NewServiceBindingUsageLister(f.Informer().GetIndexer()) +} diff --git a/components/binding-usage-controller/pkg/client/listers/servicecatalog/v1alpha1/expansion_generated.go b/components/binding-usage-controller/pkg/client/listers/servicecatalog/v1alpha1/expansion_generated.go new file mode 100644 index 000000000000..9da6054e8773 --- /dev/null +++ b/components/binding-usage-controller/pkg/client/listers/servicecatalog/v1alpha1/expansion_generated.go @@ -0,0 +1,11 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +// ServiceBindingUsageListerExpansion allows custom methods to be added to +// ServiceBindingUsageLister. +type ServiceBindingUsageListerExpansion interface{} + +// ServiceBindingUsageNamespaceListerExpansion allows custom methods to be added to +// ServiceBindingUsageNamespaceLister. +type ServiceBindingUsageNamespaceListerExpansion interface{} diff --git a/components/binding-usage-controller/pkg/client/listers/servicecatalog/v1alpha1/servicebindingusage.go b/components/binding-usage-controller/pkg/client/listers/servicecatalog/v1alpha1/servicebindingusage.go new file mode 100644 index 000000000000..e80fdb94943a --- /dev/null +++ b/components/binding-usage-controller/pkg/client/listers/servicecatalog/v1alpha1/servicebindingusage.go @@ -0,0 +1,78 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// ServiceBindingUsageLister helps list ServiceBindingUsages. +type ServiceBindingUsageLister interface { + // List lists all ServiceBindingUsages in the indexer. + List(selector labels.Selector) (ret []*v1alpha1.ServiceBindingUsage, err error) + // ServiceBindingUsages returns an object that can list and get ServiceBindingUsages. + ServiceBindingUsages(namespace string) ServiceBindingUsageNamespaceLister + ServiceBindingUsageListerExpansion +} + +// serviceBindingUsageLister implements the ServiceBindingUsageLister interface. +type serviceBindingUsageLister struct { + indexer cache.Indexer +} + +// NewServiceBindingUsageLister returns a new ServiceBindingUsageLister. +func NewServiceBindingUsageLister(indexer cache.Indexer) ServiceBindingUsageLister { + return &serviceBindingUsageLister{indexer: indexer} +} + +// List lists all ServiceBindingUsages in the indexer. +func (s *serviceBindingUsageLister) List(selector labels.Selector) (ret []*v1alpha1.ServiceBindingUsage, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.ServiceBindingUsage)) + }) + return ret, err +} + +// ServiceBindingUsages returns an object that can list and get ServiceBindingUsages. +func (s *serviceBindingUsageLister) ServiceBindingUsages(namespace string) ServiceBindingUsageNamespaceLister { + return serviceBindingUsageNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// ServiceBindingUsageNamespaceLister helps list and get ServiceBindingUsages. +type ServiceBindingUsageNamespaceLister interface { + // List lists all ServiceBindingUsages in the indexer for a given namespace. + List(selector labels.Selector) (ret []*v1alpha1.ServiceBindingUsage, err error) + // Get retrieves the ServiceBindingUsage from the indexer for a given namespace and name. + Get(name string) (*v1alpha1.ServiceBindingUsage, error) + ServiceBindingUsageNamespaceListerExpansion +} + +// serviceBindingUsageNamespaceLister implements the ServiceBindingUsageNamespaceLister +// interface. +type serviceBindingUsageNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all ServiceBindingUsages in the indexer for a given namespace. +func (s serviceBindingUsageNamespaceLister) List(selector labels.Selector) (ret []*v1alpha1.ServiceBindingUsage, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.ServiceBindingUsage)) + }) + return ret, err +} + +// Get retrieves the ServiceBindingUsage from the indexer for a given namespace and name. +func (s serviceBindingUsageNamespaceLister) Get(name string) (*v1alpha1.ServiceBindingUsage, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha1.Resource("servicebindingusage"), name) + } + return obj.(*v1alpha1.ServiceBindingUsage), nil +} diff --git a/components/binding-usage-controller/pkg/signal/signal.go b/components/binding-usage-controller/pkg/signal/signal.go new file mode 100644 index 000000000000..9200f8f9959d --- /dev/null +++ b/components/binding-usage-controller/pkg/signal/signal.go @@ -0,0 +1,24 @@ +package signal + +import ( + "os" + "os/signal" + "syscall" +) + +// SetupChannel registered for SIGTERM and Interrupt. A stop channel is returned +// which is closed on one of these signals. If a second signal is caught, the program +// is terminated with exit code 1. +func SetupChannel() (stopCh <-chan struct{}) { + stop := make(chan struct{}) + c := make(chan os.Signal, 2) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + go func() { + <-c + close(stop) + <-c + os.Exit(1) // second signal. Exit directly. + }() + + return stop +} diff --git a/components/configurations-generator/Dockerfile b/components/configurations-generator/Dockerfile new file mode 100644 index 000000000000..e9fdcb3fba75 --- /dev/null +++ b/components/configurations-generator/Dockerfile @@ -0,0 +1,6 @@ +FROM alpine:3.7 + +LABEL source="git@github.com:kyma-project/kyma.git" +ADD /bin/app /app + +ENTRYPOINT [ "/app"] \ No newline at end of file diff --git a/components/configurations-generator/Gopkg.lock b/components/configurations-generator/Gopkg.lock new file mode 100644 index 000000000000..a4cddc6470ac --- /dev/null +++ b/components/configurations-generator/Gopkg.lock @@ -0,0 +1,42 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/gorilla/context" + packages = ["."] + revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a" + version = "v1.1" + +[[projects]] + name = "github.com/gorilla/mux" + packages = ["."] + revision = "53c1911da2b537f792e7cafcb446b05ffe33b996" + version = "v1.6.1" + +[[projects]] + name = "github.com/sirupsen/logrus" + packages = ["."] + revision = "c155da19408a8799da419ed3eeb0cb5db0ad5dbc" + version = "v1.0.5" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = ["ssh/terminal"] + revision = "d6449816ce06963d9d136eee5a56fca5b0616e7e" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = [ + "unix", + "windows" + ] + revision = "b126b21c05a91c856b027c16779c12e3bf236954" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "92fb8856abf730981aab9b44aa395e45a2ed49a1da33ae7b43845cce613eae39" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/components/configurations-generator/Gopkg.toml b/components/configurations-generator/Gopkg.toml new file mode 100644 index 000000000000..1e6142c72251 --- /dev/null +++ b/components/configurations-generator/Gopkg.toml @@ -0,0 +1,38 @@ +# Gopkg.toml example +# +# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" +# +# [prune] +# non-go = false +# go-tests = true +# unused-packages = true + + +[[constraint]] + name = "github.com/gorilla/mux" + version = "1.6.1" + +[[constraint]] + name = "github.com/sirupsen/logrus" + version = "1.0.5" + +[prune] + go-tests = true + unused-packages = true diff --git a/components/configurations-generator/Jenkinsfile b/components/configurations-generator/Jenkinsfile new file mode 100644 index 000000000000..ffdb68b7cc2a --- /dev/null +++ b/components/configurations-generator/Jenkinsfile @@ -0,0 +1,98 @@ +#!/usr/bin/env groovy +def label = "kyma-${UUID.randomUUID().toString()}" +def application = 'configurations-generator' +def isMaster = params.GIT_BRANCH == 'master' + +def dockerPushRoot = isMaster +? "${env.DOCKER_REGISTRY}" +: "${env.DOCKER_REGISTRY}snapshot/" + +def dockerImageTag = isMaster +? params.APP_VERSION +: params.GIT_BRANCH + +echo """ +******************************** +Job started with the following parameters: +DOCKER_REGISTRY=${env.DOCKER_REGISTRY} +DOCKER_CREDENTIALS=${env.DOCKER_CREDENTIALS} +GIT_REVISION=${params.GIT_REVISION} +GIT_BRANCH=${params.GIT_BRANCH} +APP_VERSION=${params.APP_VERSION} +APP_FOLDER=${env.APP_FOLDER} +******************************** +""" + +podTemplate(label: label) { + node(label) { + try { + timestamps { + timeout(time:20, unit:"MINUTES") { + ansiColor('xterm') { + stage("setup") { + checkout scm + + if(dockerImageTag == ""){ + error("No version for docker tag defined, please set APP_VERSION parameter for master branch or GIT_BRANCH parameter for any branch") + } + + withCredentials([usernamePassword(credentialsId: env.DOCKER_CREDENTIALS, passwordVariable: 'pwd', usernameVariable: 'uname')]) { + sh "docker login -u $uname -p '$pwd' $env.DOCKER_REGISTRY" + } + } + + stage("install dependencies $application") { + execute("make resolve") + } + + stage("code quality $application") { + execute("make validate") + } + + stage("build $application") { + execute("CGO_ENABLED=0 go build -o bin/app cmd/generator/main.go") + } + + stage("test $application") { + execute("make test-report") + junit '**/unit-tests.xml' + } + + if (isMaster) { + stage("IP scan $application (Sourceclear)"){ + withCredentials([string(credentialsId: 'SRCCLR_API_TOKEN', variable: 'SRCCLR_API_TOKEN')]) { + execute("make scan","SRCCLR_API_TOKEN=$SRCCLR_API_TOKEN") + } + } + } + + stage("build image $application") { + dir(env.APP_FOLDER){ + sh "docker build -t $application:latest ." + } + } + + + stage("push image $application") { + sh "docker tag ${application}:latest ${dockerPushRoot}${application}:${dockerImageTag}" + sh "docker push ${dockerPushRoot}${application}:${dockerImageTag}" + } + } + } + } + } catch (ex) { + echo "Got exception: ${ex}" + currentBuild.result = "FAILURE" + def body = "${currentBuild.currentResult} ${env.JOB_NAME}${env.BUILD_DISPLAY_NAME}: on branch: ${params.GIT_BRANCH}. See details: ${env.BUILD_URL}" + emailext body: body, recipientProviders: [[$class: 'DevelopersRecipientProvider'], [$class: 'CulpritsRecipientProvider'], [$class: 'RequesterRecipientProvider']], subject: "${currentBuild.currentResult}: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'" + } + } +} + +def execute(command, envs = []) { + def buildpack = 'golang-buildpack:0.0.8' + def repositoryName = 'kyma' + def envText = envs=='' ? '' : "--env $envs" + workDir = pwd() + sh "docker run --rm -v $workDir:/go/src/github.com/kyma-project/$repositoryName/ -w /go/src/github.com/kyma-project/$repositoryName/$env.APP_FOLDER $envText ${env.DOCKER_REGISTRY}$buildpack /bin/bash -c '$command'" +} diff --git a/components/configurations-generator/README.md b/components/configurations-generator/README.md new file mode 100644 index 000000000000..bf9b4739f4ad --- /dev/null +++ b/components/configurations-generator/README.md @@ -0,0 +1,73 @@ +``` + ____ __ _ _ _ + / ___|___ _ __ / _(_) __ _ _ _ _ __ __ _| |_(_) ___ _ __ ___ +| | / _ \| '_ \| |_| |/ _` | | | | '__/ _` | __| |/ _ \| '_ \/ __| +| |__| (_) | | | | _| | (_| | |_| | | | (_| | |_| | (_) | | | \__ \ + \____\___/|_| |_|_| |_|\__, |\__,_|_| \__,_|\__|_|\___/|_| |_|___/ + |___/ + ____ _ + / ___| ___ _ __ ___ _ __ __ _| |_ ___ _ __ +| | _ / _ \ '_ \ / _ \ '__/ _` | __/ _ \| '__| +| |_| | __/ | | | __/ | | (_| | || (_) | | + \____|\___|_| |_|\___|_| \__,_|\__\___/|_| + +``` + +## Overview + +This project is a generator of configurations used in Kyma. + +## Prerequisites + +The following tools are required to set up the project: +- [Go distribution](https://golang.org) +- [Docker](https://www.docker.com/) + +## Installation + +For installation use dedicated [Helm chart](https://github.com/kyma-project/kyma/tree/master/resources/core/charts/configurations-generator) + +## Usage + +### Configuration + +Use the following arguments to configure the application: + +| Name | Required | Default | Description | +| -----|---------|--------|------------ | +| port | No | 8000 | Application port. | +| kube-config-custer-name | Yes | | Name of the Kubernetes cluster. | +| kube-config-url | Yes | | URL of the Kubernetes Apiserver. | +| kube-config-ca | Yes, if kube-config-ca-file not specified | | Certificate Authority of the Kubernetes cluster. | +| kube-config-ca-file | Yes, if kube-config-ca not specified | | File with Certificate Authority of the Kubernetes cluster. | +| kube-config-ns | No | | Default namespace of the Kubernetes context. | + +### Run a local version + +In order to run a local version, a running minikube is required. + +To run the application without building the binary, execute the following commands: + +```bash +go run cmd/generator/main.go \ + -kube-config-custer-name=minikue \ + -kube-config-url=:8443 \ + -kube-config-ca-file=~/.minikube/ca.crt +``` + + +## Development + +### Testing + +Run tests: + +```bash +go test -v ./... +``` + +Run tests with coverage: + +```bash +go test -coverprofile=coverage_report.out -v ./... +``` diff --git a/components/configurations-generator/cmd/generator/main.go b/components/configurations-generator/cmd/generator/main.go new file mode 100644 index 000000000000..e9b6d8c39a0b --- /dev/null +++ b/components/configurations-generator/cmd/generator/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "encoding/base64" + "flag" + "io/ioutil" + "net/http" + "strconv" + + "github.com/gorilla/mux" + "github.com/kyma-project/kyma/components/configurations-generator/pkg/kube_config" + log "github.com/sirupsen/logrus" +) + +func main() { + + port := flag.Int("port", 8000, "Application port") + clusterNameArg := flag.String("kube-config-custer-name", "", "Name of the Kubernetes cluster") + urlArg := flag.String("kube-config-url", "", "URL of the Kubernetes Apiserver") + caArg := flag.String("kube-config-ca", "", "Certificate Authority of the Kubernetes cluster") + caFileArg := flag.String("kube-config-ca-file", "", "File with Certificate Authority of the Kubernetes cluster") + namespaceArg := flag.String("kube-config-ns", "", "Default namespace of the Kubernetes context") + flag.Parse() + + log.Infof("Starting configurations generator on port: %d...", *port) + + kubeConfig := KubeConfigFromArgs(clusterNameArg, urlArg, caArg, caFileArg, namespaceArg) + kubeConfigEndpoints := kube_config.NewEndpoints(kubeConfig) + + router := mux.NewRouter() + router.Methods("GET").Path("/kube-config").HandlerFunc(kubeConfigEndpoints.GetKubeConfig) + + log.Fatal(http.ListenAndServe(":"+strconv.Itoa(*port), router)) +} + +func KubeConfigFromArgs(clusterNameArg, urlArg, caArg, caFileArg, namespaceArg *string) *kube_config.KubeConfig { + + var clusterName, url, ca, namespace string + + if clusterNameArg == nil || *clusterNameArg == "" { + log.Fatal("Name of the Kubernetes cluster is required.") + } else { + clusterName = *clusterNameArg + } + + if urlArg == nil || *urlArg == "" { + log.Fatal("URL of the Kubernetes Apiserver is required.") + } else { + url = *urlArg + } + + if caArg == nil || *caArg == "" { + ca = readCaFromFile(caFileArg) + } else { + ca = *caArg + } + + if namespaceArg != nil { + namespace = *namespaceArg + } + + return kube_config.NewKubeConfig(clusterName, url, ca, namespace) +} + +func readCaFromFile(caFileArg *string) string { + + if caFileArg == nil || *caFileArg == "" { + log.Fatal("Certificate Authority of the Kubernetes cluster is required.") + } + + caBytes, caErr := ioutil.ReadFile(*caFileArg) + if caErr != nil { + log.Fatalf("Error while reading Certificate Authority of the Kubernetes cluster. Root cause: %v", caErr) + } + + return base64.StdEncoding.EncodeToString(caBytes) +} diff --git a/components/configurations-generator/doc.go b/components/configurations-generator/doc.go new file mode 100644 index 000000000000..5850bc4a8acb --- /dev/null +++ b/components/configurations-generator/doc.go @@ -0,0 +1,4 @@ +package kube_config + +// this files is required only because this project MUST have at least one go file on project root, to be fetched +// using go dep, as a dependency in another project diff --git a/components/configurations-generator/pkg/kube_config/endpoints.go b/components/configurations-generator/pkg/kube_config/endpoints.go new file mode 100644 index 000000000000..89096d13cc8c --- /dev/null +++ b/components/configurations-generator/pkg/kube_config/endpoints.go @@ -0,0 +1,40 @@ +package kube_config + +import ( + "net/http" + "regexp" +) + +const MimeTypeYaml = "application/x-yaml" + +type Endpoints struct { + kubeConfig *KubeConfig +} + +func NewEndpoints(c *KubeConfig) *Endpoints { + return &Endpoints{c} +} + +var bearerPattern = regexp.MustCompile("[Bb]earer ") + +func (e *Endpoints) GetKubeConfig(w http.ResponseWriter, req *http.Request) { + + w.Header().Add("Content-Type", MimeTypeYaml) + + authorization := req.Header.Get("Authorization") + if authorization == "" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Missing Authorization header with Bearer token.")) + return + } + + if !bearerPattern.MatchString(authorization) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Invalid value of the Authorization header. Bearer token is required.")) + return + } + + token := bearerPattern.ReplaceAllString(authorization, "") + + e.kubeConfig.Generate(w, token) +} diff --git a/components/configurations-generator/pkg/kube_config/endpoints_test.go b/components/configurations-generator/pkg/kube_config/endpoints_test.go new file mode 100644 index 000000000000..6396b5b6fce3 --- /dev/null +++ b/components/configurations-generator/pkg/kube_config/endpoints_test.go @@ -0,0 +1,112 @@ +package kube_config + +import ( + "net/http" + "net/http/httptest" + "regexp" + "testing" +) + +func TestGetKubeConfig_ShouldReturnKubeConfig(t *testing.T) { + + // given + clusterName := "testname" + ca := "testca" + url := "testurl" + namespace := "testnamespace" + token := "testtoken" + + kubeConfig := NewKubeConfig(clusterName, url, ca, namespace) + endpoints := NewEndpoints(kubeConfig) + + resp := httptest.NewRecorder() + + req := httptest.NewRequest("GET", "/kube-config", nil) + req.Header.Add("Authorization", "Bearer "+token) + + // when + h := http.HandlerFunc(endpoints.GetKubeConfig) + h.ServeHTTP(resp, req) + + // then + if resp.Code != http.StatusOK { + t.Errorf("Invalid response status code (actual: %d, expected: %d)", resp.Code, http.StatusOK) + } + + contentType := resp.Header().Get("Content-Type") + if contentType != MimeTypeYaml { + t.Errorf("Invalid response content type (actual: '%s', expected: '%s')", contentType, MimeTypeYaml) + } + + respBody := resp.Body.String() + if respBody == "" { + t.Error("Response body should not be empty.") + } + + shouldContain(t, respBody, clusterName, "Response should contain cluster name.") + shouldContain(t, respBody, ca, "Response should contain CA.") + shouldContain(t, respBody, url, "Response should contain URL.") + shouldContain(t, respBody, namespace, "Response should contain namespace.") + shouldContain(t, respBody, "token: "+token, "Response should contain user token.") +} + +func TestGetKubeConfig_ShouldReturnBadRequest_IfMissingAuthorization(t *testing.T) { + + // given + clusterName := "testname" + ca := "testca" + url := "testurl" + namespace := "testnamespace" + + kubeConfig := NewKubeConfig(clusterName, url, ca, namespace) + endpoints := NewEndpoints(kubeConfig) + + resp := httptest.NewRecorder() + + req := httptest.NewRequest("GET", "/kube-config", nil) + + // when + h := http.HandlerFunc(endpoints.GetKubeConfig) + h.ServeHTTP(resp, req) + + // then + if resp.Code != http.StatusBadRequest { + t.Errorf("Invalid response status code (actual: %d, expected: %d)", resp.Code, http.StatusBadRequest) + } +} + +func TestGetKubeConfig_ShouldReturnBadRequest_IfInvalidAuthorization(t *testing.T) { + + // given + clusterName := "testname" + ca := "testca" + url := "testurl" + namespace := "testnamespace" + + kubeConfig := NewKubeConfig(clusterName, url, ca, namespace) + endpoints := NewEndpoints(kubeConfig) + + resp := httptest.NewRecorder() + + req := httptest.NewRequest("GET", "/kube-config", nil) + req.SetBasicAuth("u", "p") + + // when + h := http.HandlerFunc(endpoints.GetKubeConfig) + h.ServeHTTP(resp, req) + + // then + if resp.Code != http.StatusBadRequest { + t.Errorf("Invalid response status code (actual: %d, expected: %d)", resp.Code, http.StatusBadRequest) + } +} + +func shouldContain(t *testing.T, s, pattern, message string) { + matches, matchErr := regexp.MatchString(pattern, s) + if matchErr != nil { + t.Fatal(matchErr) + } + if !matches { + t.Errorf(message) + } +} diff --git a/components/configurations-generator/pkg/kube_config/kube_config.go b/components/configurations-generator/pkg/kube_config/kube_config.go new file mode 100644 index 000000000000..86d7db1fb3f8 --- /dev/null +++ b/components/configurations-generator/pkg/kube_config/kube_config.go @@ -0,0 +1,78 @@ +package kube_config + +import ( + "io" + "text/template" + + log "github.com/sirupsen/logrus" +) + +var content = ` +apiVersion: v1 +clusters: +- cluster: + certificate-authority-data: {{.CA}} + server: {{.URL}} + name: {{.ClusterName}} +contexts: +- context: + cluster: {{.ClusterName}} + {{- if .NS}} + namespace: {{.NS}} + {{- end}} + user: OIDCUser + name: {{.ClusterName}} +current-context: {{.ClusterName}} +kind: Config +preferences: {} +users: +- name: OIDCUser + user: + token: {{.Token}} +` + +type KubeConfig struct { + clusterName string + url string + ca string + namespace string + tmpl *template.Template +} + +func NewKubeConfig(clusterName, url, ca, namespace string) *KubeConfig { + + tmpl := template.Must(template.New("kubeConfig").Parse(content)) + + return &KubeConfig{ + clusterName: clusterName, + url: url, + ca: ca, + namespace: namespace, + tmpl: tmpl, + } +} + +type data struct { + ClusterName string + URL string + CA string + NS string + Token string +} + +func (c *KubeConfig) Generate(output io.Writer, token string) { + + log.Debug("Generating kube config...") + + d := data{ + ClusterName: c.clusterName, + URL: c.url, + CA: c.ca, + NS: c.namespace, + Token: token, + } + + c.tmpl.Execute(output, d) + + log.Debug("Kube config generated.") +} diff --git a/components/connector-service/.gitignore b/components/connector-service/.gitignore new file mode 100755 index 000000000000..0f8454d1a1d1 --- /dev/null +++ b/components/connector-service/.gitignore @@ -0,0 +1,4 @@ +.idea +*.iml +*.log +./connectorservice \ No newline at end of file diff --git a/components/connector-service/Dockerfile b/components/connector-service/Dockerfile new file mode 100755 index 000000000000..8491cc30bde1 --- /dev/null +++ b/components/connector-service/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.9-alpine as builder + +ARG DOCK_PKG_DIR=/go/src/github.com/kyma-project/kyma/components/connector-service + +RUN mkdir -p $DOCK_PKG_DIR + +COPY ./ $DOCK_PKG_DIR +WORKDIR $DOCK_PKG_DIR + +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o connectorservice ./cmd/connectorservice + +FROM alpine:3.7 +RUN apk update && apk add curl ngrep openssl + +LABEL source = git@github.com:kyma-project/kyma.git + +COPY --from=builder /go/src/github.com/kyma-project/kyma/components/connector-service/connectorservice . + +CMD ["/connectorservice"] diff --git a/components/connector-service/Gopkg.lock b/components/connector-service/Gopkg.lock new file mode 100755 index 000000000000..8b1686245b65 --- /dev/null +++ b/components/connector-service/Gopkg.lock @@ -0,0 +1,328 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/davecgh/go-spew" + packages = ["spew"] + revision = "346938d642f2ec3594ed81d874461961cd0faa76" + version = "v1.1.0" + +[[projects]] + name = "github.com/ghodss/yaml" + packages = ["."] + revision = "0ca9ea5df5451ffdf184b4428c902747c2c11cd7" + version = "v1.0.0" + +[[projects]] + name = "github.com/gogo/protobuf" + packages = [ + "proto", + "sortkeys" + ] + revision = "1adfc126b41513cc696b209667c8656ea7aac67c" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "github.com/golang/glog" + packages = ["."] + revision = "23def4e6c14b4da8ac2ed8007337bc5eb5007998" + +[[projects]] + name = "github.com/golang/protobuf" + packages = [ + "proto", + "ptypes", + "ptypes/any", + "ptypes/duration", + "ptypes/timestamp" + ] + revision = "b4deda0973fb4c70b50d226b1af49f3da59f5265" + version = "v1.1.0" + +[[projects]] + branch = "master" + name = "github.com/google/gofuzz" + packages = ["."] + revision = "24818f796faf91cd76ec7bddd72458fbced7a6c1" + +[[projects]] + name = "github.com/googleapis/gnostic" + packages = [ + "OpenAPIv2", + "compiler", + "extensions" + ] + revision = "7c663266750e7d82587642f65e60bc4083f1f84e" + version = "v0.2.0" + +[[projects]] + name = "github.com/gorilla/context" + packages = ["."] + revision = "08b5f424b9271eedf6f9f0ce86cb9396ed337a42" + version = "v1.1.1" + +[[projects]] + name = "github.com/gorilla/mux" + packages = ["."] + revision = "e3702bed27f0d39777b0b37b664b6280e8ef8fbf" + version = "v1.6.2" + +[[projects]] + name = "github.com/json-iterator/go" + packages = ["."] + revision = "ca39e5af3ece67bbcda3d0f4f56a8e24d9f2dad4" + version = "1.1.3" + +[[projects]] + name = "github.com/modern-go/concurrent" + packages = ["."] + revision = "bacd9c7ef1dd9b15be4a9909b8ac7a4e313eec94" + version = "1.0.3" + +[[projects]] + name = "github.com/modern-go/reflect2" + packages = ["."] + revision = "1df9eeb2bb81f327b96228865c5687bc2194af3f" + version = "1.0.0" + +[[projects]] + name = "github.com/patrickmn/go-cache" + packages = ["."] + revision = "a3647f8e31d79543b2d0f0ae2fe5c379d72cedc0" + version = "v2.1.0" + +[[projects]] + name = "github.com/pmezard/go-difflib" + packages = ["difflib"] + revision = "792786c7400a136282c1664665ae0a8db921c6c2" + version = "v1.0.0" + +[[projects]] + name = "github.com/sirupsen/logrus" + packages = ["."] + revision = "c155da19408a8799da419ed3eeb0cb5db0ad5dbc" + version = "v1.0.5" + +[[projects]] + name = "github.com/spf13/pflag" + packages = ["."] + revision = "583c0c0531f06d5278b7d917446061adc344b5cd" + version = "v1.0.1" + +[[projects]] + name = "github.com/stretchr/objx" + packages = ["."] + revision = "facf9a85c22f48d2f52f2380e4efce1768749a89" + version = "v0.1" + +[[projects]] + name = "github.com/stretchr/testify" + packages = [ + "assert", + "mock", + "require" + ] + revision = "12b6f73e6084dad08a7c6e575284b177ecafbc71" + version = "v1.2.1" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = ["ssh/terminal"] + revision = "ab813273cd59e1333f7ae7bff5d027d4aadf528c" + +[[projects]] + branch = "master" + name = "golang.org/x/net" + packages = [ + "context", + "http/httpguts", + "http2", + "http2/hpack", + "idna" + ] + revision = "dfa909b99c79129e1100513e5cd36307665e5723" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = [ + "unix", + "windows" + ] + revision = "c11f84a56e43e20a78cee75a7c034031ecf57d1f" + +[[projects]] + name = "golang.org/x/text" + packages = [ + "collate", + "collate/build", + "internal/colltab", + "internal/gen", + "internal/tag", + "internal/triegen", + "internal/ucd", + "language", + "secure/bidirule", + "transform", + "unicode/bidi", + "unicode/cldr", + "unicode/norm", + "unicode/rangetable" + ] + revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" + version = "v0.3.0" + +[[projects]] + branch = "master" + name = "golang.org/x/time" + packages = ["rate"] + revision = "fbb02b2291d28baffd63558aa44b4b56f178d650" + +[[projects]] + name = "gopkg.in/inf.v0" + packages = ["."] + revision = "d2d2541c53f18d2a059457998ce2876cc8e67cbf" + version = "v0.9.1" + +[[projects]] + name = "gopkg.in/yaml.v2" + packages = ["."] + revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183" + version = "v2.2.1" + +[[projects]] + name = "k8s.io/api" + packages = [ + "admissionregistration/v1alpha1", + "admissionregistration/v1beta1", + "apps/v1", + "apps/v1beta1", + "apps/v1beta2", + "authentication/v1", + "authentication/v1beta1", + "authorization/v1", + "authorization/v1beta1", + "autoscaling/v1", + "autoscaling/v2beta1", + "batch/v1", + "batch/v1beta1", + "batch/v2alpha1", + "certificates/v1beta1", + "core/v1", + "events/v1beta1", + "extensions/v1beta1", + "networking/v1", + "policy/v1beta1", + "rbac/v1", + "rbac/v1alpha1", + "rbac/v1beta1", + "scheduling/v1alpha1", + "settings/v1alpha1", + "storage/v1", + "storage/v1alpha1", + "storage/v1beta1" + ] + revision = "73d903622b7391f3312dcbac6483fed484e185f8" + version = "kubernetes-1.10.0" + +[[projects]] + name = "k8s.io/apimachinery" + packages = [ + "pkg/api/errors", + "pkg/api/meta", + "pkg/api/resource", + "pkg/apis/meta/v1", + "pkg/apis/meta/v1/unstructured", + "pkg/apis/meta/v1beta1", + "pkg/conversion", + "pkg/conversion/queryparams", + "pkg/fields", + "pkg/labels", + "pkg/runtime", + "pkg/runtime/schema", + "pkg/runtime/serializer", + "pkg/runtime/serializer/json", + "pkg/runtime/serializer/protobuf", + "pkg/runtime/serializer/recognizer", + "pkg/runtime/serializer/streaming", + "pkg/runtime/serializer/versioning", + "pkg/selection", + "pkg/types", + "pkg/util/clock", + "pkg/util/errors", + "pkg/util/framer", + "pkg/util/intstr", + "pkg/util/json", + "pkg/util/net", + "pkg/util/runtime", + "pkg/util/sets", + "pkg/util/validation", + "pkg/util/validation/field", + "pkg/util/wait", + "pkg/util/yaml", + "pkg/version", + "pkg/watch", + "third_party/forked/golang/reflect" + ] + revision = "302974c03f7e50f16561ba237db776ab93594ef6" + version = "kubernetes-1.10.0" + +[[projects]] + name = "k8s.io/client-go" + packages = [ + "discovery", + "kubernetes", + "kubernetes/scheme", + "kubernetes/typed/admissionregistration/v1alpha1", + "kubernetes/typed/admissionregistration/v1beta1", + "kubernetes/typed/apps/v1", + "kubernetes/typed/apps/v1beta1", + "kubernetes/typed/apps/v1beta2", + "kubernetes/typed/authentication/v1", + "kubernetes/typed/authentication/v1beta1", + "kubernetes/typed/authorization/v1", + "kubernetes/typed/authorization/v1beta1", + "kubernetes/typed/autoscaling/v1", + "kubernetes/typed/autoscaling/v2beta1", + "kubernetes/typed/batch/v1", + "kubernetes/typed/batch/v1beta1", + "kubernetes/typed/batch/v2alpha1", + "kubernetes/typed/certificates/v1beta1", + "kubernetes/typed/core/v1", + "kubernetes/typed/events/v1beta1", + "kubernetes/typed/extensions/v1beta1", + "kubernetes/typed/networking/v1", + "kubernetes/typed/policy/v1beta1", + "kubernetes/typed/rbac/v1", + "kubernetes/typed/rbac/v1alpha1", + "kubernetes/typed/rbac/v1beta1", + "kubernetes/typed/scheduling/v1alpha1", + "kubernetes/typed/settings/v1alpha1", + "kubernetes/typed/storage/v1", + "kubernetes/typed/storage/v1alpha1", + "kubernetes/typed/storage/v1beta1", + "pkg/apis/clientauthentication", + "pkg/apis/clientauthentication/v1alpha1", + "pkg/version", + "plugin/pkg/client/auth/exec", + "rest", + "rest/watch", + "tools/clientcmd/api", + "tools/metrics", + "tools/reference", + "transport", + "util/cert", + "util/flowcontrol", + "util/integer" + ] + revision = "989be4278f353e42f26c416c53757d16fcff77db" + version = "kubernetes-1.10.1" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "828f3345b910477705947a905495efe4dad393991d7272b9c1f0c75c21d658a2" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/components/connector-service/Gopkg.toml b/components/connector-service/Gopkg.toml new file mode 100755 index 000000000000..34f0acf61f17 --- /dev/null +++ b/components/connector-service/Gopkg.toml @@ -0,0 +1,50 @@ +# Gopkg.toml example +# +# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" +# +# [prune] +# non-go = false +# go-tests = true +# unused-packages = true + + +[[constraint]] + name = "github.com/gorilla/mux" + version = "1.6.2" + +[[constraint]] + name = "github.com/sirupsen/logrus" + version = "1.0.5" + +[[constraint]] + name = "k8s.io/client-go" + version = "kubernetes-1.10.1" + +[[override]] + name = "k8s.io/api" + version = "kubernetes-1.10.1" + +[[override]] + name = "k8s.io/apimachinery" + version = "kubernetes-1.10.1" + +[prune] + go-tests = true + unused-packages = true diff --git a/components/connector-service/Jenkinsfile b/components/connector-service/Jenkinsfile new file mode 100755 index 000000000000..4b165a3240d8 --- /dev/null +++ b/components/connector-service/Jenkinsfile @@ -0,0 +1,94 @@ +#!/usr/bin/env groovy +def label = "kyma-${UUID.randomUUID().toString()}" +def application = 'connector-service' +def isMaster = params.GIT_BRANCH == 'master' + +def dockerPushRoot = isMaster + ? "${env.DOCKER_REGISTRY}" + : "${env.DOCKER_REGISTRY}snapshot/" + +def dockerImageTag = isMaster + ? params.APP_VERSION + : params.GIT_BRANCH + +echo """ +******************************** +Job started with the following parameters: +DOCKER_REGISTRY=${env.DOCKER_REGISTRY} +DOCKER_CREDENTIALS=${env.DOCKER_CREDENTIALS} +GIT_REVISION=${params.GIT_REVISION} +GIT_BRANCH=${params.GIT_BRANCH} +APP_VERSION=${params.APP_VERSION} +APP_FOLDER=${env.APP_FOLDER} +******************************** +""" + +podTemplate(label: label) { + node(label) { + try { + timestamps { + timeout(time:20, unit:"MINUTES") { + ansiColor('xterm') { + stage("setup") { + checkout scm + + if(dockerImageTag == ""){ + error("No version for docker tag defined, please set APP_VERSION parameter for master branch or GIT_BRANCH parameter for any branch") + } + + withCredentials([usernamePassword(credentialsId: env.DOCKER_CREDENTIALS, passwordVariable: 'pwd', usernameVariable: 'uname')]) { + sh "docker login -u $uname -p '$pwd' $env.DOCKER_REGISTRY" + } + } + + stage("install dependencies $application") { + execute("dep ensure -v") + } + + stage("build $application") { + execute("go build ./cmd/connectorservice") + } + + stage("test $application") { + execute("go test `go list ./internal/... ./cmd/...`") + } + + if (isMaster) { + stage("IP scan $application (Sourceclear)"){ + withCredentials([string(credentialsId: 'SRCCLR_API_TOKEN', variable: 'SRCCLR_API_TOKEN')]) { + execute("make scan","SRCCLR_API_TOKEN=$SRCCLR_API_TOKEN") + } + } + } + + stage("build image $application") { + dir(env.APP_FOLDER){ + sh "docker build -t $application:latest ." + } + } + + + stage("push image $application") { + sh "docker tag ${application}:latest ${dockerPushRoot}${application}:${dockerImageTag}" + sh "docker push ${dockerPushRoot}${application}:${dockerImageTag}" + } + } + } + } + } catch (ex) { + echo "Got exception: ${ex}" + currentBuild.result = "FAILURE" + def body = "${currentBuild.currentResult} ${env.JOB_NAME}${env.BUILD_DISPLAY_NAME}: on branch: ${params.GIT_BRANCH}. See details: ${env.BUILD_URL}" + emailext body: body, recipientProviders: [[$class: 'DevelopersRecipientProvider'], [$class: 'CulpritsRecipientProvider'], [$class: 'RequesterRecipientProvider']], subject: "${currentBuild.currentResult}: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'" + } + } +} + +def execute(command, envs = '') { + def buildpack = 'golang-buildpack:0.0.8' + def repositoryName = 'kyma' + def envText = envs=='' ? '' : "--env $envs" + workDir = pwd() + sh "docker run --rm -v $workDir:/go/src/github.com/kyma-project/$repositoryName/ -w /go/src/github.com/kyma-project/$repositoryName/$env.APP_FOLDER $envText ${env.DOCKER_REGISTRY}$buildpack /bin/bash -c '$command'" +} + diff --git a/components/connector-service/README.md b/components/connector-service/README.md new file mode 100755 index 000000000000..a3ae7fe0018b --- /dev/null +++ b/components/connector-service/README.md @@ -0,0 +1,37 @@ +# Connector Service + +## Overview + +This is the repository for the Kyma Connector Service. + +## Prerequisites + +The Connector Service requires Go 1.8 or higher. + +## Usage + +This section explains how to use the Connector Service. + +### Start the Connector Service +To start the Connector Service, run this command: + +``` +./connectorservice +``` + +The Connector Service has the following parameters: +- **appName** - This is the name of the application used by k8s deployments and services. The default value is `connector-service`. +- **externalAPIPort** - This port exposes the Connector Service API to an external solution. The default port is `8081`. +- **internalAPIPort** - This port exposes the Connector Service within Kubernetes cluster. The default port is `8080`. +- **namespace** - Namespace where Connector Service is deployed. The default namespace is `kyma-integration`. +- **tokenLength** - Length of registration tokens. The default value is `64`. +- **tokenExpirationMinutes** - Time after which tokens expire and are no longer valid. The default value is `60` minutes. +- **domainName** - Domain name of the cluster, used for URL generating. Default domain name is `.wormhole.cluster.kyma.cx`. +- **certificateServiceHost** - Host at which this service is accessible, used for URL generating. Default host is `cert-service.wormhole.cluster.kyma.cx`. + +Connector Service also uses following environmental variables for CSR - related information config: +- **COUNTRY** (two-letter-long country code) +- **ORGANIZATION** +- **ORGANIZATIONALUNIT** +- **LOCALITY** +- **PROVINCE** diff --git a/components/connector-service/cmd/connectorservice/connectorservice.go b/components/connector-service/cmd/connectorservice/connectorservice.go new file mode 100755 index 000000000000..c3d8912f8043 --- /dev/null +++ b/components/connector-service/cmd/connectorservice/connectorservice.go @@ -0,0 +1,105 @@ +package main + +import ( + "fmt" + "net/http" + "strconv" + "sync" + + "github.com/kyma-project/kyma/components/connector-service/internal/apperrors" + "github.com/kyma-project/kyma/components/connector-service/internal/certificates" + "github.com/kyma-project/kyma/components/connector-service/internal/errorhandler" + "github.com/kyma-project/kyma/components/connector-service/internal/externalapi" + "github.com/kyma-project/kyma/components/connector-service/internal/internalapi" + "github.com/kyma-project/kyma/components/connector-service/internal/secrets" + "github.com/kyma-project/kyma/components/connector-service/internal/tokens" + "github.com/kyma-project/kyma/components/connector-service/internal/tokens/tokencache" + log "github.com/sirupsen/logrus" + "k8s.io/client-go/kubernetes" + restclient "k8s.io/client-go/rest" +) + +func main() { + formatter := &log.TextFormatter{ + FullTimestamp: true, + } + log.SetFormatter(formatter) + + log.Info("Starting Certificate Service.") + + options := parseArgs() + log.Infof("Options: %s", options) + + env := parseEnv() + log.Infof("Environment variables: %s", env) + + tokenCache := tokencache.NewTokenCache(options.tokenExpirationMinutes) + certUtil := certificates.NewCertificateUtility() + tokenGenerator := tokens.NewTokenGenerator(options.tokenLength, tokenCache) + + externalHandler := newExternalHandler(tokenCache, certUtil, tokenGenerator, options, env) + internalHandler := newInternalHandler(tokenCache, tokenGenerator, options.connectorServiceHost) + + externalSrv := &http.Server{ + Addr: ":" + strconv.Itoa(options.externalAPIPort), + Handler: externalHandler, + } + + internalSrv := &http.Server{ + Addr: ":" + strconv.Itoa(options.internalAPIPort), + Handler: internalHandler, + } + + wg := &sync.WaitGroup{} + + wg.Add(1) + go func() { + log.Info(externalSrv.ListenAndServe()) + }() + + go func() { + log.Info(internalSrv.ListenAndServe()) + }() + + wg.Wait() +} + +func newExternalHandler(cache tokencache.TokenCache, utility certificates.CertificateUtility, tokenGenerator tokens.TokenGenerator, opts *options, env *environment) http.Handler { + secretsRepository, appErr := newSecretsRepository(opts.namespace) + if appErr != nil { + log.Infof("Failed to create secrets repository. %s", appErr.Error()) + return errorhandler.NewErrorHandler(500, fmt.Sprintf("Failed to create secrets repository: %s", appErr.Error())) + } + + subjectValues := certificates.CSRSubject{ + Country: env.country, + Organization: env.organization, + OrganizationalUnit: env.organizationalUnit, + Locality: env.locality, + Province: env.province, + } + rh := externalapi.NewSignatureHandler(cache, utility, secretsRepository, opts.connectorServiceHost, opts.domainName, subjectValues) + ih := externalapi.NewInfoHandler(cache, tokenGenerator, opts.connectorServiceHost, opts.domainName, subjectValues) + return externalapi.NewHandler(rh, ih) +} + +func newInternalHandler(cache tokencache.TokenCache, tokenGenerator tokens.TokenGenerator, host string) http.Handler { + th := internalapi.NewTokenHandler(tokenGenerator, host) + return internalapi.NewHandler(th) +} + +func newSecretsRepository(namespace string) (secrets.Repository, apperrors.AppError) { + k8sConfig, err := restclient.InClusterConfig() + if err != nil { + return nil, apperrors.Internal("failed to read k8s in-cluster configuration, %s", err) + } + + coreClientset, err := kubernetes.NewForConfig(k8sConfig) + if err != nil { + return nil, apperrors.Internal("failed to create k8s core client, %s", err) + } + + sei := coreClientset.CoreV1().Secrets(namespace) + + return secrets.NewRepository(sei), nil +} diff --git a/components/connector-service/cmd/connectorservice/options.go b/components/connector-service/cmd/connectorservice/options.go new file mode 100755 index 000000000000..9508dc567135 --- /dev/null +++ b/components/connector-service/cmd/connectorservice/options.go @@ -0,0 +1,71 @@ +package main + +import ( + "flag" + "fmt" + "os" +) + +type options struct { + appName string + externalAPIPort int + internalAPIPort int + namespace string + tokenLength int + tokenExpirationMinutes int + domainName string + connectorServiceHost string +} + +type environment struct { + country string + organization string + organizationalUnit string + locality string + province string +} + +func parseArgs() *options { + appName := flag.String("appName", "connector-service", "Name of the Certificate Service, used by k8s deployments and services.") + externalAPIPort := flag.Int("externalAPIPort", 8081, "External API port.") + internalAPIPort := flag.Int("internalAPIPort", 8080, "Internal API port.") + namespace := flag.String("namespace", "kyma-integration", "Namespace used by Certificate Service") + tokenLength := flag.Int("tokenLength", 64, "Length of a registration tokens.") + tokenExpirationMinutes := flag.Int("tokenExpirationMinutes", 60, "Time to Live of tokens expressed in minutes.") + domainName := flag.String("domainName", ".wormhole.cluster.kyma.cx", "Domain name used for URL generation.") + connectorServiceHost := flag.String("connectorServiceHost", "cert-service.wormhole.cluster.kyma.cx", "Host at which this service is accessible.") + + flag.Parse() + + return &options{ + appName: *appName, + externalAPIPort: *externalAPIPort, + internalAPIPort: *internalAPIPort, + namespace: *namespace, + tokenLength: *tokenLength, + tokenExpirationMinutes: *tokenExpirationMinutes, + domainName: *domainName, + connectorServiceHost: *connectorServiceHost, + } +} + +func (o *options) String() string { + return fmt.Sprintf("--appName=%s --externalAPIPort=%d --internalAPIPort=%d --namespace=%s --tokenLength=%d "+ + "--tokenExpirationMinutes=%d --domainName=%s --connectorServiceHost=%s", o.appName, o.externalAPIPort, + o.internalAPIPort, o.namespace, o.tokenLength, o.tokenExpirationMinutes, o.domainName, o.connectorServiceHost) +} + +func parseEnv() *environment { + return &environment{ + country: os.Getenv("COUNTRY"), + organization: os.Getenv("ORGANIZATION"), + organizationalUnit: os.Getenv("ORGANIZATIONALUNIT"), + locality: os.Getenv("LOCALITY"), + province: os.Getenv("PROVINCE"), + } +} + +func (e *environment) String() string { + return fmt.Sprintf("COUNTRY=%s ORGANIZATION=%s ORGANIZATIONALUNIT=%s LOCALITY=%s PROVINCE=%s", + e.country, e.organization, e.organizationalUnit, e.locality, e.province) +} diff --git a/components/connector-service/docs/api/swagger.yaml b/components/connector-service/docs/api/swagger.yaml new file mode 100755 index 000000000000..cce41e1cf948 --- /dev/null +++ b/components/connector-service/docs/api/swagger.yaml @@ -0,0 +1,182 @@ +openapi: '3.0.0' +info: + version: '1.0.0' + title: 'Kyma Certificate Service Metadata API' +paths: + /v1/remoteenvironments/{remoteEnvName}/tokens: + post: + parameters: + - in: path + name: remoteEnvName + description: 'Name of remote environment.' + required: true + schema: + type: string + tags: + - internal API + summary: 'Creates a token used for certificate signing flow.' + responses: + '201': + description: 'Successful operation.' + content: + application/json: + schema: + $ref: '#/components/schemas/tokenResponse' + '500': + description: 'Server error.' + content: + application/json: + schema: + $ref: '#/components/schemas/appError' + /v1/remoteenvironments/{remoteEnvName}/info: + get: + parameters: + - in: path + name: remoteEnvName + description: 'Name of remote environment.' + required: true + schema: + type: string + - in: query + name: token + description: 'Access Token fetched from /tokens endpoint.' + required: true + schema: + type: string + tags: + - external API + summary: 'Allows for fetching proper information for future CSR.' + responses: + '200': + description: 'Successful operation.' + content: + application/json: + schema: + $ref: '#/components/schemas/infoResponse' + '403': + description: 'Invalid token' + content: + application/json: + schema: + $ref: '#/components/schemas/appError' + '500': + description: 'Server error.' + content: + application/json: + schema: + $ref: '#/components/schemas/appError' + /v1/remoteenvironments/{remoteEnvName}/client-certs: + post: + parameters: + - in: path + name: remoteEnvName + description: 'Name of remote environment.' + required: true + schema: + type: string + - in: query + name: token + description: 'Access Token fetched from /tokens endpoint.' + required: true + schema: + type: string + tags: + - external API + summary: 'Signs CSR.' + requestBody: + description: 'The CSR to be signed' + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/csrRequest' + responses: + '201': + description: 'Successful operation.' + content: + application/json: + schema: + $ref: '#/components/schemas/csrResponse' + '403': + description: 'Invalid token' + content: + application/json: + schema: + $ref: '#/components/schemas/appError' + '404': + description: 'Not found' + content: + application/json: + schema: + $ref: '#/components/schemas/appError' + '500': + description: 'Server error.' + content: + application/json: + schema: + $ref: '#/components/schemas/appError' +components: + schemas: + tokenResponse: + type: 'object' + properties: + url: + type: 'string' + example: 'https://cert-service.kyma.cluster.cx/v1/remoteenvironments/ec-default/info?token=1edfc34g' + token: + type: 'string' + example: '1edfc34g' + cert: + type: 'object' + properties: + subject: + type: 'string' + example: 'OU=Test,O=Test,L=Blacksburg,ST=Virginia,C=US,CN=ec-default' + extensions: + type: 'string' + key-algorithm: + type: 'string' + example: 'rsa2048' + apiURLs: + type: 'object' + properties: + metadataUrl: + type: 'string' + example: 'gateway.wormhole.kyma.cluster.cx/ec-default/v1/metadata/services' + eventsUrl: + type: 'string' + example: 'gateway.wormhole.kyma.cluster.cx/ec-default/v1/events' + certificatesUrl: + type: 'string' + example: 'certificate-service.kyma.cluster.cx/v1/remoteenvironments/ec-default' + infoResponse: + type: 'object' + properties: + url: + type: 'string' + example: 'https://cert-service.kyma.cluster.cx/v1/remoteenvironments/ec-default/client-certs?token=1edfc34g' + api: + $ref: '#/components/schemas/apiURLs' + certificate: + $ref: '#/components/schemas/cert' + appError: + type: 'object' + properties: + code: + type: 'integer' + error: + type: 'string' + csrRequest: + type: 'object' + properties: + csr: + type: 'string' + description: 'Base64 encoded certificate signing request file.' + example: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tDQpBTllfQ0VSVElGSUNBVEVfRklMRV9USElTX0lTX0pVU1RfQU5fRVhBTVBMRQ0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ0K' + csrResponse: + type: 'object' + properties: + crt: + type: 'string' + description: 'Base64 encoded certificate file.' + example: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tDQpBTllfQ0VSVElGSUNBVEVfRklMRV9USElTX0lTX0pVU1RfQU5fRVhBTVBMRQ0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ0K' \ No newline at end of file diff --git a/components/connector-service/internal/apperrors/apperrors.go b/components/connector-service/internal/apperrors/apperrors.go new file mode 100755 index 000000000000..cbac2daa0ed8 --- /dev/null +++ b/components/connector-service/internal/apperrors/apperrors.go @@ -0,0 +1,52 @@ +package apperrors + +import "fmt" + +const ( + CodeInternal = 1 + CodeNotFound = 2 + CodeAlreadyExists = 3 + CodeWrongInput = 4 + CodeForbidden = 5 +) + +type AppError interface { + Code() int + Error() string +} + +type appError struct { + code int + message string +} + +func errorf(code int, format string, a ...interface{}) AppError { + return appError{code: code, message: fmt.Sprintf(format, a...)} +} + +func Internal(format string, a ...interface{}) AppError { + return errorf(CodeInternal, format, a...) +} + +func NotFound(format string, a ...interface{}) AppError { + return errorf(CodeNotFound, format, a...) +} + +func AlreadyExists(format string, a ...interface{}) AppError { + return errorf(CodeAlreadyExists, format, a...) +} + +func WrongInput(format string, a ...interface{}) AppError { + return errorf(CodeWrongInput, format, a...) +} + +func Forbidden(format string, a ...interface{}) AppError { + return errorf(CodeForbidden, format, a...) +} +func (ae appError) Code() int { + return ae.code +} + +func (ae appError) Error() string { + return ae.message +} diff --git a/components/connector-service/internal/apperrors/apperrors_test.go b/components/connector-service/internal/apperrors/apperrors_test.go new file mode 100755 index 000000000000..2233c20104f1 --- /dev/null +++ b/components/connector-service/internal/apperrors/apperrors_test.go @@ -0,0 +1,34 @@ +package apperrors + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAppError(t *testing.T) { + + t.Run("should create error with proper code", func(t *testing.T) { + assert.Equal(t, CodeInternal, Internal("error").Code()) + assert.Equal(t, CodeNotFound, NotFound("error").Code()) + assert.Equal(t, CodeAlreadyExists, AlreadyExists("error").Code()) + assert.Equal(t, CodeWrongInput, WrongInput("error").Code()) + assert.Equal(t, CodeForbidden, Forbidden("error").Code()) + }) + + t.Run("should create error with simple message", func(t *testing.T) { + assert.Equal(t, "error", Internal("error").Error()) + assert.Equal(t, "error", NotFound("error").Error()) + assert.Equal(t, "error", AlreadyExists("error").Error()) + assert.Equal(t, "error", WrongInput("error").Error()) + assert.Equal(t, "error", Forbidden("error").Error()) + }) + + t.Run("should create error with formatted message", func(t *testing.T) { + assert.Equal(t, "code: 1, error: bug", Internal("code: %d, error: %s", 1, "bug").Error()) + assert.Equal(t, "code: 1, error: bug", NotFound("code: %d, error: %s", 1, "bug").Error()) + assert.Equal(t, "code: 1, error: bug", AlreadyExists("code: %d, error: %s", 1, "bug").Error()) + assert.Equal(t, "code: 1, error: bug", WrongInput("code: %d, error: %s", 1, "bug").Error()) + assert.Equal(t, "code: 1, error: bug", Forbidden("code: %d, error: %s", 1, "bug").Error()) + }) +} diff --git a/components/connector-service/internal/certificates/certificates.go b/components/connector-service/internal/certificates/certificates.go new file mode 100755 index 000000000000..109784382202 --- /dev/null +++ b/components/connector-service/internal/certificates/certificates.go @@ -0,0 +1,185 @@ +package certificates + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "math/big" + "time" + + "github.com/kyma-project/kyma/components/connector-service/internal/apperrors" +) + +const Certificate_Validity_Days = 90 + +type CertificateUtility interface { + LoadCert(encodedData []byte) (crt *x509.Certificate, appError apperrors.AppError) + LoadKey(encodedData []byte) (key *rsa.PrivateKey, appError apperrors.AppError) + LoadCSR(encodedData string) (csr *x509.CertificateRequest, appError apperrors.AppError) + CheckCSRValues(csr *x509.CertificateRequest, subject CSRSubject) apperrors.AppError + SignWithCA(caCrt *x509.Certificate, csr *x509.CertificateRequest, key *rsa.PrivateKey) (crtBase64 string, appError apperrors.AppError) +} + +type certificateUtility struct { +} + +type CSRSubject struct { + CName string + Country string + Organization string + OrganizationalUnit string + Locality string + Province string +} + +func NewCertificateUtility() CertificateUtility { + return &certificateUtility{} +} + +func (cu *certificateUtility) decodeBytesFromBase64(bytes []byte) (decodedData []byte, appError apperrors.AppError) { + data := make([]byte, base64.StdEncoding.DecodedLen(len(bytes))) + _, err := base64.StdEncoding.Decode(data, bytes) + if err != nil { + return nil, apperrors.Internal("Error while decoding base64 bytes: %s", err) + } + + return data, nil +} + +func (cu *certificateUtility) decodeStringFromBase64(bytes string) (decodedData []byte, appError apperrors.AppError) { + data, err := base64.StdEncoding.DecodeString(bytes) + if err != nil { + return nil, apperrors.Internal("Error while decoding base64 string: %s", err) + } + + return data, nil +} + +func (cu *certificateUtility) encodeStringBase64(bytes []byte) (data string) { + return base64.StdEncoding.EncodeToString(bytes) +} + +func (cu *certificateUtility) LoadCert(encodedData []byte) (crt *x509.Certificate, appError apperrors.AppError) { + + pemBlock, _ := pem.Decode(encodedData) + if pemBlock == nil { + return nil, apperrors.Internal("Error while decoding pem block") + } + + caCRT, err := x509.ParseCertificate(pemBlock.Bytes) + if err != nil { + return nil, apperrors.Internal("Error while parsing certificate: %s", err) + } + + return caCRT, nil +} + +func (cu *certificateUtility) LoadKey(encodedData []byte) (key *rsa.PrivateKey, appError apperrors.AppError) { + + pemBlock, _ := pem.Decode(encodedData) + if pemBlock == nil { + return nil, apperrors.Internal("Error while decoding pem block.") + } + + caPrivateKey, err := x509.ParsePKCS8PrivateKey(pemBlock.Bytes) + if err != nil { + return nil, apperrors.Internal("Error while parsing private key: %s", err) + } + + return caPrivateKey.(*rsa.PrivateKey), nil +} + +func (cu *certificateUtility) LoadCSR(encodedData string) (csr *x509.CertificateRequest, appError apperrors.AppError) { + decodedData, appErr := cu.decodeStringFromBase64(encodedData) + if appErr != nil { + return nil, appErr + } + + pemBlock, _ := pem.Decode(decodedData) + if pemBlock == nil { + return nil, apperrors.Internal("Error while decoding pem block.") + } + + clientCSR, err := x509.ParseCertificateRequest(pemBlock.Bytes) + if err != nil { + return nil, apperrors.Internal("Error while parsing CSR: %s", err) + } + + err = clientCSR.CheckSignature() + if err != nil { + return nil, apperrors.Internal("CSR signature invalid: %s", err) + } + + return clientCSR, nil +} + +func (cu *certificateUtility) CheckCSRValues(csr *x509.CertificateRequest, subject CSRSubject) apperrors.AppError { + if csr.Subject.CommonName != subject.CName { + return apperrors.Forbidden("CSR: Invalid CName provided.") + } + + if csr.Subject.Country == nil { + return apperrors.Forbidden("CSR: No country provided.") + } else if csr.Subject.Country[0] != subject.Country { + return apperrors.Forbidden("CSR: Invalid country provided.") + } + + if csr.Subject.Organization == nil { + return apperrors.Forbidden("CSR: No organization provided.") + } else if csr.Subject.Organization[0] != subject.Organization { + return apperrors.Forbidden("CSR: Invalid organization provided.") + } + + if csr.Subject.OrganizationalUnit == nil { + return apperrors.Forbidden("CSR: No organizational unit provided.") + } else if csr.Subject.OrganizationalUnit[0] != subject.OrganizationalUnit { + return apperrors.Forbidden("CSR: Invalid organizational unit provided.") + } + + if csr.Subject.Locality == nil { + return apperrors.Forbidden("CSR: No locality provided.") + } else if csr.Subject.Locality[0] != subject.Locality { + return apperrors.Forbidden("CSR: Invalid locality provided.") + } + + if csr.Subject.Province == nil { + return apperrors.Forbidden("CSR: No province provided.") + } else if csr.Subject.Province[0] != subject.Province { + return apperrors.Forbidden("CSR: Invalid province provided.") + } + return nil +} + +func (cu *certificateUtility) SignWithCA(caCrt *x509.Certificate, csr *x509.CertificateRequest, key *rsa.PrivateKey) ( + crtBase64 string, appError apperrors.AppError) { + + clientCRTTemplate := x509.Certificate{ + Signature: csr.Signature, + SignatureAlgorithm: csr.SignatureAlgorithm, + + PublicKeyAlgorithm: csr.PublicKeyAlgorithm, + PublicKey: csr.PublicKey, + + SerialNumber: big.NewInt(2), + Issuer: caCrt.Subject, + Subject: csr.Subject, + NotBefore: time.Now(), + NotAfter: time.Now().Add(Certificate_Validity_Days * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + + clientCrtRaw, err := x509.CreateCertificate(rand.Reader, &clientCRTTemplate, caCrt, csr.PublicKey, key) + if err != nil { + return "", apperrors.Internal("Error while creating certificate: %s", err) + } + + clientCrt := &bytes.Buffer{} + + pem.Encode(clientCrt, &pem.Block{Type: "CERTIFICATE", Bytes: clientCrtRaw}) + + return cu.encodeStringBase64(clientCrt.Bytes()), nil +} diff --git a/components/connector-service/internal/certificates/certificates_test.go b/components/connector-service/internal/certificates/certificates_test.go new file mode 100755 index 000000000000..dc1e333955cc --- /dev/null +++ b/components/connector-service/internal/certificates/certificates_test.go @@ -0,0 +1,510 @@ +package certificates + +import ( + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "testing" + + "github.com/kyma-project/kyma/components/connector-service/internal/apperrors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + privateKey = `-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQClJYXxG+pUe7oa +XBHnbxR+5oEMtD3Ft01TXhq0Ad/0+5+qgSZ1GNE6t8dO9q5syo237ZQ1kWHXIs0T +sCD2aWYsAIlr/R9f0ED3oOkiYx0DVV8849+eEcaKFhyzUBkm1zns+MjunYBWyR0o +J/uJO3mAszd8wTNRoEd5X4KcKTikIMkIttip35dcH6Nf5jDF0QIamOET3rp4T2rm +A3Vc+v0xChzEdKiTiVaT9LLaKeL1OUplJa90MoHZ4zHLLqMFiX12j/rdWzVkyi2M +0dS8ynEtpVhvxvna5vaooY3yr2SDKyv4+Zf2ZKrfenS5Dru8QVrGH8yDhOhTQeFC +kB+LA6RNAgMBAAECggEAMd3DtQs11a7Kgh0c9uIOsUbO3tQp9uKjgbHfpE0Qn/u+ +uZBn2WHWA8Hsd8Z64rTC2C/v2cD9ZyXGANTlDyLCTDUZSbdT2u2aQGuhGdYNs6z6 +pfs00ZkSdy24Gtjrz1Ob1RdGLO74CryNhkuUY1rHFHqJHa2E3nfkPRz+5kJ4LO6R +Mt4m1XKpNdi6Eg/WurnCKiIgq8yPckhYRuMhF3aNNevvqKGlL4QSYHMCjXAw18E4 +XqhcH5Y8ZhUR8Nv87N6dgTOHGXNYHyGN1ZpNii6+jb+JgqeitbUXI4SCh9ev+S/Y +G5qE/DjoP6JtcQH/2GoFJw5muIijt/C6fUESQH/0YQKBgQDRvA8EEkamKaWVl0MM +BeY90GqDklkmALctQ+4zgSAm//BzofGZWYyNWexV30xlAgz5xKbBU1xh7f48BR7C +La21HnTPgcp+h/GLywKPxD117drjX1GfMLZQRK4QQhNF1WNvv9NTWfq/2wmQCvtD +FLdobgRoEbR5YRvcYglRPD+F1QKBgQDJk4ev6NUGC1Gg387En9QJ7aE3xWwhdsGr +R5zsIk+/dM7Qzbw6aAm5Ui4ZUPyWcHmb2qlnSsJHwv8AOi5IJJCalxfrHYrE4TJz +RjZ2PVst7Y1uvGHhSlID39/NEEW033wKQ2MWdPwYG15eBL6pYX1ThaLhBhMUMGFQ +dxL3UmYImQKBgQDC67BY7FNUomgNuuLJDcKJuGUFmsHXm9qh6vw6ScuD82GZVeyf +xKXnyKbot/rb9SfyCV2hVsQJD5K0XV3UwXcrWP7ey5VSOy216hqbWpp0O3au0iud +czw9JVdQLNiUklkzxme0k2+DVyJwCIS0N1CtcXIO9kVweVvXWhWmtgOjcQKBgQCy +5tn1OOrfi2ouIpSLk+KH0TxVmEUoyhKG5m8ScD1hCdWIIiBdofqHXLWHSIZ1Kmvz +9DSHdSVKtXjGhdyPsMwaN+FFjZmctNWm03kApeHnuD7fOhiQ7/oscCRcBoYnSnX3 +Uel+g+M9rgSp4wIoqFqnpyJxHogOUgX8eUH++UWPeQKBgQC/NBa3TNFgFcn5eWZS +7qsVMFeIhvXOdKqrBhbIR32C2px+OW93TbMOKdnpQSFlbmvYBkqAQ+TsjC3r8u33 +xwfuqKS+KeByw2+7ac53rRZ806IMjZNHiX+N9HakgqdQdM8XwNG2GIpPQ2VOMCu0 +iEWRwvmD+/sYdSyhdEAZYoAndA== +-----END PRIVATE KEY-----` + + cert = `-----BEGIN CERTIFICATE----- +MIIDhDCCAmwCCQCCgClWcqHk4DANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMC +VVMxDjAMBgNVBAgMBXN0YXRlMQ0wCwYDVQQHDARjaXR5MRAwDgYDVQQKDAdjb21w +YW55MRAwDgYDVQQLDAdzZWN0aW9uMRQwEgYDVQQDDAtob3N0LmV4LmNvbTEbMBkG +CSqGSIb3DQEJARYMZW1haWxAZXguY29tMB4XDTE4MDYxMTA4MDMyM1oXDTIxMDMz +MTA4MDMyM1owgYMxCzAJBgNVBAYTAlVTMQ4wDAYDVQQIDAVzdGF0ZTENMAsGA1UE +BwwEY2l0eTEQMA4GA1UECgwHY29tcGFueTEQMA4GA1UECwwHc2VjdGlvbjEUMBIG +A1UEAwwLaG9zdC5leC5jb20xGzAZBgkqhkiG9w0BCQEWDGVtYWlsQGV4LmNvbTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKUlhfEb6lR7uhpcEedvFH7m +gQy0PcW3TVNeGrQB3/T7n6qBJnUY0Tq3x072rmzKjbftlDWRYdcizROwIPZpZiwA +iWv9H1/QQPeg6SJjHQNVXzzj354RxooWHLNQGSbXOez4yO6dgFbJHSgn+4k7eYCz +N3zBM1GgR3lfgpwpOKQgyQi22Knfl1wfo1/mMMXRAhqY4RPeunhPauYDdVz6/TEK +HMR0qJOJVpP0stop4vU5SmUlr3QygdnjMcsuowWJfXaP+t1bNWTKLYzR1LzKcS2l +WG/G+drm9qihjfKvZIMrK/j5l/Zkqt96dLkOu7xBWsYfzIOE6FNB4UKQH4sDpE0C +AwEAATANBgkqhkiG9w0BAQsFAAOCAQEAYccH2RdbliHmEVhTajfId66xl0lmwTVx +rVkMRvtEJ1M8rIwABVCu/w+DSorm8sNq8n9ZCwhXflFCEySk8wevg5/lLtSz4ghn +A97O/CNEABohwLZXQYkOQqGDXz6yWmCugtt8Y5of16NDj2AzqXZ++tUvo/CvB/Q8 +1iL+JpgQs15b0QEIpXRyxOAc5FdHm+I9qtx+BeA3I3tMPhYlM9mDVev8fdHtURN8 +9QM4wWFHncmNvlTK51HPexFI3TF9sEqDUQ7dozcUD8GexHlsvZh95+5TmSlA0kfl +fWXUGZObOGD246zwfHLHP3AwzFKU0bfIvqckcw23I+ZUMIbdajw9eg== +-----END CERTIFICATE-----` + + CSR = `LS0tLS1CRUdJTiBDRVJUSUZJQ0FURSBSRVFVRVNULS0tLS0KTUlJQ1d6Q0NBVU1D +QVFBd0ZqRVVNQklHQTFVRUF3d0xhRzl6ZEM1bGVDNWpiMjB3Z2dFaU1BMEdDU3FH +U0liMwpEUUVCQVFVQUE0SUJEd0F3Z2dFS0FvSUJBUUN4Rk1zN3cxdDMwbVpxVjhx +SzgvN1BrSlRkSVd6NUFPSkVJdTBMCmlRWU9pYlNDeG5RU2o5RmlVRW54VTNDaldx +dHhQNFZjU2hDY283OG1nZVk3cnBTRktXejNKV2h3QnIzVmtnU0YKSFBDUVdOTjB3 +TFVEdTdXVkJrVmtpRFVzK05iejMvUG85b2g0K3N5WktmcmRrSFZYcFNlcG9VOEds +K0tiOEFwYgo1NVEvUlcvVDBNWkhKNHNKcHMwajNmanB5UVVLSDlwdDk3NjhBdzNj +SjhLdVM4TXRnTlM0NitQbmp0QTAydGViCnpRZGZPNkY2bWk3UGZHOVdwN0FYOWNK +TnVrR2laYU1qc2s5M09yUGVpNGMzQ0lrS0VCc3FWdEM0eVZYd2svc3oKVUQrS3hT +RHJFcStOd2Z4SFk1cGVtSFZJTjJVNFl1TmZLK3hFdEcydHJWSUl1OG9UQWdNQkFB +R2dBREFOQmdrcQpoa2lHOXcwQkFRc0ZBQU9DQVFFQVZtVXF0K2hrdjd1cldFMmVS +enFGdUhvaTVRQ2ZYYWV4NzcrM0F2eXQrY0FoCkZqazgwVkxnREJJUkpGN2RCWnI2 +d2VtUStoMzZGajV6RDUxSWpnb2w0Vm5ORFE0MkdXaVlwb2NBaFhHUU0xZ1YKWUJS +Qk1VT1NQNG81bk1yUnJxbFUzSG1ZWHhHNVpleVluQW4rcjJzWFh4Z3lMYUZaY1Vx +VEo0R1RwdFlMbDd5UAovYVl3K2Jyamo5ZEtMNHNGc1NkbXpTV0k4MnpCRmFMdUMr +UkZDaTRSYTZwQm1vMW5vZ3c0R3pMWlAvVTFMQktMCjhuTEJ5L1ZyaDVxSCtWUW1T +SEdTQ3h1SnB4STQvZXFtWWRFbis3T1JqSWltVXFqQ0RsOWp1dGhSakpmNHVwdjIK +U0ZRVnZMbkxhdncxV2FPNFhHRFBZcWZQdndsbnJZSXdjMUFHQmVnTjhnPT0KLS0t +LS1FTkQgQ0VSVElGSUNBVEUgUkVRVUVTVC0tLS0tCg==` + + invalidCert = `-----BEGIN CERTIFICATE----- +-----END CERTIFICATE-----` + + invalidKey = `-----BEGIN RSA PRIVATE KEY----- +-----END RSA PRIVATE KEY-----` + + invalidCSR = `LS0tLS1CRUdJTiBDRVJUSUZJQ0FURSBSRVFVRVNULS0tLS0KLS0tLS1FTkQgQ0VS +VElGSUNBVEUgUkVRVUVTVC0tLS0tCg==` + + countryCode = "US" + province = "state" + locality = "city" + organization = "company" + organizationalUnit = "section" + commonName = "host.ex.com" +) + +var ( + encodedKey = []byte(privateKey) + encodedCert = []byte(cert) + encodedInvalidCert = []byte(invalidCert) + encodedInvalidKey = []byte(invalidKey) +) + +func TestCertificateUtility_LoadCert(t *testing.T) { + + t.Run("should load cert", func(t *testing.T) { + // given + certificateUtility := NewCertificateUtility() + + // when + crt, err := certificateUtility.LoadCert(encodedCert) + + // then + require.NoError(t, err) + + assert.Equal(t, countryCode, crt.Subject.Country[0]) + assert.Equal(t, province, crt.Subject.Province[0]) + assert.Equal(t, locality, crt.Subject.Locality[0]) + assert.Equal(t, organization, crt.Subject.Organization[0]) + assert.Equal(t, organizationalUnit, crt.Subject.OrganizationalUnit[0]) + assert.Equal(t, commonName, crt.Subject.CommonName) + assert.Equal(t, commonName, crt.Subject.CommonName) + }) + + t.Run("should fail decoding cert", func(t *testing.T) { + // given + certificateUtility := NewCertificateUtility() + + // when + crt, err := certificateUtility.LoadCert([]byte("invalid data")) + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.Nil(t, crt) + }) + + t.Run("should fail parsing cert", func(t *testing.T) { + // given + certificateUtility := NewCertificateUtility() + + // when + crt, err := certificateUtility.LoadCert(encodedInvalidCert) + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.Nil(t, crt) + }) +} + +func TestCertificateUtility_LoadKey(t *testing.T) { + + t.Run("should load key", func(t *testing.T) { + // given + certificateUtility := NewCertificateUtility() + + // when + key, err := certificateUtility.LoadKey(encodedKey) + + // then + require.NoError(t, err) + assert.NotNil(t, key) + }) + + t.Run("should fail decoding key", func(t *testing.T) { + // given + certificateUtility := NewCertificateUtility() + + // when + crt, err := certificateUtility.LoadKey([]byte("invalid data")) + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.Nil(t, crt) + }) + + t.Run("should fail parsing key", func(t *testing.T) { + // given + certificateUtility := NewCertificateUtility() + + // when + crt, err := certificateUtility.LoadKey(encodedInvalidKey) + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.Nil(t, crt) + }) +} + +func TestCertificateUtility_LoadCSR(t *testing.T) { + + t.Run("should load CSR", func(t *testing.T) { + // given + certificateUtility := NewCertificateUtility() + + // when + key, err := certificateUtility.LoadCSR(CSR) + + // then + require.NoError(t, err) + assert.NotNil(t, key) + }) + + t.Run("should fail decoding base64", func(t *testing.T) { + // given + certificateUtility := NewCertificateUtility() + + // when + crt, err := certificateUtility.LoadCSR("invalid base64") + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.Nil(t, crt) + }) + + t.Run("should fail decoding CSR", func(t *testing.T) { + // given + certificateUtility := NewCertificateUtility() + + // when + crt, err := certificateUtility.LoadCSR("aW52YWxpZCBkYXRh") + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.Nil(t, crt) + }) + + t.Run("should fail parsing CSR", func(t *testing.T) { + // given + certificateUtility := NewCertificateUtility() + + // when + crt, err := certificateUtility.LoadCSR(invalidCSR) + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.Nil(t, crt) + }) + + t.Run("should fail checking signature", func(t *testing.T) { + // given + invalidSignatureCSR := `LS0tLS1CRUdJTiBDRVJUSUZJQ0FURSBSRVFVRVNULS0tLS0KTUlJQ1dqQ0NBVUlD +QVFBd0ZURVRNQkVHQTFVRUF3d0taV010WkdWbVlYVnNkRENDQVNJd0RRWUpLb1pJ +aHZjTgpBUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTEVVeXp2RFczZlNabXBYeW9y +ei9zK1FsTjBoYlBrQTRrUWk3UXVKCkJnNkp0SUxHZEJLUDBXSlFTZkZUY0tOYXEz +RS9oVnhLRUp5anZ5YUI1anV1bElVcGJQY2xhSEFHdmRXU0JJVWMKOEpCWTAzVEF0 +UU83dFpVR1JXU0lOU3o0MXZQZjgrajJpSGo2ekprcCt0MlFkVmVsSjZtaFR3YVg0 +cHZ3Q2x2bgpsRDlGYjlQUXhrY25pd21telNQZCtPbkpCUW9mMm0zM3Zyd0REZHdu +d3E1THd5MkExTGpyNCtlTzBEVGExNXZOCkIxODdvWHFhTHM5OGIxYW5zQmYxd2sy +NlFhSmxveU95VDNjNnM5NkxoemNJaVFvUUd5cFcwTGpKVmZDVCt6TlEKUDRyRklP +c1NyNDNCL0Vkam1sNllkVWczWlRoaTQxOHI3RVMwYmEydFVnaTd5aE1DQXdFQUFh +QUFNQTBHQ1NxRwpTSWIzRFFFQkN3VUFBNElCQVFBZG1hK3RqanROc0owK1dEUzl2 +K1RidHJOcDNGNEVjNWgyeVF6ek14cFFrclVoCi9wdHF2UGJrcDZTTnBjSk9HeTZE +NkU0Wm9oNXhQbjdMdVFFZlJyWUtHQ09RRUVLcXZUdGU1eFBDK0g4MWRmbFMKYXZm +RFpuTGtXRkFVa2h5aG84MWVXNjlkVmRwUzBsUnFQUjdPL0t6dXRjeG51cnBlU3JK +TWtabml4ajEwY05FYwpXVGZhQmcxdjIvWXRJZTQ0aVBUak5ZZE9lWHk0eTZGdnFq +UXRDSGdmUDlMUDZaY1dCYndwd0VBcE1YNXQwWXAxCnVPNVAxOEFza0lTNXVBK0ZB +cEF1dGVhY2hBSHZyUWJLUFZLNlJQM3dyUjlEdXpqMkJ4WU9GZVdvOWVxS1J6Q08K +NEJNRStVakZFcDRZdjRVM3czUFdZeVFqNC9zTGNPWmZlVys2d0tVdQotLS0tLUVO +RCBDRVJUSUZJQ0FURSBSRVFVRVNULS0tLS0K` + + certificateUtility := NewCertificateUtility() + + // when + crt, err := certificateUtility.LoadCSR(invalidSignatureCSR) + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.Nil(t, crt) + }) +} + +func TestCertificateUtility_CheckCSRValues(t *testing.T) { + + csr := &x509.CertificateRequest{ + Subject: pkix.Name{ + CommonName: "cname", + Country: []string{"country"}, + Organization: []string{"organization"}, + OrganizationalUnit: []string{"organizationalUnit"}, + Locality: []string{"locality"}, + Province: []string{"province"}, + }, + } + + t.Run("should successfully check CSR values", func(t *testing.T) { + // given + csrSubject := CSRSubject{ + CName: "cname", + Country: "country", + Organization: "organization", + OrganizationalUnit: "organizationalUnit", + Locality: "locality", + Province: "province", + } + + certificateUtility := NewCertificateUtility() + + // when + err := certificateUtility.CheckCSRValues(csr, csrSubject) + + // then + require.NoError(t, err) + }) + + t.Run("should fail when subject country is nil", func(t *testing.T) { + // given + csrSubject := CSRSubject{ + CName: "cname", + } + + csr := &x509.CertificateRequest{ + Subject: pkix.Name{ + CommonName: "cname", + }, + } + + certificateUtility := NewCertificateUtility() + + // when + err := certificateUtility.CheckCSRValues(csr, csrSubject) + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeForbidden, err.Code()) + assert.Contains(t, err.Error(), "No country") + }) + + t.Run("should fail when CName differs", func(t *testing.T) { + // given + csrSubject := CSRSubject{ + CName: "differentCname", + Country: "country", + Organization: "organization", + OrganizationalUnit: "organizationalUnit", + Locality: "locality", + Province: "province", + } + + certificateUtility := NewCertificateUtility() + + // when + err := certificateUtility.CheckCSRValues(csr, csrSubject) + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeForbidden, err.Code()) + assert.Contains(t, err.Error(), "Invalid CName") + }) + + t.Run("should fail when Country differs", func(t *testing.T) { + // given + csrSubject := CSRSubject{ + CName: "cname", + Country: "invalidCountry", + Organization: "organization", + OrganizationalUnit: "organizationalUnit", + Locality: "locality", + Province: "province", + } + + certificateUtility := NewCertificateUtility() + + // when + err := certificateUtility.CheckCSRValues(csr, csrSubject) + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeForbidden, err.Code()) + assert.Contains(t, err.Error(), "Invalid country") + + }) + + t.Run("should fail when Organization differs", func(t *testing.T) { + // given + csrSubject := CSRSubject{ + CName: "cname", + Country: "country", + Organization: "invalidOrganization", + OrganizationalUnit: "organizationalUnit", + Locality: "locality", + Province: "province", + } + + certificateUtility := NewCertificateUtility() + + // when + err := certificateUtility.CheckCSRValues(csr, csrSubject) + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeForbidden, err.Code()) + assert.Contains(t, err.Error(), "CSR: Invalid organization provided.") + }) + + t.Run("should fail when OrganizationalUnit differs", func(t *testing.T) { + // given + csrSubject := CSRSubject{ + CName: "cname", + Country: "country", + Organization: "organization", + OrganizationalUnit: "invalidOrganizationalUnit", + Locality: "locality", + Province: "province", + } + + certificateUtility := NewCertificateUtility() + + // when + err := certificateUtility.CheckCSRValues(csr, csrSubject) + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeForbidden, err.Code()) + assert.Contains(t, err.Error(), "CSR: Invalid organizational unit provided.") + }) + + t.Run("should fail when Locality differs", func(t *testing.T) { + // given + csrSubject := CSRSubject{ + CName: "cname", + Country: "country", + Organization: "organization", + OrganizationalUnit: "organizationalUnit", + Locality: "invalidLocality", + Province: "province", + } + + certificateUtility := NewCertificateUtility() + + // when + err := certificateUtility.CheckCSRValues(csr, csrSubject) + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeForbidden, err.Code()) + assert.Contains(t, err.Error(), "CSR: Invalid locality provided.") + }) + + t.Run("should fail when Province differs", func(t *testing.T) { + // given + csrSubject := CSRSubject{ + CName: "cname", + Country: "country", + Organization: "organization", + OrganizationalUnit: "organizationalUnit", + Locality: "locality", + Province: "invalidProvince", + } + + certificateUtility := NewCertificateUtility() + + // when + err := certificateUtility.CheckCSRValues(csr, csrSubject) + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeForbidden, err.Code()) + assert.Contains(t, err.Error(), "CSR: Invalid province provided.") + }) +} + +func TestCertificateUtility_SignWithCA(t *testing.T) { + + t.Run("should create client certificate", func(t *testing.T) { + // given + certificateUtility := NewCertificateUtility() + + caCrt, _ := certificateUtility.LoadCert(encodedCert) + csr, _ := certificateUtility.LoadCSR(CSR) + key, _ := certificateUtility.LoadKey(encodedKey) + + // when + crtBase64, err := certificateUtility.SignWithCA(caCrt, csr, key) + + // then + require.NoError(t, err) + assert.NotEqual(t, "", crtBase64) + }) + + t.Run("should fail creating certificate", func(t *testing.T) { + // given + caCrt := &x509.Certificate{} + csr := &x509.CertificateRequest{} + key := &rsa.PrivateKey{} + + certificateUtility := NewCertificateUtility() + + // when + crtBase64, err := certificateUtility.SignWithCA(caCrt, csr, key) + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.Equal(t, "", crtBase64) + }) +} diff --git a/components/connector-service/internal/certificates/mocks/CertificateUtility.go b/components/connector-service/internal/certificates/mocks/CertificateUtility.go new file mode 100755 index 000000000000..5cc8c6edd055 --- /dev/null +++ b/components/connector-service/internal/certificates/mocks/CertificateUtility.go @@ -0,0 +1,127 @@ +// Code generated by mockery v1.0.0 +package mocks + +import apperrors "github.com/kyma-project/kyma/components/connector-service/internal/apperrors" +import certificates "github.com/kyma-project/kyma/components/connector-service/internal/certificates" +import mock "github.com/stretchr/testify/mock" +import rsa "crypto/rsa" +import x509 "crypto/x509" + +// CertificateUtility is an autogenerated mock type for the CertificateUtility type +type CertificateUtility struct { + mock.Mock +} + +// CheckCSRValues provides a mock function with given fields: csr, subject +func (_m *CertificateUtility) CheckCSRValues(csr *x509.CertificateRequest, subject certificates.CSRSubject) apperrors.AppError { + ret := _m.Called(csr, subject) + + var r0 apperrors.AppError + if rf, ok := ret.Get(0).(func(*x509.CertificateRequest, certificates.CSRSubject) apperrors.AppError); ok { + r0 = rf(csr, subject) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(apperrors.AppError) + } + } + + return r0 +} + +// LoadCSR provides a mock function with given fields: encodedData +func (_m *CertificateUtility) LoadCSR(encodedData string) (*x509.CertificateRequest, apperrors.AppError) { + ret := _m.Called(encodedData) + + var r0 *x509.CertificateRequest + if rf, ok := ret.Get(0).(func(string) *x509.CertificateRequest); ok { + r0 = rf(encodedData) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*x509.CertificateRequest) + } + } + + var r1 apperrors.AppError + if rf, ok := ret.Get(1).(func(string) apperrors.AppError); ok { + r1 = rf(encodedData) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(apperrors.AppError) + } + } + + return r0, r1 +} + +// LoadCert provides a mock function with given fields: encodedData +func (_m *CertificateUtility) LoadCert(encodedData []byte) (*x509.Certificate, apperrors.AppError) { + ret := _m.Called(encodedData) + + var r0 *x509.Certificate + if rf, ok := ret.Get(0).(func([]byte) *x509.Certificate); ok { + r0 = rf(encodedData) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*x509.Certificate) + } + } + + var r1 apperrors.AppError + if rf, ok := ret.Get(1).(func([]byte) apperrors.AppError); ok { + r1 = rf(encodedData) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(apperrors.AppError) + } + } + + return r0, r1 +} + +// LoadKey provides a mock function with given fields: encodedData +func (_m *CertificateUtility) LoadKey(encodedData []byte) (*rsa.PrivateKey, apperrors.AppError) { + ret := _m.Called(encodedData) + + var r0 *rsa.PrivateKey + if rf, ok := ret.Get(0).(func([]byte) *rsa.PrivateKey); ok { + r0 = rf(encodedData) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*rsa.PrivateKey) + } + } + + var r1 apperrors.AppError + if rf, ok := ret.Get(1).(func([]byte) apperrors.AppError); ok { + r1 = rf(encodedData) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(apperrors.AppError) + } + } + + return r0, r1 +} + +// SignWithCA provides a mock function with given fields: caCrt, csr, key +func (_m *CertificateUtility) SignWithCA(caCrt *x509.Certificate, csr *x509.CertificateRequest, key *rsa.PrivateKey) (string, apperrors.AppError) { + ret := _m.Called(caCrt, csr, key) + + var r0 string + if rf, ok := ret.Get(0).(func(*x509.Certificate, *x509.CertificateRequest, *rsa.PrivateKey) string); ok { + r0 = rf(caCrt, csr, key) + } else { + r0 = ret.Get(0).(string) + } + + var r1 apperrors.AppError + if rf, ok := ret.Get(1).(func(*x509.Certificate, *x509.CertificateRequest, *rsa.PrivateKey) apperrors.AppError); ok { + r1 = rf(caCrt, csr, key) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(apperrors.AppError) + } + } + + return r0, r1 +} diff --git a/components/connector-service/internal/errorhandler/errorhandler.go b/components/connector-service/internal/errorhandler/errorhandler.go new file mode 100755 index 000000000000..560737803d4a --- /dev/null +++ b/components/connector-service/internal/errorhandler/errorhandler.go @@ -0,0 +1,27 @@ +package errorhandler + +import ( + "encoding/json" + + "net/http" + + "github.com/kyma-project/kyma/components/connector-service/internal/httpconsts" + "github.com/kyma-project/kyma/components/connector-service/internal/httperrors" +) + +type ErrorHandler struct { + Message string + Code int +} + +func NewErrorHandler(code int, message string) *ErrorHandler { + return &ErrorHandler{Message: message, Code: code} +} + +func (eh *ErrorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + responseBody := httperrors.ErrorResponse{Code: eh.Code, Error: eh.Message} + + w.Header().Set(httpconsts.HeaderContentType, httpconsts.ContentTypeApplicationJson) + w.WriteHeader(eh.Code) + json.NewEncoder(w).Encode(responseBody) +} diff --git a/components/connector-service/internal/errorhandler/errorhandler_test.go b/components/connector-service/internal/errorhandler/errorhandler_test.go new file mode 100755 index 000000000000..bb1ce155390d --- /dev/null +++ b/components/connector-service/internal/errorhandler/errorhandler_test.go @@ -0,0 +1,43 @@ +package errorhandler + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/kyma-project/kyma/components/connector-service/internal/httperrors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestErrorHandler_ServeHTTP(t *testing.T) { + t.Run("Should always respond with given error and status code", func(t *testing.T) { + + r := mux.NewRouter() + + r.NotFoundHandler = NewErrorHandler(404, "Requested resource could not be found.") + ts := httptest.NewServer(r) + defer ts.Close() + + // when + res, err := http.Get(ts.URL + "/wrong/path") + + responseBody, err := ioutil.ReadAll(res.Body) + if err != nil { + assert.Fail(t, "Failure while reading response body.") + } + defer res.Body.Close() + + var errResponse httperrors.ErrorResponse + + json.Unmarshal(responseBody, &errResponse) + + // then + require.NoError(t, err) + assert.Equal(t, http.StatusNotFound, errResponse.Code) + assert.Equal(t, http.StatusNotFound, res.StatusCode) + }) +} diff --git a/components/connector-service/internal/externalapi/externalapi.go b/components/connector-service/internal/externalapi/externalapi.go new file mode 100755 index 000000000000..cae005ebcb79 --- /dev/null +++ b/components/connector-service/internal/externalapi/externalapi.go @@ -0,0 +1,30 @@ +package externalapi + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/kyma-project/kyma/components/connector-service/internal/errorhandler" +) + +type SignatureHandler interface { + SignCSR(w http.ResponseWriter, r *http.Request) +} + +type InfoHandler interface { + GetInfo(w http.ResponseWriter, r *http.Request) +} + +func NewHandler(sHandler SignatureHandler, iHandler InfoHandler) http.Handler { + router := mux.NewRouter() + + registrationRouter := router.PathPrefix("/v1/remoteenvironments").Subrouter() + + registrationRouter.HandleFunc("/{reName}/client-certs", sHandler.SignCSR).Methods(http.MethodPost) + registrationRouter.HandleFunc("/{reName}/info", iHandler.GetInfo).Methods(http.MethodGet) + + router.NotFoundHandler = errorhandler.NewErrorHandler(404, "Requested resource could not be found.") + router.MethodNotAllowedHandler = errorhandler.NewErrorHandler(405, "Method not allowed.") + + return router +} diff --git a/components/connector-service/internal/externalapi/infohandler.go b/components/connector-service/internal/externalapi/infohandler.go new file mode 100755 index 000000000000..3ea2498f4604 --- /dev/null +++ b/components/connector-service/internal/externalapi/infohandler.go @@ -0,0 +1,85 @@ +package externalapi + +import ( + "fmt" + "net/http" + + "github.com/gorilla/mux" + "github.com/kyma-project/kyma/components/connector-service/internal/apperrors" + "github.com/kyma-project/kyma/components/connector-service/internal/certificates" + "github.com/kyma-project/kyma/components/connector-service/internal/tokens" + "github.com/kyma-project/kyma/components/connector-service/internal/tokens/tokencache" +) + +const ( + CertUrl = "https://%s/v1/remoteenvironments/%s" + SignUrl = "https://%s/v1/remoteenvironments/%s/client-certs?token=%s" + APIUrl = "https://gateway.%s/%s/v1/" +) + +type infoHandler struct { + tokenCache tokencache.TokenCache + tokenGenerator tokens.TokenGenerator + host string + domainName string + csr csrInfo +} + +func NewInfoHandler(cache tokencache.TokenCache, tokenGenerator tokens.TokenGenerator, host string, domainName string, subjectValues certificates.CSRSubject) InfoHandler { + csr := csrInfo{ + Country: subjectValues.Country, + Organization: subjectValues.Organization, + OrganizationalUnit: subjectValues.OrganizationalUnit, + Locality: subjectValues.Locality, + Province: subjectValues.Province, + } + + return &infoHandler{tokenCache: cache, tokenGenerator: tokenGenerator, host: host, domainName: domainName, csr: csr} +} + +func (ih *infoHandler) GetInfo(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + if token == "" { + respondWithError(w, apperrors.Forbidden("Token not provided.")) + return + } + + reName := mux.Vars(r)["reName"] + + cachedToken, found := ih.tokenCache.Get(reName) + + if !found || cachedToken != token { + respondWithError(w, apperrors.Forbidden("Invalid token.")) + return + } + + newToken, err := ih.tokenGenerator.NewToken(reName) + if err != nil { + respondWithError(w, apperrors.Internal("Failed to generate new token.")) + return + } + + signUrl := fmt.Sprintf(SignUrl, ih.host, reName, newToken) + certUrl := fmt.Sprintf(CertUrl, ih.host, reName) + + apiUrl := fmt.Sprintf(APIUrl, ih.domainName, reName) + api := api{ + MetadataURL: apiUrl + "metadata/services", + EventsURL: apiUrl + "events", + CertificatesUrl: certUrl, + } + + certInfo := makeCertInfo(ih.csr, reName) + + respondWithBody(w, 200, infoResponse{SignUrl: signUrl, Api: api, CertificateInfo: certInfo}) +} + +func makeCertInfo(info csrInfo, reName string) certInfo { + subject := fmt.Sprintf("OU=%s,O=%s,L=%s,ST=%s,C=%s,CN=%s", info.OrganizationalUnit, info.Organization, info.Locality, info.Province, info.Country, reName) + + return certInfo{ + Subject: subject, + Extensions: "", + KeyAlgorithm: "rsa2048", + } +} diff --git a/components/connector-service/internal/externalapi/infohandler_test.go b/components/connector-service/internal/externalapi/infohandler_test.go new file mode 100755 index 000000000000..b7293548db2a --- /dev/null +++ b/components/connector-service/internal/externalapi/infohandler_test.go @@ -0,0 +1,240 @@ +package externalapi + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/kyma-project/kyma/components/connector-service/internal/apperrors" + "github.com/kyma-project/kyma/components/connector-service/internal/certificates" + "github.com/kyma-project/kyma/components/connector-service/internal/httperrors" + tokenMocks "github.com/kyma-project/kyma/components/connector-service/internal/tokens/mocks" + tokenCacheMocks "github.com/kyma-project/kyma/components/connector-service/internal/tokens/tokencache/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInfoHandler_GetInfo(t *testing.T) { + + t.Run("should get info", func(t *testing.T) { + // given + newToken := "newToken" + url := fmt.Sprintf("/v1/remoteenvironments/%s/client-cert?token=%s", reName, token) + + expectedSignUrl := fmt.Sprintf("https://%s/v1/remoteenvironments/%s/client-certs?token=%s", host, reName, newToken) + + expectedApi := api{ + MetadataURL: fmt.Sprintf("https://gateway.%s/%s/v1/metadata/services", domain, reName), + EventsURL: fmt.Sprintf("https://gateway.%s/%s/v1/events", domain, reName), + CertificatesUrl: fmt.Sprintf("https://%s/v1/remoteenvironments/%s", host, reName), + } + + expectedCertInfo := certInfo{ + Subject: fmt.Sprintf("OU=%s,O=%s,L=%s,ST=%s,C=%s,CN=%s", organizationalUnit, organization, locality, province, country, reName), + Extensions: "", + KeyAlgorithm: "rsa2048", + } + + tokenCache := &tokenCacheMocks.TokenCache{} + tokenCache.On("Get", reName).Return(token, true) + + tokenGenerator := &tokenMocks.TokenGenerator{} + tokenGenerator.On("NewToken", reName).Return(newToken, nil) + + subjectValues := certificates.CSRSubject{ + CName: reName, + Country: country, + Organization: organization, + OrganizationalUnit: organizationalUnit, + Locality: locality, + Province: province, + } + infoHandler := NewInfoHandler(tokenCache, tokenGenerator, host, domain, subjectValues) + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(tokenRequestRaw)) + require.NoError(t, err) + rr := httptest.NewRecorder() + + req = mux.SetURLVars(req, map[string]string{"reName": reName}) + + // when + infoHandler.GetInfo(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var infoResponse infoResponse + err = json.Unmarshal(responseBody, &infoResponse) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, expectedSignUrl, infoResponse.SignUrl) + assert.EqualValues(t, expectedApi, infoResponse.Api) + assert.EqualValues(t, expectedCertInfo, infoResponse.CertificateInfo) + }) + + t.Run("should return 403 when token not provided", func(t *testing.T) { + // given + url := fmt.Sprintf("/v1/remoteenvironments/%s/client-cert", reName) + + tokenCache := &tokenCacheMocks.TokenCache{} + tokenGenerator := &tokenMocks.TokenGenerator{} + + subjectValues := certificates.CSRSubject{ + CName: reName, + Country: country, + Organization: organization, + OrganizationalUnit: organizationalUnit, + Locality: locality, + Province: province, + } + infoHandler := NewInfoHandler(tokenCache, tokenGenerator, host, domain, subjectValues) + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(tokenRequestRaw)) + require.NoError(t, err) + rr := httptest.NewRecorder() + + req = mux.SetURLVars(req, map[string]string{"reName": reName}) + + // when + infoHandler.GetInfo(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var errorResponse httperrors.ErrorResponse + err = json.Unmarshal(responseBody, &errorResponse) + require.NoError(t, err) + + assert.Equal(t, http.StatusForbidden, errorResponse.Code) + assert.Equal(t, http.StatusForbidden, rr.Code) + }) + + t.Run("should return 403 when token not found", func(t *testing.T) { + // given + url := fmt.Sprintf("/v1/remoteenvironments/%s/client-cert?token=%s", reName, token) + + tokenCache := &tokenCacheMocks.TokenCache{} + tokenCache.On("Get", reName).Return("", false) + + tokenGenerator := &tokenMocks.TokenGenerator{} + + subjectValues := certificates.CSRSubject{ + CName: reName, + Country: country, + Organization: organization, + OrganizationalUnit: organizationalUnit, + Locality: locality, + Province: province, + } + infoHandler := NewInfoHandler(tokenCache, tokenGenerator, host, domain, subjectValues) + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(tokenRequestRaw)) + require.NoError(t, err) + rr := httptest.NewRecorder() + + req = mux.SetURLVars(req, map[string]string{"reName": reName}) + + // when + infoHandler.GetInfo(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var errorResponse httperrors.ErrorResponse + err = json.Unmarshal(responseBody, &errorResponse) + require.NoError(t, err) + + assert.Equal(t, http.StatusForbidden, errorResponse.Code) + assert.Equal(t, http.StatusForbidden, rr.Code) + }) + + t.Run("should return 403 when wrong token provided", func(t *testing.T) { + // given + url := fmt.Sprintf("/v1/remoteenvironments/%s/client-cert?token=%s", reName, token) + + tokenCache := &tokenCacheMocks.TokenCache{} + tokenCache.On("Get", reName).Return("differentToken", true) + + tokenGenerator := &tokenMocks.TokenGenerator{} + + subjectValues := certificates.CSRSubject{ + CName: reName, + Country: country, + Organization: organization, + OrganizationalUnit: organizationalUnit, + Locality: locality, + Province: province, + } + infoHandler := NewInfoHandler(tokenCache, tokenGenerator, host, domain, subjectValues) + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(tokenRequestRaw)) + require.NoError(t, err) + rr := httptest.NewRecorder() + + req = mux.SetURLVars(req, map[string]string{"reName": reName}) + + // when + infoHandler.GetInfo(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var errorResponse httperrors.ErrorResponse + err = json.Unmarshal(responseBody, &errorResponse) + require.NoError(t, err) + + assert.Equal(t, http.StatusForbidden, errorResponse.Code) + assert.Equal(t, http.StatusForbidden, rr.Code) + }) + + t.Run("should return 500 when failed to generate new token", func(t *testing.T) { + // given + url := fmt.Sprintf("/v1/remoteenvironments/%s/client-cert?token=%s", reName, token) + + tokenCache := &tokenCacheMocks.TokenCache{} + tokenCache.On("Get", reName).Return(token, true) + + tokenGenerator := &tokenMocks.TokenGenerator{} + tokenGenerator.On("NewToken", reName).Return("", apperrors.Internal("error")) + + subjectValues := certificates.CSRSubject{ + CName: reName, + Country: country, + Organization: organization, + OrganizationalUnit: organizationalUnit, + Locality: locality, + Province: province, + } + infoHandler := NewInfoHandler(tokenCache, tokenGenerator, host, domain, subjectValues) + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(tokenRequestRaw)) + require.NoError(t, err) + rr := httptest.NewRecorder() + + req = mux.SetURLVars(req, map[string]string{"reName": reName}) + + // when + infoHandler.GetInfo(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var errorResponse httperrors.ErrorResponse + err = json.Unmarshal(responseBody, &errorResponse) + require.NoError(t, err) + + assert.Equal(t, http.StatusInternalServerError, errorResponse.Code) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + }) +} diff --git a/components/connector-service/internal/externalapi/model.go b/components/connector-service/internal/externalapi/model.go new file mode 100755 index 000000000000..03b8bdf17921 --- /dev/null +++ b/components/connector-service/internal/externalapi/model.go @@ -0,0 +1,35 @@ +package externalapi + +type certRequest struct { + CSR string `json:"csr"` +} + +type certResponse struct { + CRT string `json:"crt"` +} + +type infoResponse struct { + SignUrl string `json:"csrUrl"` + Api api `json:"api"` + CertificateInfo certInfo `json:"certificate"` +} + +type api struct { + MetadataURL string `json:"metadataUrl"` + EventsURL string `json:"eventsUrl"` + CertificatesUrl string `json:"certificatesUrl"` +} + +type certInfo struct { + Subject string `json:"subject"` + Extensions string `json:"extensions"` + KeyAlgorithm string `json:"key-algorithm"` +} + +type csrInfo struct { + Country string `json:"country"` + Organization string `json:"organization"` + OrganizationalUnit string `json:"organizationalUnit"` + Locality string `json:"locality"` + Province string `json:"province"` +} diff --git a/components/connector-service/internal/externalapi/signaturehandler.go b/components/connector-service/internal/externalapi/signaturehandler.go new file mode 100755 index 000000000000..236330f2ec59 --- /dev/null +++ b/components/connector-service/internal/externalapi/signaturehandler.go @@ -0,0 +1,164 @@ +package externalapi + +import ( + "crypto/x509" + "encoding/json" + "io/ioutil" + "net/http" + + "github.com/gorilla/mux" + "github.com/kyma-project/kyma/components/connector-service/internal/apperrors" + "github.com/kyma-project/kyma/components/connector-service/internal/certificates" + "github.com/kyma-project/kyma/components/connector-service/internal/httpconsts" + "github.com/kyma-project/kyma/components/connector-service/internal/httperrors" + "github.com/kyma-project/kyma/components/connector-service/internal/secrets" + "github.com/kyma-project/kyma/components/connector-service/internal/tokens/tokencache" +) + +type signatureHandler struct { + tokenCache tokencache.TokenCache + certUtil certificates.CertificateUtility + secretsRepository secrets.Repository + host string + domainName string + csr csrInfo +} + +func NewSignatureHandler(tokenCache tokencache.TokenCache, certUtil certificates.CertificateUtility, secretsRepository secrets.Repository, + host string, domainName string, subjectValues certificates.CSRSubject) SignatureHandler { + csr := csrInfo{ + Country: subjectValues.Country, + Organization: subjectValues.Organization, + OrganizationalUnit: subjectValues.OrganizationalUnit, + Locality: subjectValues.Locality, + Province: subjectValues.Province, + } + + return &signatureHandler{ + tokenCache: tokenCache, + certUtil: certUtil, + secretsRepository: secretsRepository, + host: host, domainName: domainName, + csr: csr, + } +} + +func (sh *signatureHandler) SignCSR(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + if token == "" { + respondWithError(w, apperrors.Forbidden("Token not provided.")) + return + } + + reName := mux.Vars(r)["reName"] + + cachedToken, found := sh.tokenCache.Get(reName) + if !found || cachedToken != token { + respondWithError(w, apperrors.Forbidden("Invalid token.")) + return + } + + tokenRequest, appErr := sh.readCertRequest(r) + if appErr != nil { + respondWithError(w, appErr) + return + } + + csr, appErr := sh.loadAndCheckCSR(tokenRequest.CSR, reName) + if appErr != nil { + respondWithError(w, appErr) + return + } + + signedCrt, appErr := sh.signCSR("nginx-auth-ca", csr) + if appErr != nil { + respondWithError(w, appErr) + return + } + + sh.tokenCache.Delete(reName) + + respondWithBody(w, 201, certResponse{CRT: signedCrt}) +} + +func (sh *signatureHandler) signCSR(secretName string, csr *x509.CertificateRequest) ( + string, apperrors.AppError) { + + caCrtBytesEncoded, caKeyBytesEncoded, appErr := sh.secretsRepository.Get(secretName) + if appErr != nil { + return "", appErr + } + + caCrt, appErr := sh.certUtil.LoadCert(caCrtBytesEncoded) + if appErr != nil { + return "", appErr + } + + caKey, appErr := sh.certUtil.LoadKey(caKeyBytesEncoded) + if appErr != nil { + return "", appErr + } + + signedCrt, appErr := sh.certUtil.SignWithCA(caCrt, csr, caKey) + if appErr != nil { + return "", appErr + } + + return signedCrt, nil +} + +func (sh *signatureHandler) readCertRequest(r *http.Request) (*certRequest, apperrors.AppError) { + b, err := ioutil.ReadAll(r.Body) + if err != nil { + return nil, apperrors.Internal("Error while reading request body: %s", err) + } + defer r.Body.Close() + + var tokenRequest certRequest + err = json.Unmarshal(b, &tokenRequest) + if err != nil { + return nil, apperrors.Internal("Error while unmarshalling request body: %s", err) + } + + return &tokenRequest, nil +} + +func (sh *signatureHandler) loadAndCheckCSR(encodedData string, reName string) (*x509.CertificateRequest, apperrors.AppError) { + csr, appErr := sh.certUtil.LoadCSR(encodedData) + if appErr != nil { + return nil, appErr + } + + subjectValues := certificates.CSRSubject{ + CName: reName, + Country: sh.csr.Country, + Organization: sh.csr.Organization, + OrganizationalUnit: sh.csr.OrganizationalUnit, + Locality: sh.csr.Locality, + Province: sh.csr.Province, + } + + appErr = sh.certUtil.CheckCSRValues(csr, subjectValues) + if appErr != nil { + return nil, appErr + } + + return csr, nil +} + +func respondWithError(w http.ResponseWriter, apperr apperrors.AppError) { + statusCode, responseBody := httperrors.AppErrorToResponse(apperr) + + respond(w, statusCode) + json.NewEncoder(w).Encode(responseBody) +} + +func respond(w http.ResponseWriter, statusCode int) { + w.Header().Set(httpconsts.HeaderContentType, httpconsts.ContentTypeApplicationJson) + w.WriteHeader(statusCode) +} + +func respondWithBody(w http.ResponseWriter, statusCode int, responseBody interface{}) { + respond(w, statusCode) + json.NewEncoder(w).Encode(responseBody) +} diff --git a/components/connector-service/internal/externalapi/signaturehandler_test.go b/components/connector-service/internal/externalapi/signaturehandler_test.go new file mode 100755 index 000000000000..a59d1067b1f2 --- /dev/null +++ b/components/connector-service/internal/externalapi/signaturehandler_test.go @@ -0,0 +1,462 @@ +package externalapi + +import ( + "bytes" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/kyma-project/kyma/components/connector-service/internal/apperrors" + "github.com/kyma-project/kyma/components/connector-service/internal/certificates" + certMock "github.com/kyma-project/kyma/components/connector-service/internal/certificates/mocks" + "github.com/kyma-project/kyma/components/connector-service/internal/httperrors" + "github.com/kyma-project/kyma/components/connector-service/internal/secrets" + secrectsMock "github.com/kyma-project/kyma/components/connector-service/internal/secrets/mocks" + "github.com/kyma-project/kyma/components/connector-service/internal/tokens/tokencache" + tokensMock "github.com/kyma-project/kyma/components/connector-service/internal/tokens/tokencache/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + authSecretName = "nginx-auth-ca" + reName = "reName" + token = "token" + + host = "host" + domain = "domain" + country = "country" + organization = "organization" + organizationalUnit = "organizationalUnit" + locality = "locality" + province = "province" +) + +var ( + tokenRequestRaw = compact([]byte("{\"csr\":\"CSR\"}")) + caCrtEncoded = []byte("caCrtEncoded") + caKeyEncoded = []byte("caKeyEncoded") + + tokenRequest = certRequest{CSR: "CSR"} + caCrt = &x509.Certificate{} + caKey = &rsa.PrivateKey{} + csr = &x509.CertificateRequest{} + crtBase64 = "crtBase64" +) + +func TestSignatureHandler_SignCSR(t *testing.T) { + + t.Run("should create certificate", func(t *testing.T) { + // given + url := fmt.Sprintf("/v1/remoteenvironments/%s/client-cert?token=%s", reName, token) + + tokenCache := &tokensMock.TokenCache{} + tokenCache.On("Get", reName).Return(token, true) + tokenCache.On("Delete", reName).Return() + + secretsRepository := &secrectsMock.Repository{} + secretsRepository.On("Get", authSecretName).Return(caCrtEncoded, caKeyEncoded, nil) + + certUtils := &certMock.CertificateUtility{} + certUtils.On("LoadCert", caCrtEncoded).Return(caCrt, nil) + certUtils.On("LoadKey", caKeyEncoded).Return(caKey, nil) + certUtils.On("LoadCSR", tokenRequest.CSR).Return(csr, nil) + + subjectValues := certificates.CSRSubject{ + CName: reName, + Country: country, + Organization: organization, + OrganizationalUnit: organizationalUnit, + Locality: locality, + Province: province, + } + certUtils.On("CheckCSRValues", csr, subjectValues).Return(nil) + certUtils.On("SignWithCA", caCrt, csr, caKey).Return(crtBase64, nil) + + registrationHandler := createSignatureHandler(tokenCache, certUtils, secretsRepository) + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(tokenRequestRaw)) + require.NoError(t, err) + rr := httptest.NewRecorder() + + req = mux.SetURLVars(req, map[string]string{"reName": reName}) + + // when + registrationHandler.SignCSR(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var certResponse certResponse + err = json.Unmarshal(responseBody, &certResponse) + require.NoError(t, err) + + assert.Equal(t, crtBase64, certResponse.CRT) + assert.Equal(t, http.StatusCreated, rr.Code) + }) + + t.Run("should return 403 when token not provided", func(t *testing.T) { + // given + url := fmt.Sprintf("/v1/remoteenvironments/%s/client-cert", reName) + + tokenCache := &tokensMock.TokenCache{} + secretsRepository := &secrectsMock.Repository{} + certUtils := &certMock.CertificateUtility{} + + registrationHandler := createSignatureHandler(tokenCache, certUtils, secretsRepository) + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(tokenRequestRaw)) + require.NoError(t, err) + rr := httptest.NewRecorder() + + // when + registrationHandler.SignCSR(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var errorResponse httperrors.ErrorResponse + err = json.Unmarshal(responseBody, &errorResponse) + require.NoError(t, err) + + assert.Equal(t, http.StatusForbidden, errorResponse.Code) + assert.Equal(t, http.StatusForbidden, rr.Code) + }) + + t.Run("should return 400 when token not found", func(t *testing.T) { + // given + url := fmt.Sprintf("/v1/remoteenvironments/%s/client-cert?token=%s", reName, token) + + tokenCache := &tokensMock.TokenCache{} + tokenCache.On("Get", reName).Return("", false) + + secretsRepository := &secrectsMock.Repository{} + certUtils := &certMock.CertificateUtility{} + + registrationHandler := createSignatureHandler(tokenCache, certUtils, secretsRepository) + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(tokenRequestRaw)) + require.NoError(t, err) + rr := httptest.NewRecorder() + + req = mux.SetURLVars(req, map[string]string{"reName": reName}) + + // when + registrationHandler.SignCSR(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var errorResponse httperrors.ErrorResponse + err = json.Unmarshal(responseBody, &errorResponse) + require.NoError(t, err) + + assert.Equal(t, http.StatusForbidden, errorResponse.Code) + assert.Equal(t, http.StatusForbidden, rr.Code) + }) + + t.Run("should return 403 when wrong token provided", func(t *testing.T) { + // given + url := fmt.Sprintf("/v1/remoteenvironments/%s/client-cert?token=%s", reName, token) + + tokenCache := &tokensMock.TokenCache{} + tokenCache.On("Get", reName).Return("differentToken", true) + + secretsRepository := &secrectsMock.Repository{} + certUtils := &certMock.CertificateUtility{} + + registrationHandler := createSignatureHandler(tokenCache, certUtils, secretsRepository) + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(tokenRequestRaw)) + require.NoError(t, err) + rr := httptest.NewRecorder() + + req = mux.SetURLVars(req, map[string]string{"reName": reName}) + + // when + registrationHandler.SignCSR(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var errorResponse httperrors.ErrorResponse + err = json.Unmarshal(responseBody, &errorResponse) + require.NoError(t, err) + + assert.Equal(t, http.StatusForbidden, errorResponse.Code) + assert.Equal(t, http.StatusForbidden, rr.Code) + }) + + t.Run("should return 500 when couldn't unmarshal request body", func(t *testing.T) { + // given + url := fmt.Sprintf("/v1/remoteenvironments/%s/client-cert?token=%s", reName, token) + + tokenCache := &tokensMock.TokenCache{} + tokenCache.On("Get", reName).Return(token, true) + + secretsRepository := &secrectsMock.Repository{} + certUtils := &certMock.CertificateUtility{} + + registrationHandler := createSignatureHandler(tokenCache, certUtils, secretsRepository) + + incorrectBody := []byte("incorrectBody") + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(incorrectBody)) + require.NoError(t, err) + rr := httptest.NewRecorder() + + req = mux.SetURLVars(req, map[string]string{"reName": reName}) + + // when + registrationHandler.SignCSR(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var errorResponse httperrors.ErrorResponse + err = json.Unmarshal(responseBody, &errorResponse) + require.NoError(t, err) + + assert.Equal(t, http.StatusInternalServerError, errorResponse.Code) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + }) + + t.Run("should return 404 when secret not found", func(t *testing.T) { + // given + url := fmt.Sprintf("/v1/remoteenvironments/%s/client-cert?token=%s", reName, token) + + tokenCache := &tokensMock.TokenCache{} + tokenCache.On("Get", reName).Return(token, true) + tokenCache.On("Delete", reName).Return() + + secretNotFoundError := apperrors.NotFound("error") + secretsRepository := &secrectsMock.Repository{} + secretsRepository.On("Get", authSecretName).Return([]byte(""), []byte(""), secretNotFoundError) + + certUtils := &certMock.CertificateUtility{} + certUtils.On("LoadCSR", mock.Anything).Return(nil, nil) + certUtils.On("CheckCSRValues", mock.Anything, mock.Anything).Return(nil, nil) + + registrationHandler := createSignatureHandler(tokenCache, certUtils, secretsRepository) + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(tokenRequestRaw)) + require.NoError(t, err) + rr := httptest.NewRecorder() + + req = mux.SetURLVars(req, map[string]string{"reName": reName}) + + // when + registrationHandler.SignCSR(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var errorResponse httperrors.ErrorResponse + err = json.Unmarshal(responseBody, &errorResponse) + require.NoError(t, err) + + assert.Equal(t, http.StatusNotFound, errorResponse.Code) + assert.Equal(t, "error", errorResponse.Error) + }) + + t.Run("should return 500 when couldn't load cert", func(t *testing.T) { + // given + url := fmt.Sprintf("/v1/remoteenvironments/%s/client-cert?token=%s", reName, token) + + tokenCache := &tokensMock.TokenCache{} + tokenCache.On("Get", reName).Return(token, true) + + secretsRepository := &secrectsMock.Repository{} + secretsRepository.On("Get", authSecretName).Return(caCrtEncoded, caKeyEncoded, nil) + + certUtils := &certMock.CertificateUtility{} + certUtils.On("LoadCSR", mock.Anything).Return(nil, nil) + certUtils.On("CheckCSRValues", mock.Anything, mock.Anything).Return(nil, nil) + certUtils.On("LoadCert", caCrtEncoded).Return(nil, apperrors.Internal("error")) + + registrationHandler := createSignatureHandler(tokenCache, certUtils, secretsRepository) + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(tokenRequestRaw)) + require.NoError(t, err) + rr := httptest.NewRecorder() + + req = mux.SetURLVars(req, map[string]string{"reName": reName}) + + // when + registrationHandler.SignCSR(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var errorResponse httperrors.ErrorResponse + err = json.Unmarshal(responseBody, &errorResponse) + require.NoError(t, err) + + assert.Equal(t, http.StatusInternalServerError, errorResponse.Code) + assert.Equal(t, "error", errorResponse.Error) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + }) + + t.Run("should return 500 when couldn't load key", func(t *testing.T) { + // given + url := fmt.Sprintf("/v1/remoteenvironments/%s/client-cert?token=%s", reName, token) + + tokenCache := &tokensMock.TokenCache{} + tokenCache.On("Get", reName).Return(token, true) + + secretsRepository := &secrectsMock.Repository{} + secretsRepository.On("Get", authSecretName).Return(caCrtEncoded, caKeyEncoded, nil) + + certUtils := &certMock.CertificateUtility{} + certUtils.On("LoadCSR", mock.Anything).Return(nil, nil) + certUtils.On("CheckCSRValues", mock.Anything, mock.Anything).Return(nil, nil) + certUtils.On("LoadCert", caCrtEncoded).Return(caCrt, nil) + certUtils.On("LoadKey", caKeyEncoded).Return(nil, apperrors.Internal("error")) + + registrationHandler := createSignatureHandler(tokenCache, certUtils, secretsRepository) + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(tokenRequestRaw)) + require.NoError(t, err) + rr := httptest.NewRecorder() + + req = mux.SetURLVars(req, map[string]string{"reName": reName}) + + // when + registrationHandler.SignCSR(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var errorResponse httperrors.ErrorResponse + err = json.Unmarshal(responseBody, &errorResponse) + require.NoError(t, err) + + assert.Equal(t, http.StatusInternalServerError, errorResponse.Code) + assert.Equal(t, "error", errorResponse.Error) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + }) + + t.Run("should return 500 when couldn't load CSR", func(t *testing.T) { + // given + url := fmt.Sprintf("/v1/remoteenvironments/%s/client-cert?token=%s", reName, token) + + tokenCache := &tokensMock.TokenCache{} + tokenCache.On("Get", reName).Return(token, true) + + secretsRepository := &secrectsMock.Repository{} + secretsRepository.On("Get", authSecretName).Return(caCrtEncoded, caKeyEncoded, nil) + + certUtils := &certMock.CertificateUtility{} + certUtils.On("LoadCert", caCrtEncoded).Return(caCrt, nil) + certUtils.On("LoadKey", caKeyEncoded).Return(caKey, nil) + certUtils.On("LoadCSR", tokenRequest.CSR).Return(nil, apperrors.Internal("error")) + + registrationHandler := createSignatureHandler(tokenCache, certUtils, secretsRepository) + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(tokenRequestRaw)) + require.NoError(t, err) + rr := httptest.NewRecorder() + + req = mux.SetURLVars(req, map[string]string{"reName": reName}) + + // when + registrationHandler.SignCSR(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var errorResponse httperrors.ErrorResponse + err = json.Unmarshal(responseBody, &errorResponse) + require.NoError(t, err) + + assert.Equal(t, http.StatusInternalServerError, errorResponse.Code) + assert.Equal(t, "error", errorResponse.Error) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + }) + + t.Run("should return 500 when failed to check CSR values", func(t *testing.T) { + // given + url := fmt.Sprintf("/v1/remoteenvironments/%s/client-cert?token=%s", reName, token) + + tokenCache := &tokensMock.TokenCache{} + tokenCache.On("Get", reName).Return(token, true) + + secretsRepository := &secrectsMock.Repository{} + secretsRepository.On("Get", authSecretName).Return(caCrtEncoded, caKeyEncoded, nil) + + certUtils := &certMock.CertificateUtility{} + certUtils.On("LoadCert", caCrtEncoded).Return(caCrt, nil) + certUtils.On("LoadKey", caKeyEncoded).Return(caKey, nil) + certUtils.On("LoadCSR", tokenRequest.CSR).Return(csr, nil) + + subjectValues := certificates.CSRSubject{ + CName: reName, + Country: country, + Organization: organization, + OrganizationalUnit: organizationalUnit, + Locality: locality, + Province: province, + } + certUtils.On("CheckCSRValues", csr, subjectValues).Return(apperrors.Forbidden("error")) + + registrationHandler := createSignatureHandler(tokenCache, certUtils, secretsRepository) + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(tokenRequestRaw)) + require.NoError(t, err) + rr := httptest.NewRecorder() + + req = mux.SetURLVars(req, map[string]string{"reName": reName}) + + // when + registrationHandler.SignCSR(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var errorResponse httperrors.ErrorResponse + err = json.Unmarshal(responseBody, &errorResponse) + require.NoError(t, err) + + assert.Equal(t, http.StatusForbidden, errorResponse.Code) + assert.Equal(t, "error", errorResponse.Error) + assert.Equal(t, http.StatusForbidden, rr.Code) + }) +} + +func createSignatureHandler(tokenCache tokencache.TokenCache, certUtils certificates.CertificateUtility, secretsRepository secrets.Repository) SignatureHandler { + subjectValues := certificates.CSRSubject{ + CName: reName, + Country: country, + Organization: organization, + OrganizationalUnit: organizationalUnit, + Locality: locality, + Province: province, + } + + return NewSignatureHandler(tokenCache, certUtils, secretsRepository, host, domain, subjectValues) +} + +func compact(src []byte) []byte { + buffer := new(bytes.Buffer) + err := json.Compact(buffer, src) + if err != nil { + return src + } + return buffer.Bytes() +} diff --git a/components/connector-service/internal/httpconsts/httpconsts.go b/components/connector-service/internal/httpconsts/httpconsts.go new file mode 100755 index 000000000000..0536bf1968ef --- /dev/null +++ b/components/connector-service/internal/httpconsts/httpconsts.go @@ -0,0 +1,9 @@ +package httpconsts + +const ( + HeaderContentType = "Content-Type" +) + +const ( + ContentTypeApplicationJson = "application/json;charset=UTF-8" +) diff --git a/components/connector-service/internal/httperrors/httperrors.go b/components/connector-service/internal/httperrors/httperrors.go new file mode 100755 index 000000000000..ae8733aac799 --- /dev/null +++ b/components/connector-service/internal/httperrors/httperrors.go @@ -0,0 +1,34 @@ +package httperrors + +import ( + "net/http" + + "github.com/kyma-project/kyma/components/connector-service/internal/apperrors" +) + +type ErrorResponse struct { + Code int `json:"code"` + Error string `json:"error"` +} + +func errorCodeToHttpStatus(code int) int { + switch code { + case apperrors.CodeInternal: + return http.StatusInternalServerError + case apperrors.CodeNotFound: + return http.StatusNotFound + case apperrors.CodeAlreadyExists: + return http.StatusConflict + case apperrors.CodeWrongInput: + return http.StatusBadRequest + case apperrors.CodeForbidden: + return http.StatusForbidden + default: + return http.StatusInternalServerError + } +} + +func AppErrorToResponse(appError apperrors.AppError) (status int, body ErrorResponse) { + httpCode := errorCodeToHttpStatus(appError.Code()) + return httpCode, ErrorResponse{httpCode, appError.Error()} +} diff --git a/components/connector-service/internal/internalapi/internalapi.go b/components/connector-service/internal/internalapi/internalapi.go new file mode 100755 index 000000000000..e07dff1dfee6 --- /dev/null +++ b/components/connector-service/internal/internalapi/internalapi.go @@ -0,0 +1,25 @@ +package internalapi + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/kyma-project/kyma/components/connector-service/internal/errorhandler" +) + +type TokenHandler interface { + CreateToken(w http.ResponseWriter, r *http.Request) +} + +func NewHandler(handler TokenHandler) http.Handler { + router := mux.NewRouter() + + tokenRouter := router.PathPrefix("/v1/remoteenvironments").Subrouter() + + tokenRouter.HandleFunc("/{reName}/tokens", handler.CreateToken).Methods(http.MethodPost) + + router.NotFoundHandler = errorhandler.NewErrorHandler(404, "Requested resource could not be found.") + router.MethodNotAllowedHandler = errorhandler.NewErrorHandler(405, "Method not allowed.") + + return router +} diff --git a/components/connector-service/internal/internalapi/tokenhandler.go b/components/connector-service/internal/internalapi/tokenhandler.go new file mode 100755 index 000000000000..2deee7d69dcd --- /dev/null +++ b/components/connector-service/internal/internalapi/tokenhandler.go @@ -0,0 +1,60 @@ +package internalapi + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/gorilla/mux" + "github.com/kyma-project/kyma/components/connector-service/internal/apperrors" + "github.com/kyma-project/kyma/components/connector-service/internal/httpconsts" + "github.com/kyma-project/kyma/components/connector-service/internal/httperrors" + "github.com/kyma-project/kyma/components/connector-service/internal/tokens" +) + +const TokenURL = "https://%s/v1/remoteenvironments/%s/info?token=%s" + +type tokenHandler struct { + tokenGenerator tokens.TokenGenerator + host string +} + +func NewTokenHandler(tokenGenerator tokens.TokenGenerator, host string) TokenHandler { + return &tokenHandler{tokenGenerator: tokenGenerator, host: host} +} + +func (tg *tokenHandler) CreateToken(w http.ResponseWriter, r *http.Request) { + reName := mux.Vars(r)["reName"] + token, err := tg.tokenGenerator.NewToken(reName) + if err != nil { + respondWithError(w, err) + return + } + + url := fmt.Sprintf(TokenURL, tg.host, reName, token) + res := tokenResponse{URL: url, Token: token} + + respondWithBody(w, 201, res) +} + +func respondWithError(w http.ResponseWriter, apperr apperrors.AppError) { + statusCode, responseBody := httperrors.AppErrorToResponse(apperr) + + respond(w, statusCode) + json.NewEncoder(w).Encode(responseBody) +} + +func respond(w http.ResponseWriter, statusCode int) { + w.Header().Set(httpconsts.HeaderContentType, httpconsts.ContentTypeApplicationJson) + w.WriteHeader(statusCode) +} + +func respondWithBody(w http.ResponseWriter, statusCode int, responseBody interface{}) { + respond(w, statusCode) + json.NewEncoder(w).Encode(responseBody) +} + +type tokenResponse struct { + URL string `json:"url"` + Token string `json:"token"` +} diff --git a/components/connector-service/internal/internalapi/tokenhandler_test.go b/components/connector-service/internal/internalapi/tokenhandler_test.go new file mode 100755 index 000000000000..844d81bf38e3 --- /dev/null +++ b/components/connector-service/internal/internalapi/tokenhandler_test.go @@ -0,0 +1,92 @@ +package internalapi + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/kyma-project/kyma/components/connector-service/internal/apperrors" + "github.com/kyma-project/kyma/components/connector-service/internal/httperrors" + "github.com/kyma-project/kyma/components/connector-service/internal/tokens/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + host = "host" + reName = "reName" + token = "token" +) + +func TestTokenHandler_CreateToken(t *testing.T) { + + t.Run("should create token", func(t *testing.T) { + // given + url := fmt.Sprintf("/v1/remoteenvironments/%s/tokens", reName) + + expectedTokenResponse := tokenResponse{ + URL: fmt.Sprintf("https://%s/v1/remoteenvironments/%s/info?token=%s", host, reName, token), + Token: token, + } + + tokenGenerator := &mocks.TokenGenerator{} + tokenGenerator.On("NewToken", reName).Return(token, nil) + + tokenHandler := NewTokenHandler(tokenGenerator, host) + + req, err := http.NewRequest(http.MethodGet, url, nil) + require.NoError(t, err) + rr := httptest.NewRecorder() + + req = mux.SetURLVars(req, map[string]string{"reName": reName}) + + // when + tokenHandler.CreateToken(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var tokenResponse tokenResponse + err = json.Unmarshal(responseBody, &tokenResponse) + require.NoError(t, err) + + assert.Equal(t, http.StatusCreated, rr.Code) + assert.EqualValues(t, expectedTokenResponse, tokenResponse) + }) + + t.Run("should return 500 when failed to generate token", func(t *testing.T) { + // given + url := fmt.Sprintf("/v1/remoteenvironments/%s/tokens", reName) + + tokenGenerator := &mocks.TokenGenerator{} + tokenGenerator.On("NewToken", reName).Return("", apperrors.Internal("error")) + + tokenHandler := NewTokenHandler(tokenGenerator, host) + + req, err := http.NewRequest(http.MethodGet, url, nil) + require.NoError(t, err) + rr := httptest.NewRecorder() + + req = mux.SetURLVars(req, map[string]string{"reName": reName}) + + // when + tokenHandler.CreateToken(rr, req) + + // then + responseBody, err := ioutil.ReadAll(rr.Body) + require.NoError(t, err) + + var errorResponse httperrors.ErrorResponse + err = json.Unmarshal(responseBody, &errorResponse) + require.NoError(t, err) + + assert.Equal(t, http.StatusInternalServerError, errorResponse.Code) + assert.Equal(t, "error", errorResponse.Error) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + }) +} diff --git a/components/connector-service/internal/secrets/mocks/Manager.go b/components/connector-service/internal/secrets/mocks/Manager.go new file mode 100755 index 000000000000..0a3fc3cd6eca --- /dev/null +++ b/components/connector-service/internal/secrets/mocks/Manager.go @@ -0,0 +1,35 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. +package mocks + +import corev1 "k8s.io/api/core/v1" +import mock "github.com/stretchr/testify/mock" + +import v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// Manager is an autogenerated mock type for the Manager type +type Manager struct { + mock.Mock +} + +// Get provides a mock function with given fields: name, options +func (_m *Manager) Get(name string, options v1.GetOptions) (*corev1.Secret, error) { + ret := _m.Called(name, options) + + var r0 *corev1.Secret + if rf, ok := ret.Get(0).(func(string, v1.GetOptions) *corev1.Secret); ok { + r0 = rf(name, options) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*corev1.Secret) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, v1.GetOptions) error); ok { + r1 = rf(name, options) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/connector-service/internal/secrets/mocks/Repository.go b/components/connector-service/internal/secrets/mocks/Repository.go new file mode 100755 index 000000000000..b03ba389b403 --- /dev/null +++ b/components/connector-service/internal/secrets/mocks/Repository.go @@ -0,0 +1,44 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. +package mocks + +import apperrors "github.com/kyma-project/kyma/components/connector-service/internal/apperrors" +import mock "github.com/stretchr/testify/mock" + +// Repository is an autogenerated mock type for the Repository type +type Repository struct { + mock.Mock +} + +// Get provides a mock function with given fields: name +func (_m *Repository) Get(name string) ([]byte, []byte, apperrors.AppError) { + ret := _m.Called(name) + + var r0 []byte + if rf, ok := ret.Get(0).(func(string) []byte); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + var r1 []byte + if rf, ok := ret.Get(1).(func(string) []byte); ok { + r1 = rf(name) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]byte) + } + } + + var r2 apperrors.AppError + if rf, ok := ret.Get(2).(func(string) apperrors.AppError); ok { + r2 = rf(name) + } else { + if ret.Get(2) != nil { + r2 = ret.Get(2).(apperrors.AppError) + } + } + + return r0, r1, r2 +} diff --git a/components/connector-service/internal/secrets/repository.go b/components/connector-service/internal/secrets/repository.go new file mode 100755 index 000000000000..45b437734aa7 --- /dev/null +++ b/components/connector-service/internal/secrets/repository.go @@ -0,0 +1,36 @@ +package secrets + +import ( + "github.com/kyma-project/kyma/components/connector-service/internal/apperrors" + "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type Manager interface { + Get(name string, options metav1.GetOptions) (*v1.Secret, error) +} + +type Repository interface { + Get(name string) (crt []byte, key []byte, appError apperrors.AppError) +} + +type repository struct { + secretsManager Manager +} + +func NewRepository(secretsManager Manager) Repository { + return &repository{secretsManager: secretsManager} +} + +func (r *repository) Get(name string) (crt []byte, key []byte, appError apperrors.AppError) { + secret, err := r.secretsManager.Get(name, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return nil, nil, apperrors.NotFound("secret %s not found", name) + } + return nil, nil, apperrors.Internal("failed to get %s secret, %s", name, err) + } + + return secret.Data["ca.crt"], secret.Data["ca.key"], nil +} diff --git a/components/connector-service/internal/secrets/repository_test.go b/components/connector-service/internal/secrets/repository_test.go new file mode 100755 index 000000000000..2f787625c64e --- /dev/null +++ b/components/connector-service/internal/secrets/repository_test.go @@ -0,0 +1,83 @@ +package secrets + +import ( + "testing" + + "github.com/kyma-project/kyma/components/connector-service/internal/apperrors" + "github.com/kyma-project/kyma/components/connector-service/internal/secrets/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + reName = "reName" +) + +var ( + expectedCaCrt = []byte("caCrtEncoded") + expectedCaKey = []byte("caKeyEncoded") +) + +func TestRepository_Get(t *testing.T) { + + t.Run("should get secret", func(t *testing.T) { + // given + secretMap := make(map[string][]byte) + secretMap["ca.crt"] = expectedCaCrt + secretMap["ca.key"] = expectedCaKey + + secretsManager := &mocks.Manager{} + secretsManager.On("Get", reName, metav1.GetOptions{}).Return(&v1.Secret{Data: secretMap}, nil) + + repository := NewRepository(secretsManager) + + // when + encodedCrt, encodedKey, err := repository.Get(reName) + + // then + require.NoError(t, err) + + assert.Equal(t, expectedCaCrt, encodedCrt) + assert.Equal(t, expectedCaKey, encodedKey) + }) + + t.Run("should fail in case secret not found", func(t *testing.T) { + // given + k8sNotFoundError := &k8serrors.StatusError{ + ErrStatus: metav1.Status{Reason: metav1.StatusReasonNotFound}, + } + secretsManager := &mocks.Manager{} + secretsManager.On("Get", reName, metav1.GetOptions{}).Return(nil, k8sNotFoundError) + + repository := NewRepository(secretsManager) + + // when + encodedCrt, encodedKey, err := repository.Get(reName) + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeNotFound, err.Code()) + assert.Nil(t, encodedCrt) + assert.Nil(t, encodedKey) + }) + + t.Run("should fail if couldn't get secret", func(t *testing.T) { + // given + secretsManager := &mocks.Manager{} + secretsManager.On("Get", reName, metav1.GetOptions{}).Return(nil, &k8serrors.StatusError{}) + + repository := NewRepository(secretsManager) + + // when + encodedCrt, encodedKey, err := repository.Get(reName) + + // then + require.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.Nil(t, encodedCrt) + assert.Nil(t, encodedKey) + }) +} diff --git a/components/connector-service/internal/tokens/mocks/TokenGenerator.go b/components/connector-service/internal/tokens/mocks/TokenGenerator.go new file mode 100755 index 000000000000..afb785eebe97 --- /dev/null +++ b/components/connector-service/internal/tokens/mocks/TokenGenerator.go @@ -0,0 +1,33 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. +package mocks + +import apperrors "github.com/kyma-project/kyma/components/connector-service/internal/apperrors" +import mock "github.com/stretchr/testify/mock" + +// TokenGenerator is an autogenerated mock type for the TokenGenerator type +type TokenGenerator struct { + mock.Mock +} + +// NewToken provides a mock function with given fields: re +func (_m *TokenGenerator) NewToken(re string) (string, apperrors.AppError) { + ret := _m.Called(re) + + var r0 string + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(re) + } else { + r0 = ret.Get(0).(string) + } + + var r1 apperrors.AppError + if rf, ok := ret.Get(1).(func(string) apperrors.AppError); ok { + r1 = rf(re) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(apperrors.AppError) + } + } + + return r0, r1 +} diff --git a/components/connector-service/internal/tokens/tokencache/mocks/TokenCache.go b/components/connector-service/internal/tokens/tokencache/mocks/TokenCache.go new file mode 100755 index 000000000000..1a190295b002 --- /dev/null +++ b/components/connector-service/internal/tokens/tokencache/mocks/TokenCache.go @@ -0,0 +1,40 @@ +// Code generated by mockery v1.0.0 +package mocks + +import mock "github.com/stretchr/testify/mock" + +// TokenCache is an autogenerated mock type for the TokenCache type +type TokenCache struct { + mock.Mock +} + +// Delete provides a mock function with given fields: re +func (_m *TokenCache) Delete(re string) { + _m.Called(re) +} + +// Get provides a mock function with given fields: re +func (_m *TokenCache) Get(re string) (string, bool) { + ret := _m.Called(re) + + var r0 string + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(re) + } else { + r0 = ret.Get(0).(string) + } + + var r1 bool + if rf, ok := ret.Get(1).(func(string) bool); ok { + r1 = rf(re) + } else { + r1 = ret.Get(1).(bool) + } + + return r0, r1 +} + +// Put provides a mock function with given fields: re, token +func (_m *TokenCache) Put(re string, token string) { + _m.Called(re, token) +} diff --git a/components/connector-service/internal/tokens/tokencache/tokencache.go b/components/connector-service/internal/tokens/tokencache/tokencache.go new file mode 100755 index 000000000000..bb063d9a33fa --- /dev/null +++ b/components/connector-service/internal/tokens/tokencache/tokencache.go @@ -0,0 +1,40 @@ +package tokencache + +import ( + "time" + + "github.com/patrickmn/go-cache" +) + +type TokenCache interface { + Put(re string, token string) + Get(re string) (string, bool) + Delete(re string) +} + +type tokenCache struct { + tokenCache *cache.Cache +} + +func NewTokenCache(expirationMinutes int) TokenCache { + return &tokenCache{ + tokenCache: cache.New(time.Duration(expirationMinutes)*time.Minute, 1*time.Minute), + } +} + +func (c *tokenCache) Put(re string, token string) { + c.tokenCache.Set(re, token, cache.DefaultExpiration) +} + +func (c *tokenCache) Get(re string) (string, bool) { + token, found := c.tokenCache.Get(re) + if !found { + return "", found + } + + return token.(string), found +} + +func (c *tokenCache) Delete(re string) { + c.tokenCache.Delete(re) +} diff --git a/components/connector-service/internal/tokens/tokens.go b/components/connector-service/internal/tokens/tokens.go new file mode 100755 index 000000000000..9f00e3665efc --- /dev/null +++ b/components/connector-service/internal/tokens/tokens.go @@ -0,0 +1,47 @@ +package tokens + +import ( + "crypto/rand" + "encoding/base64" + + "github.com/kyma-project/kyma/components/connector-service/internal/apperrors" + "github.com/kyma-project/kyma/components/connector-service/internal/tokens/tokencache" +) + +type TokenGenerator interface { + NewToken(re string) (string, apperrors.AppError) +} + +type tokenGenerator struct { + tokenLength int + tokenCache tokencache.TokenCache +} + +func NewTokenGenerator(tokenLength int, tokenCache tokencache.TokenCache) TokenGenerator { + return &tokenGenerator{tokenLength: tokenLength, tokenCache: tokenCache} +} + +func (tg *tokenGenerator) NewToken(re string) (string, apperrors.AppError) { + token, err := generateRandomString(tg.tokenLength) + if err != nil { + return "", err + } + + tg.tokenCache.Put(re, token) + return token, nil +} + +func generateRandomBytes(number int) ([]byte, apperrors.AppError) { + bytes := make([]byte, number) + _, err := rand.Read(bytes) + if err != nil { + return nil, apperrors.Internal("Failed to generate random bytes: %s", err) + } + + return bytes, nil +} + +func generateRandomString(length int) (string, apperrors.AppError) { + bytes, err := generateRandomBytes(length) + return base64.URLEncoding.EncodeToString(bytes), err +} diff --git a/components/connector-service/internal/tokens/tokens_test.go b/components/connector-service/internal/tokens/tokens_test.go new file mode 100755 index 000000000000..d53ed3887ad9 --- /dev/null +++ b/components/connector-service/internal/tokens/tokens_test.go @@ -0,0 +1,39 @@ +package tokens + +import ( + "encoding/base64" + "testing" + + "github.com/kyma-project/kyma/components/connector-service/internal/tokens/tokencache/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + tokenLength = 10 + reName = "reName" +) + +func TestTokenGenerator_NewToken(t *testing.T) { + + t.Run("should generate token", func(t *testing.T) { + // given + tokenCache := &mocks.TokenCache{} + tokenCache.On("Put", reName, mock.AnythingOfType("string")) + + tokenGenerator := NewTokenGenerator(tokenLength, tokenCache) + + // when + newToken, apperr := tokenGenerator.NewToken(reName) + + // then + require.NoError(t, apperr) + + decoded, err := base64.URLEncoding.DecodeString(newToken) + require.NoError(t, err) + + assert.Equal(t, tokenLength, len(decoded)) + }) + +} diff --git a/components/connector-service/scripts/can-i-commit.sh b/components/connector-service/scripts/can-i-commit.sh new file mode 100755 index 000000000000..652dfb3cefa7 --- /dev/null +++ b/components/connector-service/scripts/can-i-commit.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +RED='\033[0;31m' +GREEN='\033[0;32m' +INVERTED='\033[7m' +NC='\033[0m' # No Color + +echo -e "${INVERTED}" +echo "USER: " + $USER +echo "PATH: " + $PATH +echo "GOPATH:" + $GOPATH +echo -e "${NC}" + +## +# DEP STATUS +## +echo "? dep status" +depResult=$(dep status -v) +if [ $? != 0 ] + then + echo -e "${RED}✗ dep status\n$depResult${NC}" + exit 1; + else echo -e "${GREEN}√ dep status${NC}" +fi + +## +# MAKE BUILD +## +echo "? make build" +make build +if [ $? != 0 ]; # Check make build passed + then + echo -e "${RED}✗ make build\n${NC}" + exit 1; + else echo -e "${GREEN}√ make build${NC}" +fi + +filesToCheck=$(find . -type f -name "*.go" | egrep -v "\/vendor\/|_*/automock/|_*/testdata/|/pkg\/|_*export_test.go") + +# +# GO FMT +# +goFmtResult=$(echo "${filesToCheck}" | xargs -L1 go fmt) +if [ $(echo ${#goFmtResult}) != 0 ] + then + echo -e "${RED}✗ go fmt${NC}\n$goFmtResult${NC}" + exit 1; + else echo -e "${GREEN}√ go fmt${NC}" +fi + +echo -e "${GREEN}Congrats $(whoami)! You've made it! Now you can commit.${NC}" diff --git a/components/environments/.gitignore b/components/environments/.gitignore new file mode 100644 index 000000000000..585077b47a95 --- /dev/null +++ b/components/environments/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +.idea +.vscode + +/environments +environments +info.json + +vendor \ No newline at end of file diff --git a/components/environments/Gopkg.lock b/components/environments/Gopkg.lock new file mode 100644 index 000000000000..0fec0feed6c4 --- /dev/null +++ b/components/environments/Gopkg.lock @@ -0,0 +1,358 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/davecgh/go-spew" + packages = ["spew"] + revision = "346938d642f2ec3594ed81d874461961cd0faa76" + version = "v1.1.0" + +[[projects]] + name = "github.com/ghodss/yaml" + packages = ["."] + revision = "0ca9ea5df5451ffdf184b4428c902747c2c11cd7" + version = "v1.0.0" + +[[projects]] + name = "github.com/gogo/protobuf" + packages = [ + "proto", + "sortkeys" + ] + revision = "342cbe0a04158f6dcb03ca0079991a51a4248c02" + version = "v0.5" + +[[projects]] + branch = "master" + name = "github.com/golang/glog" + packages = ["."] + revision = "23def4e6c14b4da8ac2ed8007337bc5eb5007998" + +[[projects]] + branch = "master" + name = "github.com/golang/protobuf" + packages = [ + "proto", + "ptypes", + "ptypes/any", + "ptypes/duration", + "ptypes/timestamp" + ] + revision = "1e59b77b52bf8e4b449a57e6f79f21226d571845" + +[[projects]] + branch = "master" + name = "github.com/google/gofuzz" + packages = ["."] + revision = "24818f796faf91cd76ec7bddd72458fbced7a6c1" + +[[projects]] + name = "github.com/googleapis/gnostic" + packages = [ + "OpenAPIv2", + "compiler", + "extensions" + ] + revision = "ee43cbb60db7bd22502942cccbc39059117352ab" + version = "v0.1.0" + +[[projects]] + branch = "master" + name = "github.com/gopherjs/gopherjs" + packages = ["js"] + revision = "178c176a91fe05e3e6c58fa5c989bad19e6cdcb3" + +[[projects]] + branch = "master" + name = "github.com/hashicorp/golang-lru" + packages = [ + ".", + "simplelru" + ] + revision = "0a025b7e63adc15a622f29b0b2c4c3848243bbf6" + +[[projects]] + branch = "master" + name = "github.com/howeyc/gopass" + packages = ["."] + revision = "bf9dde6d0d2c004a008c27aaee91170c786f6db8" + +[[projects]] + name = "github.com/imdario/mergo" + packages = ["."] + revision = "dc972c8e7a40bc9190119632ed90a1f59c603d2f" + version = "0.3.1" + +[[projects]] + name = "github.com/json-iterator/go" + packages = ["."] + revision = "f7279a603edee96fe7764d3de9c6ff8cf9970994" + version = "1.0.4" + +[[projects]] + name = "github.com/jtolds/gls" + packages = ["."] + revision = "77f18212c9c7edc9bd6a33d383a7b545ce62f064" + version = "v4.2.1" + +[[projects]] + name = "github.com/pkg/errors" + packages = ["."] + revision = "645ef00459ed84a119197bfb8d8205042c6df63d" + version = "v0.8.0" + +[[projects]] + name = "github.com/smartystreets/assertions" + packages = [ + ".", + "internal/go-render/render", + "internal/oglematchers" + ] + revision = "0b37b35ec7434b77e77a4bb29b79677cced992ea" + version = "1.8.1" + +[[projects]] + name = "github.com/smartystreets/goconvey" + packages = [ + "convey", + "convey/gotest", + "convey/reporting" + ] + revision = "9e8dc3f972df6c8fcc0375ef492c24d0bb204857" + version = "1.6.3" + +[[projects]] + name = "github.com/spf13/pflag" + packages = ["."] + revision = "e57e3eeb33f795204c1ca35f56c44f83227c6e66" + version = "v1.0.0" + +[[projects]] + name = "github.com/vrischmann/envconfig" + packages = ["."] + revision = "98b0b9a570bdd3eb00e3e6eeb15548e7f982bfd3" + version = "1.0.0" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = ["ssh/terminal"] + revision = "ee41a25c63fb5b74abf2213abb6dee3751e6ac4a" + +[[projects]] + branch = "master" + name = "golang.org/x/net" + packages = [ + "context", + "http2", + "http2/hpack", + "idna", + "lex/httplex" + ] + revision = "5ccada7d0a7ba9aeb5d3aca8d3501b4c2a509fec" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = [ + "unix", + "windows" + ] + revision = "2c42eef0765b9837fbdab12011af7830f55f88f0" + +[[projects]] + branch = "master" + name = "golang.org/x/text" + packages = [ + "collate", + "collate/build", + "internal/colltab", + "internal/gen", + "internal/tag", + "internal/triegen", + "internal/ucd", + "language", + "secure/bidirule", + "transform", + "unicode/bidi", + "unicode/cldr", + "unicode/norm", + "unicode/rangetable" + ] + revision = "e19ae1496984b1c655b8044a65c0300a3c878dd3" + +[[projects]] + branch = "master" + name = "golang.org/x/time" + packages = ["rate"] + revision = "fbb02b2291d28baffd63558aa44b4b56f178d650" + +[[projects]] + branch = "master" + name = "golang.org/x/tools" + packages = [ + "cmd/goimports", + "go/ast/astutil", + "imports", + "internal/fastwalk" + ] + revision = "a5b4c53f6e8bdcafa95a94671bf2d1203365858b" + +[[projects]] + name = "gopkg.in/inf.v0" + packages = ["."] + revision = "3887ee99ecf07df5b447e9b00d9c0b2adaa9f3e4" + version = "v0.9.0" + +[[projects]] + branch = "v2" + name = "gopkg.in/yaml.v2" + packages = ["."] + revision = "d670f9405373e636a5a2765eea47fac0c9bc91a4" + +[[projects]] + name = "k8s.io/api" + packages = [ + "admissionregistration/v1alpha1", + "admissionregistration/v1beta1", + "apps/v1", + "apps/v1beta1", + "apps/v1beta2", + "authentication/v1", + "authentication/v1beta1", + "authorization/v1", + "authorization/v1beta1", + "autoscaling/v1", + "autoscaling/v2beta1", + "batch/v1", + "batch/v1beta1", + "batch/v2alpha1", + "certificates/v1beta1", + "core/v1", + "events/v1beta1", + "extensions/v1beta1", + "networking/v1", + "policy/v1beta1", + "rbac/v1", + "rbac/v1alpha1", + "rbac/v1beta1", + "scheduling/v1alpha1", + "settings/v1alpha1", + "storage/v1", + "storage/v1alpha1", + "storage/v1beta1" + ] + revision = "73d903622b7391f3312dcbac6483fed484e185f8" + version = "kubernetes-1.10.0" + +[[projects]] + name = "k8s.io/apimachinery" + packages = [ + "pkg/api/errors", + "pkg/api/meta", + "pkg/api/resource", + "pkg/apis/meta/internalversion", + "pkg/apis/meta/v1", + "pkg/apis/meta/v1/unstructured", + "pkg/apis/meta/v1beta1", + "pkg/conversion", + "pkg/conversion/queryparams", + "pkg/fields", + "pkg/labels", + "pkg/runtime", + "pkg/runtime/schema", + "pkg/runtime/serializer", + "pkg/runtime/serializer/json", + "pkg/runtime/serializer/protobuf", + "pkg/runtime/serializer/recognizer", + "pkg/runtime/serializer/streaming", + "pkg/runtime/serializer/versioning", + "pkg/selection", + "pkg/types", + "pkg/util/cache", + "pkg/util/clock", + "pkg/util/diff", + "pkg/util/errors", + "pkg/util/framer", + "pkg/util/intstr", + "pkg/util/json", + "pkg/util/net", + "pkg/util/runtime", + "pkg/util/sets", + "pkg/util/validation", + "pkg/util/validation/field", + "pkg/util/wait", + "pkg/util/yaml", + "pkg/version", + "pkg/watch", + "third_party/forked/golang/reflect" + ] + revision = "302974c03f7e50f16561ba237db776ab93594ef6" + version = "kubernetes-1.10.0" + +[[projects]] + name = "k8s.io/client-go" + packages = [ + "discovery", + "kubernetes", + "kubernetes/scheme", + "kubernetes/typed/admissionregistration/v1alpha1", + "kubernetes/typed/admissionregistration/v1beta1", + "kubernetes/typed/apps/v1", + "kubernetes/typed/apps/v1beta1", + "kubernetes/typed/apps/v1beta2", + "kubernetes/typed/authentication/v1", + "kubernetes/typed/authentication/v1beta1", + "kubernetes/typed/authorization/v1", + "kubernetes/typed/authorization/v1beta1", + "kubernetes/typed/autoscaling/v1", + "kubernetes/typed/autoscaling/v2beta1", + "kubernetes/typed/batch/v1", + "kubernetes/typed/batch/v1beta1", + "kubernetes/typed/batch/v2alpha1", + "kubernetes/typed/certificates/v1beta1", + "kubernetes/typed/core/v1", + "kubernetes/typed/events/v1beta1", + "kubernetes/typed/extensions/v1beta1", + "kubernetes/typed/networking/v1", + "kubernetes/typed/policy/v1beta1", + "kubernetes/typed/rbac/v1", + "kubernetes/typed/rbac/v1alpha1", + "kubernetes/typed/rbac/v1beta1", + "kubernetes/typed/scheduling/v1alpha1", + "kubernetes/typed/settings/v1alpha1", + "kubernetes/typed/storage/v1", + "kubernetes/typed/storage/v1alpha1", + "kubernetes/typed/storage/v1beta1", + "pkg/apis/clientauthentication", + "pkg/apis/clientauthentication/v1alpha1", + "pkg/version", + "plugin/pkg/client/auth/exec", + "rest", + "rest/watch", + "tools/auth", + "tools/cache", + "tools/clientcmd", + "tools/clientcmd/api", + "tools/clientcmd/api/latest", + "tools/clientcmd/api/v1", + "tools/metrics", + "tools/pager", + "tools/reference", + "transport", + "util/buffer", + "util/cert", + "util/flowcontrol", + "util/homedir", + "util/integer", + "util/retry" + ] + revision = "989be4278f353e42f26c416c53757d16fcff77db" + version = "kubernetes-1.10.1" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "3310854552e4a5a025770575d1e1c262ddd1c28e35b6b167a9a629e80dd170b0" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/components/environments/Gopkg.toml b/components/environments/Gopkg.toml new file mode 100644 index 000000000000..a7d1c2f89e5c --- /dev/null +++ b/components/environments/Gopkg.toml @@ -0,0 +1,19 @@ +required = [ + "golang.org/x/tools/cmd/goimports", +] + +[[constraint]] + name = "github.com/smartystreets/goconvey" + version = "1.6.3" + +[[constraint]] + name = "k8s.io/client-go" + version = "kubernetes-1.10.1" + +[[override]] + name = "k8s.io/api" + version = "kubernetes-1.10.1" + +[[override]] + name = "k8s.io/apimachinery" + version = "kubernetes-1.10.1" \ No newline at end of file diff --git a/components/environments/Jenkinsfile b/components/environments/Jenkinsfile new file mode 100644 index 000000000000..4642f1825b13 --- /dev/null +++ b/components/environments/Jenkinsfile @@ -0,0 +1,97 @@ +#!/usr/bin/env groovy +def label = "kyma-${UUID.randomUUID().toString()}" +def application = 'environments' +def isMaster = params.GIT_BRANCH == 'master' + +def dockerPushRoot = isMaster + ? "${env.DOCKER_REGISTRY}" + : "${env.DOCKER_REGISTRY}snapshot/" + +def dockerImageTag = isMaster + ? params.APP_VERSION + : params.GIT_BRANCH + +echo """ +******************************** +Job started with the following parameters: +DOCKER_REGISTRY=${env.DOCKER_REGISTRY} +DOCKER_CREDENTIALS=${env.DOCKER_CREDENTIALS} +GIT_REVISION=${params.GIT_REVISION} +GIT_BRANCH=${params.GIT_BRANCH} +APP_VERSION=${params.APP_VERSION} +APP_FOLDER=${env.APP_FOLDER} +******************************** +""" + +podTemplate(label: label) { + node(label) { + try { + timestamps { + timeout(time:20, unit:"MINUTES") { + ansiColor('xterm') { + stage("setup") { + checkout scm + + if(dockerImageTag == ""){ + error("No version for docker tag defined, please set APP_VERSION parameter for master branch or GIT_BRANCH parameter for any branch") + } + + withCredentials([usernamePassword(credentialsId: env.DOCKER_CREDENTIALS, passwordVariable: 'pwd', usernameVariable: 'uname')]) { + sh "docker login -u $uname -p '$pwd' $env.DOCKER_REGISTRY" + } + } + + stage("install dependencies $application") { + execute("make resolve") + } + + stage("code quality $application") { + execute("make validate") + } + + stage("build $application") { + execute("CGO_ENABLED=0 go build -o environments ./cmd/controller/") + } + + stage("test $application") { + execute("make test-report") + junit '**/unit-tests.xml' + } + + if (isMaster) { + stage("IP scan $application (Sourceclear)"){ + withCredentials([string(credentialsId: 'SRCCLR_API_TOKEN', variable: 'SRCCLR_API_TOKEN')]) { + execute("make scan","SRCCLR_API_TOKEN=$SRCCLR_API_TOKEN") + } + } + } + + stage("build image $application") { + dir(env.APP_FOLDER){ + sh "cp ./$application deploy/controller/$application" + sh "docker build -t $application:latest deploy/controller" + } + } + + stage("push image") { + sh "docker tag ${application}:latest ${dockerPushRoot}${application}:${dockerImageTag}" + sh "docker push ${dockerPushRoot}${application}:${dockerImageTag}" + } + } + } + } + } catch (ex) { + echo "Got exception: ${ex}" + currentBuild.result = "FAILURE" + def body = "${currentBuild.currentResult} ${env.JOB_NAME}${env.BUILD_DISPLAY_NAME}: on branch: ${params.GIT_BRANCH}. See details: ${env.BUILD_URL}" + emailext body: body, recipientProviders: [[$class: 'DevelopersRecipientProvider'], [$class: 'CulpritsRecipientProvider'], [$class: 'RequesterRecipientProvider']], subject: "${currentBuild.currentResult}: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'" + } + } +} + +def execute(command, envs = '') { + def buildpack = 'golang-buildpack:0.0.8' + def envText = envs=='' ? '' : "--env $envs" + workDir = pwd() + sh "docker run --rm -v $workDir:/go/src/github.com/kyma-project/kyma/ -w /go/src/github.com/kyma-project/kyma/$env.APP_FOLDER $envText ${env.DOCKER_REGISTRY}$buildpack /bin/bash -c '$command'" +} \ No newline at end of file diff --git a/components/environments/README.md b/components/environments/README.md new file mode 100644 index 000000000000..0a1241abf4e9 --- /dev/null +++ b/components/environments/README.md @@ -0,0 +1,69 @@ +## Overview + +This controller injects limit ranges, resource quotas, and default roles into each Namespace that you create. + +Developers create default roles from a roles template that they first define in a Namespace inside the Kubernetes cluster. +At the time of cluster provisioning, developers might define the roles in the `3-bootstrap-roles.yaml` file. The controller looks for roles labeled as `env=true` at the creation of the new Namespace. Next, the controller copies the roles to the new Namespace. + +Limit range configuration is required. These environment variables provide the configuration: +* `APP_LIMIT_RANGE_MEMORY_DEFAULT_REQUEST` +* `APP_LIMIT_RANGE_MEMORY_DEFAULT` +* `APP_LIMIT_RANGE_MEMORY_MAX` + +Each Kubernetes environment has a `ResourceQuota` configured with standard quantities (such as 1G1 or 256Mi) that are set in the following required configuration values: +* `APP_RESOURCE_QUOTA_REQUESTS_MEMORY` +* `APP_RESOURCE_QUOTA_LIMITS_MEMORY` + +## Prerequisites + + - A working Golang installation + - Minikube + - kubectl + - Docker + +### Build to run on Kubernetes + +Use the following commands to prepare to run on Kubernetes. Run them in the following order: + + - `dep ensure` + - `export GOOS=linux` + - `go build -o environments cmd/controller/main.go` + +### Build a Docker image + +Make sure that the [build](#build-to-run-on-kubernetes) step is complete. Run the following commands: + +- `cp ./environments deploy/controller/environments` +- `docker build -t environments:{your_tag} deploy/controller` + +Make sure the image is built: + +- `docker images | grep environments` + +### Run the image locally inside Kyma + +This section describes how to run Kyma with an updated environments image. The procedure is useful in case the component has been modified and needs to be tested. + +Read the main [Kyma project README.md](../../README.md). By default, the system runs the environments image specified in the [4-deployment.yaml](../../resources/core/charts/environments/templates/4-deployment.yaml) file. You can provide your own image by following one of the procedures. + +#### Docker registry + +If you have access to an external Docker registry, build your Docker image, push it to the registry and modify the [4-deployment.yaml](../../resources/core/charts/environments/templates/4-deployment.yaml) file by swapping the evironments image. Follow the [instructions](../../docs/kyma/docs/031-gs-local-installation.md) to run Kyma as usual. + +#### Minikube built in Docker daemon + +In case you have no access to a Docker registry, use Minikube’s built in Docker daemon that keeps images for running containers: + +1. Modify the [4-deployment.yaml](../../resources/core/charts/environments/templates/4-deployment.yaml) file by swapping the evironments image. +``` +image: environments:my_tag +``` + +2. [Start Kyma installation as usual](../../docs/kyma/docs/031-gs-local-installation.md). + +3. Run the following command to set up the Docker environment variables so a Docker client can communicate with the Minikube Docker daemon: +``` +eval $(minikube docker-env) +``` + +4. [Build your Docker image](#build-a-docker-image) with the tag you specified in the first step. Wait for the installation to complete. diff --git a/components/environments/before-commit.sh b/components/environments/before-commit.sh new file mode 100755 index 000000000000..0268cf188711 --- /dev/null +++ b/components/environments/before-commit.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash + +readonly CI_FLAG=ci + +RED='\033[0;31m' +GREEN='\033[0;32m' +INVERTED='\033[7m' +NC='\033[0m' # No Color + +echo -e "${INVERTED}" +echo "USER: " + $USER +echo "PATH: " + $PATH +echo "GOPATH:" + $GOPATH +echo -e "${NC}" + +## +# GO BUILD +## +buildEnv="" +if [ "$1" == "$CI_FLAG" ]; then + # build binary statically + buildEnv="env CGO_ENABLED=0" +fi + +${buildEnv} go build -o environments ./cmd/controller + +goBuildResult=$? +if [ ${goBuildResult} != 0 ]; then + echo -e "${RED}✗ go build${NC}\n$goBuildResult${NC}" + exit 1 +else echo -e "${GREEN}√ go build${NC}" +fi + +## +# DEP STATUS +## +echo "? dep status" +depResult=$(dep status -v) +if [ $? != 0 ]; then + echo -e "${RED}✗ dep status\n$depResult${NC}" + exit 1 +else echo -e "${GREEN}√ dep status${NC}" +fi + +## +# GO TEST +## +echo "? go test" +go test ./... +# Check if tests passed +if [ $? != 0 ]; then + echo -e "${RED}✗ go test\n${NC}" + exit 1 +else echo -e "${GREEN}√ go test${NC}" +fi + +goFilesToCheck=$(find . -type f -name "*.go" | egrep -v "/vendor") + +## +# GO IMPORTS & FMT +## +go build -o goimports-vendored ./vendor/golang.org/x/tools/cmd/goimports +buildGoImportResult=$? +if [ ${buildGoImportResult} != 0 ]; then + echo -e "${RED}✗ go build goimports${NC}\n$buildGoImportResult${NC}" + exit 1 +fi + +goImportsResult=$(echo "${goFilesToCheck}" | xargs -L1 ./goimports-vendored -w -l) +rm goimports-vendored + +if [ $(echo ${#goImportsResult}) != 0 ]; then + echo -e "${RED}✗ goimports and fmt${NC}\n$goImportsResult${NC}" + exit 1 +else echo -e "${GREEN}√ goimports and fmt${NC}" +fi + +## +# GO VET +## +packagesToVet=("./cmd/..." "./internal/...") + +for vPackage in "${packagesToVet[@]}"; do + vetResult=$(go vet ${vPackage}) + if [ $(echo ${#vetResult}) != 0 ]; then + echo -e "${RED}✗ go vet ${vPackage} ${NC}\n$vetResult${NC}" + exit 1 + else echo -e "${GREEN}√ go vet ${vPackage} ${NC}" + fi +done + +## +# INFO.JSON +## +author=$(git show -s --pretty=%an) +# the branch calcualtion looks as follow, because the git checkout process works differently on jenkins and our local machine +branch=${GIT_BRANCH} +if [$branch == ""]; then + branch=$(git rev-parse --abbrev-ref HEAD) +fi +commit=$(git rev-parse --verify HEAD) +commitMsg=$(git show -s --pretty=%s) +commitDate=$(git log -1 --format=%cd) +printf "{\n\t\"author\": \"""$author""\",\n\t\"commit\": \""$commit"\",\n\t\"branch\": \""$branch"\",\n\t\"commitDate\":\"""$commitDate""\",\n\t\"commitMessage\":\"""$commitMsg""\",\n\t\"deployDate\": \"""$(date)""\"\n}" >info.json +echo -e "${GREEN}√ info.json ${NC}" $(cat info.json) diff --git a/components/environments/cmd/controller/main.go b/components/environments/cmd/controller/main.go new file mode 100644 index 000000000000..3ee3c22e42f0 --- /dev/null +++ b/components/environments/cmd/controller/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "flag" + "log" + + "github.com/kyma-project/kyma/components/environments/internal/controller" + "github.com/pkg/errors" + "github.com/vrischmann/envconfig" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +func main() { + + kubeconfig := flag.String("kubeconfig", "", "Path to a kubeconfig file") + + var cfg controller.EnvironmentsConfig + err := envconfig.InitWithPrefix(&cfg, "APP") + panicOnError(errors.Wrap(err, "while reading configuration from environment variables")) + + flag.Parse() + + config, err := getClientConfig(*kubeconfig) + panicOnError(err) + + clientset, err := kubernetes.NewForConfig(config) + panicOnError(err) + + controllerInstance, err := controller.NewController(clientset, &cfg) + panicOnError(err) + + stop := make(chan struct{}) + go controllerInstance.Run(stop) + + log.Println("Started listening") + + <-stop +} + +func panicOnError(err error) { + if err != nil { + panic(err) + } +} + +func getClientConfig(kubeconfig string) (*rest.Config, error) { + if kubeconfig != "" { + return clientcmd.BuildConfigFromFlags("", kubeconfig) + } + return rest.InClusterConfig() +} diff --git a/components/environments/deploy/controller/Dockerfile b/components/environments/deploy/controller/Dockerfile new file mode 100644 index 000000000000..3302957ad563 --- /dev/null +++ b/components/environments/deploy/controller/Dockerfile @@ -0,0 +1,10 @@ +FROM alpine:3.7 + +RUN apk --no-cache add ca-certificates +RUN apk add --no-cache curl + +LABEL source="github.com:kyma-project/kyma.git" + +ADD environments /environments + +ENTRYPOINT ["/environments"] diff --git a/components/environments/examples/namespace-test.yaml b/components/environments/examples/namespace-test.yaml new file mode 100644 index 000000000000..aaadab987e51 --- /dev/null +++ b/components/environments/examples/namespace-test.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: toad-test-1 + labels: + env: "true" + myLabel: "ts" diff --git a/components/environments/internal/controller/config.go b/components/environments/internal/controller/config.go new file mode 100644 index 000000000000..791c4de65ce4 --- /dev/null +++ b/components/environments/internal/controller/config.go @@ -0,0 +1,34 @@ +package controller + +import ( + "k8s.io/apimachinery/pkg/api/resource" +) + +type EnvironmentsConfig struct { + Namespace string + LimitRangeMemory LimitRangeConfig + ResourceQuota ResourceQuotaConfig +} + +type FormattedQuantity int64 + +func (fq *FormattedQuantity) Unmarshal(value string) error { + val, err := resource.ParseQuantity(value) + *fq = FormattedQuantity(val.Value()) + return err +} + +func (fq *FormattedQuantity) AsQuantity() *resource.Quantity { + return resource.NewQuantity(int64(*fq), resource.BinarySI) +} + +type ResourceQuotaConfig struct { + LimitsMemory FormattedQuantity + RequestsMemory FormattedQuantity +} + +type LimitRangeConfig struct { + DefaultRequest FormattedQuantity + Default FormattedQuantity + Max FormattedQuantity +} diff --git a/components/environments/internal/controller/controller.go b/components/environments/internal/controller/controller.go new file mode 100644 index 000000000000..340dd4ac9b84 --- /dev/null +++ b/components/environments/internal/controller/controller.go @@ -0,0 +1,331 @@ +package controller + +import ( + "fmt" + "log" + + "github.com/kyma-project/kyma/components/environments/internal" + "github.com/kyma-project/kyma/components/environments/internal/limit_range" + "github.com/kyma-project/kyma/components/environments/internal/namespaces" + rq "github.com/kyma-project/kyma/components/environments/internal/resource-quota" + "github.com/kyma-project/kyma/components/environments/internal/roles" + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/cache" +) + +const ( + rolesAnnotName = "kyma-roles" + labelSelector = "env=true" + istioInjectionLabel = "istio-injection" + istioInjectionLabelValue = "enabled" + resourceQuotaName = "kyma-default" + limitRangeName = "kyma-default" +) + +var listOptions = metav1.ListOptions{LabelSelector: labelSelector} + +type environments struct { + Clientset *kubernetes.Clientset + NamespacesClient namespaces.NamespacesClientInterface + RolesClient roles.RolesClientInterface + LimitRangeClient limit_range.LimitRangesClientInterface + ResourceQuotaClient rq.ResourceQuotaClientInterface + Config *EnvironmentsConfig + ErrorHandlers internal.ErrorHandlersInterface +} + +func NewController(clientset *kubernetes.Clientset, config *EnvironmentsConfig) (cache.Controller, error) { + envs := environments{ + Clientset: clientset, + Config: config, + NamespacesClient: &namespaces.NamespacesClient{Clientset: clientset}, + RolesClient: &roles.RolesClient{Clientset: clientset}, + LimitRangeClient: &limit_range.LimitRangeClient{Clientset: clientset}, + ResourceQuotaClient: &rq.ResourceQuotaClient{Clientset: clientset}, + ErrorHandlers: &internal.ErrorHandlers{}, + } + + _, controller := cache.NewInformer( + enviromentsWatcher{clientset.CoreV1().Namespaces()}, + &v1.Namespace{}, + 0, + cache.ResourceEventHandlerFuncs{ + AddFunc: envs.onAdd, + UpdateFunc: func(oldObj, newObj interface{}) {}, + DeleteFunc: envs.onDelete, + }) + + return controller, nil +} + +func (envs *environments) onAdd(obj interface{}) { + namespace := obj.(*v1.Namespace) + + log.Printf("[CONTROLLER] onAdd triggered for %s\n", namespace.Name) + + envs.AddRolesForEnvironment(namespace) + envs.LabelWithIstioInjection(namespace) + envs.CreateLimitRangeForEnv(namespace) + envs.CreateResourceQuota(namespace) +} + +func (envs *environments) onDelete(obj interface{}) { + namespace := obj.(*v1.Namespace) + + log.Printf("[CONTROLLER] onDelete triggered for %s\n", namespace.Name) + + envs.RemoveRolesFromEnvironment(namespace) + envs.RemoveIstioInjectionLabel(namespace) + envs.DeleteLimitRange(namespace) + envs.DeleteResourceQuota(namespace) +} + +func hasRoles(namespace *v1.Namespace) bool { + return contains(namespace.ObjectMeta.Annotations, rolesAnnotName) +} + +func (envs *environments) AddRolesForEnvironment(environment *v1.Namespace) error { + namespace, err := envs.NamespacesClient.GetNamespace(environment.Name) + if envs.ErrorHandlers.CheckError("Error while getting namespace.", err) { + return err + } + + if hasRoles(namespace) { + log.Println("Namespace already have default roles.") + return nil + } + + rolesToBootstrap, err := envs.RolesClient.GetList(envs.Config.Namespace, listOptions) + if envs.ErrorHandlers.CheckError("Error on fetching roles to bootstrap.", err) { + return err + } + + for _, role := range rolesToBootstrap.Items { + roleCopy := role.DeepCopy() + roleCopy.ObjectMeta = metav1.ObjectMeta{ + Name: role.ObjectMeta.Name, + Namespace: namespace.Name, + } + + _, err = envs.RolesClient.CreateRole(roleCopy, namespace.Name) + envs.ErrorHandlers.LogError(fmt.Sprintf("Error on creating %s role", roleCopy.ObjectMeta.Name), err) + } + + err = envs.annotateObject(namespace, rolesAnnotName) + if envs.ErrorHandlers.CheckError("Error on updating namespace.", err) { + return err + } + + return nil +} + +func (envs *environments) LabelWithIstioInjection(environment *v1.Namespace) error { + namespace, err := envs.NamespacesClient.GetNamespace(environment.Name) + if envs.ErrorHandlers.CheckError("Error while getting namespace.", err) { + return err + } + + err = envs.labelNamespace(namespace, istioInjectionLabel, istioInjectionLabelValue) + if envs.ErrorHandlers.CheckError("Error on updating namespace.", err) { + return err + } + + return nil +} + +func (envs *environments) CreateLimitRangeForEnv(environment *v1.Namespace) error { + err := envs.LimitRangeClient.CreateLimitRange(environment.Name, &v1.LimitRange{ + ObjectMeta: metav1.ObjectMeta{ + Name: limitRangeName, + }, + Spec: v1.LimitRangeSpec{ + Limits: []v1.LimitRangeItem{ + { + Type: v1.LimitTypeContainer, + Default: v1.ResourceList{ + v1.ResourceMemory: *envs.Config.LimitRangeMemory.Default.AsQuantity(), + }, + DefaultRequest: v1.ResourceList{ + v1.ResourceMemory: *envs.Config.LimitRangeMemory.DefaultRequest.AsQuantity(), + }, + Max: v1.ResourceList{ + v1.ResourceMemory: *envs.Config.LimitRangeMemory.Max.AsQuantity(), + }, + }, + }, + }, + }) + if envs.ErrorHandlers.CheckError("Error on creating limit range", err) { + return err + } + + return nil +} + +func (envs *environments) CreateResourceQuota(namespace *v1.Namespace) error { + err := envs.ResourceQuotaClient.CreateResourceQuota(namespace.Name, &v1.ResourceQuota{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceQuotaName, + }, + Spec: v1.ResourceQuotaSpec{ + Hard: v1.ResourceList{ + v1.ResourceRequestsMemory: *envs.Config.ResourceQuota.RequestsMemory.AsQuantity(), + v1.ResourceLimitsMemory: *envs.Config.ResourceQuota.LimitsMemory.AsQuantity(), + }, + }, + }) + + if envs.ErrorHandlers.CheckError("Error on creating resource quota", err) { + return err + } + + return nil +} + +func (envs *environments) annotateObject(namespace *v1.Namespace, annotName string) error { + origAnnotations := namespace.GetAnnotations() + + if origAnnotations == nil { + origAnnotations = make(map[string]string) + } + + origAnnotations[annotName] = "true" + + namespaceCopy := namespace.DeepCopy() + namespaceCopy.SetAnnotations(origAnnotations) + + _, err := envs.NamespacesClient.UpdateNamespace(namespaceCopy) + + if envs.ErrorHandlers.CheckError("Error annotating object", err) { + return err + } + + return nil +} + +func (envs *environments) labelNamespace(namespace *v1.Namespace, labelName string, labelValue string) error { + origLabels := namespace.GetLabels() + + origLabels[labelName] = labelValue + + namespaceCopy := namespace.DeepCopy() + namespaceCopy.SetLabels(origLabels) + + _, err := envs.NamespacesClient.UpdateNamespace(namespaceCopy) + if envs.ErrorHandlers.CheckError("Error labelling object", err) { + return err + } + + return nil +} + +func (envs *environments) RemoveRolesFromEnvironment(environment *v1.Namespace) error { + namespace, err := envs.NamespacesClient.GetNamespace(environment.Name) + + if envs.ErrorHandlers.CheckError("Error while getting namespace.", err) { + return err + } + + if !hasRoles(namespace) { + return nil + } + + err = envs.unannotateObject(namespace, rolesAnnotName) + if envs.ErrorHandlers.CheckError("Error on updating namespace.", err) { + return err + } + + rolesToDelete, err := envs.RolesClient.GetList(envs.Config.Namespace, listOptions) + if envs.ErrorHandlers.CheckError("Error on fetching roles to delete.", err) { + return err + } + + for _, role := range rolesToDelete.Items { + err = envs.RolesClient.DeleteRole(role.ObjectMeta.Name, namespace.Name) + envs.ErrorHandlers.LogError(fmt.Sprintf("Error on deleting %s role", role.ObjectMeta.Name), err) + } + + return nil +} + +func (envs *environments) RemoveIstioInjectionLabel(environment *v1.Namespace) error { + + namespace, err := envs.NamespacesClient.GetNamespace(environment.Name) + if envs.ErrorHandlers.CheckError("Error while getting namespace.", err) { + return err + } + + err = envs.removeLabelFromNamespace(namespace, istioInjectionLabel) + if envs.ErrorHandlers.CheckError("Error on updating namespace.", err) { + return err + } + + return nil +} + +func (envs *environments) DeleteLimitRange(environment *v1.Namespace) error { + err := envs.LimitRangeClient.DeleteLimitRange(environment.Name) + if envs.ErrorHandlers.CheckError("Error on deleting limit range.", err) { + return err + } + + return nil +} + +func (envs *environments) DeleteResourceQuota(namespace *v1.Namespace) error { + err := envs.ResourceQuotaClient.DeleteResourceQuota(namespace.Name) + if envs.ErrorHandlers.CheckError("Error on deleting resource quota.", err) { + return err + } + + return nil +} + +func (envs *environments) unannotateObject(namespace *v1.Namespace, annotName string) error { + origAnnotations := namespace.GetAnnotations() + + if origAnnotations == nil { + log.Println("Unable to unannotate. Provided object is not annotated!") + return nil + } + + delete(origAnnotations, annotName) + + namespaceCopy := namespace.DeepCopy() + namespaceCopy.SetAnnotations(origAnnotations) + + _, err := envs.NamespacesClient.UpdateNamespace(namespaceCopy) + if envs.ErrorHandlers.CheckError("Error unannotating object", err) { + return err + } + + return nil +} + +func (envs *environments) removeLabelFromNamespace(namespace *v1.Namespace, labelName string) error { + origLabels := namespace.GetLabels() + + delete(origLabels, labelName) + + namespaceCopy := namespace.DeepCopy() + namespaceCopy.SetLabels(origLabels) + + _, err := envs.NamespacesClient.UpdateNamespace(namespaceCopy) + if envs.ErrorHandlers.CheckError("Error removing label", err) { + return err + } + + return nil +} + +func contains(slice map[string]string, item string) bool { + for key := range slice { + if key == item { + return true + } + } + + return false +} diff --git a/components/environments/internal/controller/controller_test.go b/components/environments/internal/controller/controller_test.go new file mode 100644 index 000000000000..647c08b94865 --- /dev/null +++ b/components/environments/internal/controller/controller_test.go @@ -0,0 +1,365 @@ +package controller + +import ( + "testing" + + "github.com/kyma-project/kyma/components/environments/internal" + . "github.com/smartystreets/goconvey/convey" + "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type testNamespacesClient struct { + GetNamespaceCalled bool + UpdateNamespaceCalled bool +} + +type testRolesClient struct { + GetRoleCalled bool + GetListCalled bool + CreateRoleCalled bool + DeleteRoleCalled bool +} + +type testLimitRangeClient struct { + CreateCalled bool + DeleteCalled bool +} + +type testResourceQuotaClient struct { + CreateCalled bool + DeleteCalled bool +} + +var testNamespace = &v1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testNamespace", + }, +} + +var testEnvironmentsConfig = &EnvironmentsConfig{ + Namespace: "configSecretNamespace", + LimitRangeMemory: LimitRangeConfig{ + Max: formattedQuantity("1024Mi"), + Default: formattedQuantity("96Mi"), + DefaultRequest: formattedQuantity("32Mi"), + }, + ResourceQuota: ResourceQuotaConfig{ + LimitsMemory: formattedQuantity("1Gi"), + RequestsMemory: formattedQuantity("256Mi"), + }, +} + +var testLimitRange = &v1.LimitRange{ + ObjectMeta: metav1.ObjectMeta{ + Name: testNamespace.Name, + }, + Spec: v1.LimitRangeSpec{ + Limits: []v1.LimitRangeItem{ + { + Type: v1.LimitTypeContainer, + Default: v1.ResourceList{ + v1.ResourceMemory: *testEnvironmentsConfig.LimitRangeMemory.Default.AsQuantity(), + }, + DefaultRequest: v1.ResourceList{ + v1.ResourceMemory: *testEnvironmentsConfig.LimitRangeMemory.DefaultRequest.AsQuantity(), + }, + Max: v1.ResourceList{ + v1.ResourceMemory: *testEnvironmentsConfig.LimitRangeMemory.Max.AsQuantity(), + }, + }, + }, + }, +} + +var testRole = rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kyma-admin", + Namespace: "configSecretNamespace", + }, +} + +var testRolesList = &rbacv1.RoleList{ + Items: []rbacv1.Role{testRole}, +} + +func GetTestSetup() (envs *environments, nc *testNamespacesClient, rc *testRolesClient, lr *testLimitRangeClient, rq *testResourceQuotaClient) { + + nc = &testNamespacesClient{GetNamespaceCalled: false, UpdateNamespaceCalled: false} + rc = &testRolesClient{GetRoleCalled: false, GetListCalled: false, CreateRoleCalled: false, DeleteRoleCalled: false} + lr = &testLimitRangeClient{DeleteCalled: false, CreateCalled: false} + rq = &testResourceQuotaClient{DeleteCalled: false, CreateCalled: false} + + envs = &environments{ + Clientset: nil, + Config: testEnvironmentsConfig, + NamespacesClient: nc, + RolesClient: rc, + LimitRangeClient: lr, + ResourceQuotaClient: rq, + ErrorHandlers: &internal.ErrorHandlers{}, + } + + return envs, nc, rc, lr, rq +} + +func (nc *testNamespacesClient) GetNamespace(name string) (result *v1.Namespace, err error) { + So(testNamespace.Name, ShouldEqual, name) + + nc.GetNamespaceCalled = true + + return testNamespace, nil +} + +func (nc *testNamespacesClient) UpdateNamespace(namespace *v1.Namespace) (result *v1.Namespace, err error) { + So(testNamespace.Name, ShouldEqual, namespace.Name) + nc.UpdateNamespaceCalled = true + + return testNamespace, nil +} + +func (rc *testRolesClient) GetList(namespace string, opts metav1.ListOptions) (*rbacv1.RoleList, error) { + So(testEnvironmentsConfig.Namespace, ShouldEqual, namespace) + rc.GetListCalled = true + + return testRolesList, nil +} + +func (rc *testRolesClient) GetRole(name string, namespace string) (*rbacv1.Role, error) { + So(testNamespace.Name, ShouldEqual, namespace) + So(testRole.ObjectMeta.Name, ShouldEqual, name) + rc.GetRoleCalled = true + + return &testRole, nil +} + +func (rc *testRolesClient) CreateRole(role *rbacv1.Role, namespace string) (*rbacv1.Role, error) { + So(testNamespace.Name, ShouldEqual, namespace) + So(testRole.ObjectMeta.Name, ShouldEqual, role.ObjectMeta.Name) + rc.CreateRoleCalled = true + + return &testRole, nil +} + +func (rc *testRolesClient) DeleteRole(name string, namespace string) error { + So(testNamespace.Name, ShouldEqual, namespace) + So(testRole.ObjectMeta.Name, ShouldEqual, name) + rc.DeleteRoleCalled = true + + return nil +} + +func (lr *testLimitRangeClient) CreateLimitRange(namespace string, limitRange *v1.LimitRange) error { + So(testNamespace.Name, ShouldEqual, namespace) + So(limitRange.Name, ShouldEqual, "kyma-default") + So(limitRange.Spec.Limits[0].Default.Memory().Value(), ShouldEqual, testEnvironmentsConfig.LimitRangeMemory.Default.AsQuantity().Value()) + So(limitRange.Spec.Limits[0].DefaultRequest.Memory().Value(), ShouldEqual, testEnvironmentsConfig.LimitRangeMemory.DefaultRequest.AsQuantity().Value()) + So(limitRange.Spec.Limits[0].Max.Memory().Value(), ShouldEqual, testEnvironmentsConfig.LimitRangeMemory.Max.AsQuantity().Value()) + lr.CreateCalled = true + + return nil +} + +func (lr *testLimitRangeClient) DeleteLimitRange(namespace string) error { + So(testNamespace.Name, ShouldEqual, namespace) + lr.DeleteCalled = true + + return nil +} + +func (lr *testResourceQuotaClient) CreateResourceQuota(namespace string, resourceQuota *v1.ResourceQuota) error { + rm := resourceQuota.Spec.Hard[v1.ResourceRequestsMemory] + lm := resourceQuota.Spec.Hard[v1.ResourceLimitsMemory] + + So(testNamespace.Name, ShouldEqual, namespace) + So(resourceQuota.Name, ShouldEqual, "kyma-default") + So(rm.String(), ShouldEqual, "256Mi") + So(lm.String(), ShouldEqual, "1Gi") + lr.CreateCalled = true + return nil +} + +func (lr *testResourceQuotaClient) DeleteResourceQuota(namespace string) error { + So(testNamespace.Name, ShouldEqual, namespace) + lr.DeleteCalled = true + return nil +} + +func TestYfenvironments(t *testing.T) { + Convey("Adding roles for environment shouldn't return error", t, func() { + + envs, nc, rc, lr, _ := GetTestSetup() + + err := envs.AddRolesForEnvironment(testNamespace) + + So(err, ShouldBeNil) + So(nc.GetNamespaceCalled, ShouldBeTrue) + So(nc.UpdateNamespaceCalled, ShouldBeTrue) + + allLimitRangeClientMethodsShouldNotBeCalled(lr) + + So(rc.CreateRoleCalled, ShouldBeTrue) + So(rc.GetListCalled, ShouldBeTrue) + So(rc.DeleteRoleCalled, ShouldBeFalse) + So(rc.GetRoleCalled, ShouldBeFalse) + }) + + Convey("Should not add roles for environment with existing roles", t, func() { + + origNamespace := testNamespace.DeepCopy() + envs, nc, rc, lr, _ := GetTestSetup() + + annotations := make(map[string]string) + annotations[rolesAnnotName] = "true" + testNamespace.SetAnnotations(annotations) + + err := envs.AddRolesForEnvironment(testNamespace) + + So(err, ShouldBeNil) + So(nc.GetNamespaceCalled, ShouldBeTrue) + So(nc.UpdateNamespaceCalled, ShouldBeFalse) + + allRolesClientMethodsShuldNotBeCalled(rc) + allLimitRangeClientMethodsShouldNotBeCalled(lr) + + Reset(func() { + testNamespace = origNamespace + }) + }) + + Convey("Removing roles from environment shouldn't return error", t, func() { + + origNamespace := testNamespace.DeepCopy() + envs, nc, rc, lr, _ := GetTestSetup() + + annotations := make(map[string]string) + annotations[rolesAnnotName] = "true" + testNamespace.SetAnnotations(annotations) + + err := envs.RemoveRolesFromEnvironment(testNamespace) + + So(err, ShouldBeNil) + So(nc.GetNamespaceCalled, ShouldBeTrue) + So(nc.UpdateNamespaceCalled, ShouldBeTrue) + + allLimitRangeClientMethodsShouldNotBeCalled(lr) + + So(rc.GetListCalled, ShouldBeTrue) + So(rc.DeleteRoleCalled, ShouldBeTrue) + So(rc.CreateRoleCalled, ShouldBeFalse) + So(rc.GetRoleCalled, ShouldBeFalse) + + Reset(func() { + testNamespace = origNamespace + }) + }) + + Convey("istio-inject label", t, func() { + + origNamespace := testNamespace.DeepCopy() + envs, nc, _, _, _ := GetTestSetup() + + Convey("should be added with no error", func() { + + labels := make(map[string]string) + testNamespace.SetLabels(labels) + + err := envs.LabelWithIstioInjection(testNamespace) + + So(err, ShouldBeNil) + So(nc.GetNamespaceCalled, ShouldBeTrue) + So(nc.UpdateNamespaceCalled, ShouldBeTrue) + }) + + Convey("and removing with no error as well", func() { + + err := envs.RemoveIstioInjectionLabel(testNamespace) + + So(err, ShouldBeNil) + So(nc.GetNamespaceCalled, ShouldBeTrue) + So(nc.UpdateNamespaceCalled, ShouldBeTrue) + }) + + Reset(func() { + testNamespace = origNamespace + }) + }) + + Convey("should create limit range", t, func() { + + origNamespace := testNamespace.DeepCopy() + envs, _, _, lr, _ := GetTestSetup() + + err := envs.CreateLimitRangeForEnv(testNamespace) + + So(err, ShouldBeNil) + So(lr.CreateCalled, ShouldBeTrue) + + Reset(func() { + testNamespace = origNamespace + }) + }) + + Convey("should delete limit range", t, func() { + + origNamespace := testNamespace.DeepCopy() + envs, _, _, lr, _ := GetTestSetup() + + err := envs.DeleteLimitRange(testNamespace) + + So(err, ShouldBeNil) + So(lr.DeleteCalled, ShouldBeTrue) + + Reset(func() { + testNamespace = origNamespace + }) + }) + + Convey("should create resource quota", t, func() { + + origNamespace := testNamespace.DeepCopy() + envs, _, _, _, rq := GetTestSetup() + + err := envs.CreateResourceQuota(testNamespace) + + So(err, ShouldBeNil) + So(rq.CreateCalled, ShouldBeTrue) + + Reset(func() { + testNamespace = origNamespace + }) + }) + + Convey("should delete resource quota", t, func() { + + origNamespace := testNamespace.DeepCopy() + envs, _, _, _, rq := GetTestSetup() + + err := envs.DeleteResourceQuota(testNamespace) + + So(err, ShouldBeNil) + So(rq.DeleteCalled, ShouldBeTrue) + + Reset(func() { + testNamespace = origNamespace + }) + }) +} + +func allRolesClientMethodsShuldNotBeCalled(rc *testRolesClient) { + So(rc.CreateRoleCalled, ShouldBeFalse) + So(rc.GetListCalled, ShouldBeFalse) + So(rc.GetRoleCalled, ShouldBeFalse) +} + +func allLimitRangeClientMethodsShouldNotBeCalled(lr *testLimitRangeClient) { + So(lr.CreateCalled, ShouldBeFalse) + So(lr.DeleteCalled, ShouldBeFalse) +} + +func formattedQuantity(v string) FormattedQuantity { + q := resource.MustParse(v) + return FormattedQuantity(q.Value()) +} diff --git a/components/environments/internal/controller/environment_watcher.go b/components/environments/internal/controller/environment_watcher.go new file mode 100644 index 000000000000..782f5c6b379f --- /dev/null +++ b/components/environments/internal/controller/environment_watcher.go @@ -0,0 +1,20 @@ +package controller + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes/typed/core/v1" +) + +type enviromentsWatcher struct { + ns v1.NamespaceInterface +} + +func (m enviromentsWatcher) List(options metav1.ListOptions) (runtime.Object, error) { + return m.ns.List(listOptions) +} + +func (m enviromentsWatcher) Watch(options metav1.ListOptions) (watch.Interface, error) { + return m.ns.Watch(listOptions) +} diff --git a/components/environments/internal/errors.go b/components/environments/internal/errors.go new file mode 100644 index 000000000000..cd7f185d1f50 --- /dev/null +++ b/components/environments/internal/errors.go @@ -0,0 +1,27 @@ +package internal + +import ( + "log" +) + +type ErrorHandlersInterface interface { + CheckError(msg string, err error) bool + LogError(msg string, err error) +} + +type ErrorHandlers struct { +} + +func (eh *ErrorHandlers) CheckError(msg string, err error) bool { + if err != nil { + log.Printf("%s Details: %s", msg, err.Error()) + return true + } + return false +} + +func (eh *ErrorHandlers) LogError(msg string, err error) { + if err != nil { + log.Printf("%s Details: %s", msg, err.Error()) + } +} diff --git a/components/environments/internal/limit_range/client.go b/components/environments/internal/limit_range/client.go new file mode 100644 index 000000000000..df6c33e53a0e --- /dev/null +++ b/components/environments/internal/limit_range/client.go @@ -0,0 +1,33 @@ +package limit_range + +import ( + "github.com/pkg/errors" + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +type LimitRangesClientInterface interface { + CreateLimitRange(namespace string, limitRange *v1.LimitRange) error + DeleteLimitRange(namespace string) error +} + +type LimitRangeClient struct { + Clientset *kubernetes.Clientset +} + +func (lr *LimitRangeClient) CreateLimitRange(namespace string, limitRange *v1.LimitRange) error { + _, err := lr.Clientset.CoreV1().LimitRanges(namespace).Create(limitRange) + if err != nil { + return errors.Wrapf(err, "cannot create limit range in namespace: %s", namespace) + } + return nil +} + +func (lr *LimitRangeClient) DeleteLimitRange(namespace string) error { + err := lr.Clientset.CoreV1().LimitRanges(namespace).Delete(namespace, &metav1.DeleteOptions{}) + if err != nil { + return errors.Wrapf(err, "cannot delete limit range from namespace: %s", namespace) + } + return nil +} diff --git a/components/environments/internal/namespaces/client.go b/components/environments/internal/namespaces/client.go new file mode 100644 index 000000000000..5e47bda78adc --- /dev/null +++ b/components/environments/internal/namespaces/client.go @@ -0,0 +1,24 @@ +package namespaces + +import ( + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +type NamespacesClientInterface interface { + GetNamespace(name string) (*v1.Namespace, error) + UpdateNamespace(namespace *v1.Namespace) (*v1.Namespace, error) +} + +type NamespacesClient struct { + Clientset *kubernetes.Clientset +} + +func (nc *NamespacesClient) GetNamespace(name string) (*v1.Namespace, error) { + return nc.Clientset.CoreV1().Namespaces().Get(name, metav1.GetOptions{}) +} + +func (nc *NamespacesClient) UpdateNamespace(namespace *v1.Namespace) (*v1.Namespace, error) { + return nc.Clientset.CoreV1().Namespaces().Update(namespace) +} diff --git a/components/environments/internal/resource-quota/client.go b/components/environments/internal/resource-quota/client.go new file mode 100644 index 000000000000..bf2ec8739327 --- /dev/null +++ b/components/environments/internal/resource-quota/client.go @@ -0,0 +1,33 @@ +package limit_range + +import ( + "github.com/pkg/errors" + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +type ResourceQuotaClientInterface interface { + CreateResourceQuota(namespace string, resourceQuota *v1.ResourceQuota) error + DeleteResourceQuota(namespace string) error +} + +type ResourceQuotaClient struct { + Clientset *kubernetes.Clientset +} + +func (lr *ResourceQuotaClient) CreateResourceQuota(namespace string, resourceQuota *v1.ResourceQuota) error { + _, err := lr.Clientset.CoreV1().ResourceQuotas(namespace).Create(resourceQuota) + if err != nil { + return errors.Wrapf(err, "cannot create resource quota in namespace: %s", namespace) + } + return nil +} + +func (lr *ResourceQuotaClient) DeleteResourceQuota(namespace string) error { + err := lr.Clientset.CoreV1().ResourceQuotas(namespace).Delete(namespace, &metav1.DeleteOptions{}) + if err != nil { + return errors.Wrapf(err, "cannot delete resource quota from namespace: %s", namespace) + } + return nil +} diff --git a/components/environments/internal/roles/client.go b/components/environments/internal/roles/client.go new file mode 100644 index 000000000000..b4fd364914ff --- /dev/null +++ b/components/environments/internal/roles/client.go @@ -0,0 +1,34 @@ +package roles + +import ( + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +type RolesClientInterface interface { + GetRole(name string, namespace string) (*rbacv1.Role, error) + GetList(namespace string, opts metav1.ListOptions) (*rbacv1.RoleList, error) + CreateRole(role *rbacv1.Role, namespace string) (*rbacv1.Role, error) + DeleteRole(name string, namespace string) error +} + +type RolesClient struct { + Clientset *kubernetes.Clientset +} + +func (rc *RolesClient) GetRole(name string, namespace string) (*rbacv1.Role, error) { + return rc.Clientset.Rbac().Roles(namespace).Get(name, metav1.GetOptions{}) +} + +func (rc *RolesClient) GetList(namespace string, opts metav1.ListOptions) (*rbacv1.RoleList, error) { + return rc.Clientset.Rbac().Roles(namespace).List(opts) +} + +func (rc *RolesClient) CreateRole(role *rbacv1.Role, namespace string) (*rbacv1.Role, error) { + return rc.Clientset.Rbac().Roles(namespace).Create(role) +} + +func (rc *RolesClient) DeleteRole(name string, namespace string) error { + return rc.Clientset.Rbac().Roles(namespace).Delete(name, nil) +} diff --git a/components/event-bus/.gitignore b/components/event-bus/.gitignore new file mode 100644 index 000000000000..bf31c4234a8b --- /dev/null +++ b/components/event-bus/.gitignore @@ -0,0 +1,12 @@ +# Visual Studio Code +/.vscode/ +*.code-workspace +# Mac OS +.DS_Store + +/.idea/ +*.iml + +bin/ +docker/ +vendor/ diff --git a/components/event-bus/Gopkg.lock b/components/event-bus/Gopkg.lock new file mode 100644 index 000000000000..29240f0f2c64 --- /dev/null +++ b/components/event-bus/Gopkg.lock @@ -0,0 +1,508 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/Shopify/sarama" + packages = ["."] + revision = "35324cf48e33d8260e1c7c18854465a904ade249" + version = "v1.17.0" + +[[projects]] + branch = "master" + name = "github.com/apache/thrift" + packages = ["lib/go/thrift"] + revision = "f12cacf56145e2c8f0d4429694fedf5453648089" + +[[projects]] + name = "github.com/davecgh/go-spew" + packages = ["spew"] + revision = "346938d642f2ec3594ed81d874461961cd0faa76" + version = "v1.1.0" + +[[projects]] + name = "github.com/eapache/go-resiliency" + packages = ["breaker"] + revision = "ea41b0fad31007accc7f806884dcdf3da98b79ce" + version = "v1.1.0" + +[[projects]] + branch = "master" + name = "github.com/eapache/go-xerial-snappy" + packages = ["."] + revision = "040cc1a32f578808623071247fdbd5cc43f37f5f" + +[[projects]] + name = "github.com/eapache/queue" + packages = ["."] + revision = "44cc805cf13205b55f69e14bcb69867d1ae92f98" + version = "v1.1.0" + +[[projects]] + name = "github.com/ghodss/yaml" + packages = ["."] + revision = "0ca9ea5df5451ffdf184b4428c902747c2c11cd7" + version = "v1.0.0" + +[[projects]] + name = "github.com/go-logfmt/logfmt" + packages = ["."] + revision = "390ab7935ee28ec6b286364bba9b4dd6410cb3d5" + version = "v0.3.0" + +[[projects]] + name = "github.com/go-sql-driver/mysql" + packages = ["."] + revision = "d523deb1b23d913de5bdada721a6071e71283618" + version = "v1.4.0" + +[[projects]] + name = "github.com/gogo/protobuf" + packages = [ + "gogoproto", + "proto", + "protoc-gen-gogo/descriptor", + "sortkeys" + ] + revision = "7d68e886eac4f7e34d0d82241a6273d6c304c5cf" + version = "v1.1.0" + +[[projects]] + branch = "master" + name = "github.com/golang/glog" + packages = ["."] + revision = "23def4e6c14b4da8ac2ed8007337bc5eb5007998" + +[[projects]] + name = "github.com/golang/protobuf" + packages = [ + "proto", + "ptypes", + "ptypes/any", + "ptypes/duration", + "ptypes/timestamp" + ] + revision = "b4deda0973fb4c70b50d226b1af49f3da59f5265" + version = "v1.1.0" + +[[projects]] + branch = "master" + name = "github.com/golang/snappy" + packages = ["."] + revision = "2e65f85255dbc3072edf28d6b5b8efc472979f5a" + +[[projects]] + branch = "master" + name = "github.com/google/gofuzz" + packages = ["."] + revision = "24818f796faf91cd76ec7bddd72458fbced7a6c1" + +[[projects]] + name = "github.com/googleapis/gnostic" + packages = [ + "OpenAPIv2", + "compiler", + "extensions" + ] + revision = "7c663266750e7d82587642f65e60bc4083f1f84e" + version = "v0.2.0" + +[[projects]] + branch = "master" + name = "github.com/hashicorp/golang-lru" + packages = [ + ".", + "simplelru" + ] + revision = "0fb14efe8c47ae851c0034ed7a448854d3d34cf3" + +[[projects]] + name = "github.com/json-iterator/go" + packages = ["."] + revision = "ab8a2e0c74be9d3be70b3184d9acc634935ded82" + version = "1.1.4" + +[[projects]] + branch = "master" + name = "github.com/kr/logfmt" + packages = ["."] + revision = "b84e30acd515aadc4b783ad4ff83aff3299bdfe0" + +[[projects]] + branch = "master" + name = "github.com/lib/pq" + packages = [ + ".", + "oid" + ] + revision = "90697d60dd844d5ef6ff15135d0203f65d2f53b8" + +[[projects]] + name = "github.com/modern-go/concurrent" + packages = ["."] + revision = "bacd9c7ef1dd9b15be4a9909b8ac7a4e313eec94" + version = "1.0.3" + +[[projects]] + name = "github.com/modern-go/reflect2" + packages = ["."] + revision = "4b7aa43c6742a2c18fdef89dd197aaae7dac7ccd" + version = "1.0.1" + +[[projects]] + name = "github.com/nats-io/gnatsd" + packages = [ + "conf", + "logger", + "server", + "server/pse", + "util" + ] + revision = "6608e9ac3be979dcb0614b772cc86a87b71acaa3" + version = "v1.2.0" + +[[projects]] + name = "github.com/nats-io/go-nats" + packages = [ + ".", + "encoders/builtin", + "util" + ] + revision = "062418ea1c2181f52dc0f954f6204370519a868b" + version = "v1.5.0" + +[[projects]] + name = "github.com/nats-io/go-nats-streaming" + packages = [ + ".", + "pb" + ] + revision = "6e620057a207bd61e992c1c5b6a2de7b6a4cb010" + version = "v0.3.4" + +[[projects]] + name = "github.com/nats-io/nats-streaming-server" + packages = [ + "logger", + "server", + "spb", + "stores", + "util" + ] + revision = "33414c6f2179201f7fda8743ba89d07184e3fa77" + version = "v0.7.2" + +[[projects]] + name = "github.com/nats-io/nuid" + packages = ["."] + revision = "289cccf02c178dc782430d534e3c1f5b72af807f" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "github.com/opentracing-contrib/go-observer" + packages = ["."] + revision = "a52f2342449246d5bcc273e65cbdcfa5f7d6c63c" + +[[projects]] + name = "github.com/opentracing/opentracing-go" + packages = [ + ".", + "ext", + "log" + ] + revision = "1949ddbfd147afd4d964a9f00b24eb291e0e7c38" + version = "v1.0.2" + +[[projects]] + name = "github.com/openzipkin/zipkin-go-opentracing" + packages = [ + ".", + "flag", + "thrift/gen-go/scribe", + "thrift/gen-go/zipkincore", + "types", + "wire" + ] + revision = "26cf9707480e6b90e5eff22cf0bbf05319154232" + version = "v0.3.4" + +[[projects]] + name = "github.com/pierrec/lz4" + packages = [ + ".", + "internal/xxh32" + ] + revision = "1958fd8fff7f115e79725b1288e0b878b3e06b00" + version = "v2.0.3" + +[[projects]] + name = "github.com/pmezard/go-difflib" + packages = ["difflib"] + revision = "792786c7400a136282c1664665ae0a8db921c6c2" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "github.com/rcrowley/go-metrics" + packages = ["."] + revision = "e2704e165165ec55d062f5919b4b29494e9fa790" + +[[projects]] + name = "github.com/satori/go.uuid" + packages = ["."] + revision = "f58768cc1a7a7e77a3bd49e98cdd21419399b6a3" + version = "v1.2.0" + +[[projects]] + name = "github.com/spf13/pflag" + packages = ["."] + revision = "583c0c0531f06d5278b7d917446061adc344b5cd" + version = "v1.0.1" + +[[projects]] + name = "github.com/stretchr/objx" + packages = ["."] + revision = "477a77ecc69700c7cdeb1fa9e129548e1c1c393c" + version = "v0.1.1" + +[[projects]] + name = "github.com/stretchr/testify" + packages = [ + "assert", + "mock" + ] + revision = "f35b8ab0b5a2cef36673838d662e249dd9c94686" + version = "v1.2.2" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = [ + "bcrypt", + "blowfish", + "ssh/terminal" + ] + revision = "a49355c7e3f8fe157a85be2f77e6e269a0f89602" + +[[projects]] + branch = "master" + name = "golang.org/x/net" + packages = [ + "context", + "http/httpguts", + "http2", + "http2/hpack", + "idna" + ] + revision = "d0887baf81f4598189d4e12a37c6da86f0bba4d0" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = [ + "unix", + "windows", + "windows/registry", + "windows/svc", + "windows/svc/debug", + "windows/svc/eventlog", + "windows/svc/mgr" + ] + revision = "ac767d655b305d4e9612f5f6e33120b9176c4ad4" + +[[projects]] + name = "golang.org/x/text" + packages = [ + "collate", + "collate/build", + "internal/colltab", + "internal/gen", + "internal/tag", + "internal/triegen", + "internal/ucd", + "language", + "secure/bidirule", + "transform", + "unicode/bidi", + "unicode/cldr", + "unicode/norm", + "unicode/rangetable" + ] + revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" + version = "v0.3.0" + +[[projects]] + branch = "master" + name = "golang.org/x/time" + packages = ["rate"] + revision = "fbb02b2291d28baffd63558aa44b4b56f178d650" + +[[projects]] + branch = "master" + name = "golang.org/x/tools" + packages = [ + "go/ast/astutil", + "imports", + "internal/fastwalk" + ] + revision = "fd2d2c45eb2dff7b87eab4303a1016b4dbf95e81" + +[[projects]] + name = "google.golang.org/appengine" + packages = ["cloudsql"] + revision = "b1f26356af11148e710935ed1ac8a7f5702c7612" + version = "v1.1.0" + +[[projects]] + name = "gopkg.in/inf.v0" + packages = ["."] + revision = "d2d2541c53f18d2a059457998ce2876cc8e67cbf" + version = "v0.9.1" + +[[projects]] + name = "gopkg.in/yaml.v2" + packages = ["."] + revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183" + version = "v2.2.1" + +[[projects]] + branch = "master" + name = "k8s.io/api" + packages = [ + "admissionregistration/v1alpha1", + "admissionregistration/v1beta1", + "apps/v1", + "apps/v1beta1", + "apps/v1beta2", + "authentication/v1", + "authentication/v1beta1", + "authorization/v1", + "authorization/v1beta1", + "autoscaling/v1", + "autoscaling/v2beta1", + "batch/v1", + "batch/v1beta1", + "batch/v2alpha1", + "certificates/v1beta1", + "core/v1", + "events/v1beta1", + "extensions/v1beta1", + "networking/v1", + "policy/v1beta1", + "rbac/v1", + "rbac/v1alpha1", + "rbac/v1beta1", + "scheduling/v1alpha1", + "settings/v1alpha1", + "storage/v1", + "storage/v1alpha1", + "storage/v1beta1" + ] + revision = "183f3326a9353bd6d41430fc80f96259331d029c" + +[[projects]] + name = "k8s.io/apimachinery" + packages = [ + "pkg/api/errors", + "pkg/api/meta", + "pkg/api/resource", + "pkg/apis/meta/internalversion", + "pkg/apis/meta/v1", + "pkg/apis/meta/v1/unstructured", + "pkg/apis/meta/v1beta1", + "pkg/conversion", + "pkg/conversion/queryparams", + "pkg/fields", + "pkg/labels", + "pkg/runtime", + "pkg/runtime/schema", + "pkg/runtime/serializer", + "pkg/runtime/serializer/json", + "pkg/runtime/serializer/protobuf", + "pkg/runtime/serializer/recognizer", + "pkg/runtime/serializer/streaming", + "pkg/runtime/serializer/versioning", + "pkg/selection", + "pkg/types", + "pkg/util/cache", + "pkg/util/clock", + "pkg/util/diff", + "pkg/util/errors", + "pkg/util/framer", + "pkg/util/intstr", + "pkg/util/json", + "pkg/util/net", + "pkg/util/runtime", + "pkg/util/sets", + "pkg/util/validation", + "pkg/util/validation/field", + "pkg/util/wait", + "pkg/util/yaml", + "pkg/version", + "pkg/watch", + "third_party/forked/golang/reflect" + ] + revision = "302974c03f7e50f16561ba237db776ab93594ef6" + version = "kubernetes-1.10.0" + +[[projects]] + name = "k8s.io/client-go" + packages = [ + "discovery", + "discovery/fake", + "kubernetes/scheme", + "pkg/apis/clientauthentication", + "pkg/apis/clientauthentication/v1alpha1", + "pkg/version", + "plugin/pkg/client/auth/exec", + "rest", + "rest/watch", + "testing", + "tools/cache", + "tools/clientcmd/api", + "tools/metrics", + "tools/pager", + "transport", + "util/buffer", + "util/cert", + "util/flowcontrol", + "util/integer", + "util/retry" + ] + revision = "989be4278f353e42f26c416c53757d16fcff77db" + version = "kubernetes-1.10.1" + +[[projects]] + name = "k8s.io/code-generator" + packages = [ + "cmd/client-gen", + "cmd/client-gen/args", + "cmd/client-gen/generators", + "cmd/client-gen/generators/fake", + "cmd/client-gen/generators/scheme", + "cmd/client-gen/generators/util", + "cmd/client-gen/path", + "cmd/client-gen/types", + "pkg/util" + ] + revision = "7ead8f38b01cf8653249f5af80ce7b2c8aba12e2" + version = "kubernetes-1.10.0" + +[[projects]] + branch = "master" + name = "k8s.io/gengo" + packages = [ + "args", + "generator", + "namer", + "parser", + "types" + ] + revision = "fdcf9f9480fdd5bf2b3c3df9bf4ecd22b25b87e2" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "2ac4799166f0a39e277f01bc3491f3792cedc17b4a559a1593b4ef59f210aff7" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/components/event-bus/Gopkg.toml b/components/event-bus/Gopkg.toml new file mode 100644 index 000000000000..62771ee52e56 --- /dev/null +++ b/components/event-bus/Gopkg.toml @@ -0,0 +1,45 @@ +required = ["k8s.io/code-generator/cmd/client-gen"] + +[[constraint]] + name = "github.com/nats-io/go-nats" + version = "1.5.0" + +[[constraint]] + name = "github.com/nats-io/go-nats-streaming" + version = "0.3.4" + +[[constraint]] + name = "github.com/nats-io/nats-streaming-server" + version = "0.7.2" + +[[constraint]] + name = "github.com/opentracing/opentracing-go" + version = "1.0.2" + +[[constraint]] + name = "github.com/openzipkin/zipkin-go-opentracing" + version = "0.3.4" + +[[constraint]] + name = "github.com/satori/go.uuid" + version = "1.2.0" + +[[constraint]] + name = "github.com/stretchr/testify" + version = "1.2.1" + +[[constraint]] + name = "k8s.io/apimachinery" + version = "kubernetes-1.10.1" + +[[constraint]] + name = "k8s.io/client-go" + version = "kubernetes-1.10.1" + +[[constraint]] + name = "k8s.io/code-generator" + version = "kubernetes-1.10.1" + +[[override]] + branch = "master" + name = "github.com/apache/thrift" diff --git a/components/event-bus/Jenkinsfile b/components/event-bus/Jenkinsfile new file mode 100644 index 000000000000..1d290d45a541 --- /dev/null +++ b/components/event-bus/Jenkinsfile @@ -0,0 +1,136 @@ +#!/usr/bin/env groovy +def label = "kyma-${UUID.randomUUID().toString()}" +def application = "event-bus" +def isMaster = params.GIT_BRANCH == 'master' + +def dockerPushRoot = isMaster + ? "${env.DOCKER_REGISTRY}" + : "${env.DOCKER_REGISTRY}snapshot/" + +def dockerImageTag = isMaster + ? params.APP_VERSION + : params.GIT_BRANCH + +def imageNamePush = 'event-bus-push' +def imageNamePublish = 'event-bus-publish' +def imageNameSubValidator = 'event-bus-sub-validator' + +echo """ +******************************** +Job started with the following parameters: +DOCKER_REGISTRY=${env.DOCKER_REGISTRY} +DOCKER_CREDENTIALS=${env.DOCKER_CREDENTIALS} +GIT_REVISION=${params.GIT_REVISION} +GIT_BRANCH=${params.GIT_BRANCH} +APP_VERSION=${params.APP_VERSION} +APP_FOLDER=${env.APP_FOLDER} +dockerPushRoot=${dockerPushRoot} +dockerImageTag=${dockerImageTag} +******************************** +""" + +podTemplate(label: label) { + node(label) { + try { + timestamps { + timeout(time:20, unit:"MINUTES") { + ansiColor('xterm') { + stage("setup") { + checkout scm + + if(dockerImageTag == "") { + error("No version for docker tag defined, please set APP_VERSION parameter for master branch or GIT_BRANCH parameter for any branch") + } + + withCredentials([usernamePassword(credentialsId: env.DOCKER_CREDENTIALS, passwordVariable: 'pwd', usernameVariable: 'uname')]) { + sh "docker login -u $uname -p '$pwd' $env.DOCKER_REGISTRY" + } + } + + stage("install dependencies $application") { + execute("/", "make resolve") + } + + stage("code quality") { + execute("/", "gometalinter --skip=generated --vendor --deadline=2m --disable-all " + + "--enable=vet " + + "./...") + } + + stage("build and test - event-bus-publish") { + execute("/cmd/event-bus-publish", "make clean build") + } + + stage("build image - event-bus-publish") { + dir(env.APP_FOLDER + '/cmd/event-bus-publish') { + sh "./dockerBuild.sh event-bus-publish latest" + } + } + + stage("build and test - event-bus-push") { + execute("/cmd/event-bus-push", "make clean build") + } + + stage("build image - event-bus-push") { + dir(env.APP_FOLDER + '/cmd/event-bus-push') { + sh "./dockerBuild.sh event-bus-push latest" + } + } + + stage("build and test - event-bus-sub-validator") { + execute("/cmd/event-bus-sv", "make clean build") + } + + stage("build image - event-bus-sub-validator") { + dir(env.APP_FOLDER + '/cmd/event-bus-sv') { + sh "./dockerBuild.sh event-bus-sub-validator latest" + } + } + + if (isMaster) { + stage("IP scan $application (Sourceclear)"){ + withCredentials([string(credentialsId: 'SRCCLR_API_TOKEN', variable: 'SRCCLR_API_TOKEN')]) { + execute("/", "make scan", "SRCCLR_API_TOKEN=$SRCCLR_API_TOKEN") + } + } + } + + stage("push image - event-bus-publish") { + def imageName = "${dockerPushRoot}${imageNamePublish}:${dockerImageTag}" + sh "docker tag event-bus-publish:latest ${imageName}" + sh "docker push ${imageName}" + echo "pushed image event-bus-publish: ${imageName}" + } + + stage("push image - event-bus-push") { + def imageName = "${dockerPushRoot}${imageNamePush}:${dockerImageTag}" + sh "docker tag event-bus-push:latest ${imageName}" + sh "docker push ${imageName}" + echo "pushed image event-bus-push: ${imageName}" + } + + stage("push image - event-bus-sub-validator") { + def imageName = "${dockerPushRoot}${imageNameSubValidator}:${dockerImageTag}" + sh "docker tag event-bus-sub-validator:latest ${imageName}" + sh "docker push ${imageName}" + echo "pushed image event-bus-sub-validator: ${imageName}" + } + } + } + } + } catch (ex) { + echo "Got exception: ${ex}" + currentBuild.result = "FAILURE" + def body = "${currentBuild.currentResult} ${env.JOB_NAME}${env.BUILD_DISPLAY_NAME}: on branch: ${params.GIT_BRANCH}. See details: ${env.BUILD_URL}" + emailext body: body, recipientProviders: [[$class: 'DevelopersRecipientProvider'], [$class: 'CulpritsRecipientProvider'], [$class: 'RequesterRecipientProvider']], subject: "${currentBuild.currentResult}: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'" + } + } +} + +def execute(workPath, command, envs = '') { + def buildpack = 'golang-buildpack:0.0.8' + def repositoryName = 'kyma' + def envText = envs=='' ? '' : "--env $envs" + workDir = pwd() + sh "docker run --rm -v $workDir:/go/src/github.com/kyma-project/$repositoryName/ -w /go/src/github.com/kyma-project/$repositoryName/$env.APP_FOLDER$workPath $envText ${env.DOCKER_REGISTRY}$buildpack /bin/bash -c '$command'" +} diff --git a/components/event-bus/README.md b/components/event-bus/README.md new file mode 100644 index 000000000000..9032b49ee8d1 --- /dev/null +++ b/components/event-bus/README.md @@ -0,0 +1,36 @@ +``` + ______ _ ____ + | ____| | | | _ \ + | |____ _____ _ __ | |_ | |_) |_ _ ___ + | __\ \ / / _ \ '_ \| __| | _ <| | | / __| + | |___\ V / __/ | | | |_ | |_) | |_| \__ \ + |______\_/ \___|_| |_|\__| |____/ \__,_|___/ +``` + +## Overview +The Event Bus enables Kyma to integrate with various other external solutions. The integration uses the `publish-subscribe` messaging pattern that allows Kyma to receive business Events from different solutions, enrich the events, and trigger business flows using lambdas or services defined in Kyma. See the [Event Bus documentation](https://github.com/kyma-project/kyma/tree/master/docs/event-bus/docs). + + +## Docker Images +Currently, Event Bus makes the following three Docker images available to the `kyma core` Helm chart: + +- event-bus-publish +- event-bus-push +- event-bus-sub-validator + +There are also end-to-end test Docker images to use as `helm tests`. See [the tests in the `event-bus` directory](https://github.com/kyma-project/kyma/tree/master/tests/event-bus) for more details. + +## Development + +The three binaries of `Event Bus` reside under `cmd/event-bus-XXXX` "e.g. `cmd/event-bus-publish`". They each have a Makefile to build and test the component as well as to create and push a Docker image. The following table explains the various make targets. + + +|Command| Description| +|-----------|------------| +|`make`|This is the default target for building the Docker image. It tests, compiles, creates, and appropriately tags a Docker image.| +|`make build`|Runs all the tests and the linter. It compiles the binary in the `bin` directory.| +|`make push`|Pushes the Docker image to the registry specified in the `REGISTRY` variable of the Makefile.| +|`make docker-build`|Creates a Docker image.| +|`make test`|Run all the tests.| +|`make vet`|Runs `go vet` on all sources including `vendor` but excluding the `generated` directory.| +|`make compile`|Builds a binary without running any tests.| diff --git a/components/event-bus/api/publish/api.go b/components/event-bus/api/publish/api.go new file mode 100644 index 000000000000..78d5d8d503d0 --- /dev/null +++ b/components/event-bus/api/publish/api.go @@ -0,0 +1,60 @@ +package publish + +const ( //TODO Check how to access struct tags + FieldData = "data" + FieldEventId = "event-id" + FieldEventTime = "event-time" + FieldEventType = "event-type" + FieldEventTypeVersion = "event-type-version" + FieldSource = "source" + FieldSourceType = "source.source-type" + FieldSourceNamespace = "source.source-namespace" + FieldSourceEnvironment = "source.source-environment" + FieldTraceContext = "trace-context" + + //AllowedIDChars + AllowedIdChars = `^[a-zA-Z0-9_\-]+$` + AllowedEventIDChars = `^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$` + + // fully-qualified topic name components + AllowedSourceEnvironmentChars = `^[a-zA-Z]+([_\-\.]?[a-zA-Z0-9]+)*$` + AllowedSourceNamespaceChars = `^[a-zA-Z]+([_\-\.]?[a-zA-Z0-9]+)*$` + AllowedSourceTypeChars = `^[a-zA-Z]+([_\-\.]?[a-zA-Z0-9]+)*$` + AllowedEventTypeChars = `^[a-zA-Z]+([_\-\.]?[a-zA-Z0-9]+)*$` + AllowedEventTypeVersionChars = `^[a-zA-Z0-9]+$` +) + +// PublishRequest represents a publish request +type PublishRequest struct { + Source *EventSource `json:"source"` + EventType string `json:"event-type"` + EventTypeVersion string `json:"event-type-version"` + EventID string `json:"event-id"` + EventTime string `json:"event-time"` + Data AnyValue `json:"data"` +} + +// AnyValue implements the service definition of AnyValue +type AnyValue interface{} + +// EventSource describes the software instance that emits the event at runtime (i.e. the producer). +type EventSource struct { + SourceNamespace string `json:"source-namespace"` + SourceType string `json:"source-type"` + SourceEnvironment string `json:"source-environment"` +} + +// PublishResponse represents a successful publish response +type PublishResponse struct { + EventID string `json:"event-id"` +} + +// CloudEvent represents the event to be persisted to NATS +type CloudEvent struct { + PublishRequest + Extensions Extensions `json:"extensions,omitempty"` +} + +type Extensions = map[string]interface{} + +type TraceContext map[string]string diff --git a/components/event-bus/api/publish/error.go b/components/event-bus/api/publish/error.go new file mode 100644 index 000000000000..c4c7137ca4ab --- /dev/null +++ b/components/event-bus/api/publish/error.go @@ -0,0 +1,196 @@ +package publish + +import "net/http" + +const ( + /*ErrorTypeBadPayload The request payload has incorrect syntax according to the sent Content-Type. + Check the payload content for syntax errors, such as missing commas or quotation marks that are not closed. + */ + ErrorTypeBadPayload = "bad_payload_syntax" + /*ErrorTypeValidationViolation Top level validation error. + */ + ErrorTypeValidationViolation = "validation_violation" + /*ErrorTypeMissingField Sub-level error type of `ErrorTypeValidationViolation` representaing that the requested body + payload for a POST or PUT operation is missing, which violates the defined validation constraints. This denotes + a missing field when a value is expected. + */ + ErrorTypeMissingField = "missing_field" + /*ErrorTypeInvalidField Sub-level error type of `ErrorTypeValidationViolation` representaing that the requested body + payload for the POST or PUT operation violates the validation constraints. + This denotes specifically that there is: + - A type incompatibility, such as a field modeled to be an integer, but a non-numeric expression was found instead. + - A range under or over flow validation violation cause. + */ + ErrorTypeInvalidField = "invalid_field" + // ErrorTypeInternalServerError Some unexpected internal error occurred while processing the request. + ErrorTypeInternalServerError = "internal_server_error" + // ErrorMessageInternalServerError represents the error message for `ErrorTypeInternalServerError` + ErrorMessageInternalServerError = "Some unexpected internal error occurred, please contact support." + /*ErrorTypeBadRequest A generic error for bad requests sent by the clients. Use when none of the specific + error types apply. + */ + ErrorTypeBadRequest = "bad_request" + // ErrorMessageBadRequest represents the error message for `ErrorTypeBadRequest` + ErrorMessageBadRequest = "Some unexpected internal error occurred, please contact support." + // ErrorMessageBadPayload represents the error message for `ErrorTypeBadPayload` + ErrorMessageBadPayload = "Something went very wrong. Please try again." + // ErrorMessageMissingField represents the error message for `ErrorTypeMissingField` + ErrorMessageMissingField = "We need all required fields complete to keep you moving." + // ErrorMessageInvalidField represents the error message for `ErrorTypeInvalidField` + ErrorMessageInvalidField = "We need all your entries to be correct to keep you moving." +) + +// ErrorDetail represents error cause +type ErrorDetail struct { + Field string `json:"field"` + Type string `json:"type"` + Message string `json:"message"` + MoreInfo string `json:"moreInfo"` +} + +// Error represents API error response code +type Error struct { + Status int `json:"status"` + Type string `json:"type"` + Message string `json:"message"` + MoreInfo string `json:"moreInfo"` + Details []ErrorDetail `json:"details"` +} + +// TODO Add propper comments +func ErrorResponseInternalServer() (response *Error) { + apiError := Error{ + Status: http.StatusInternalServerError, + Type: ErrorTypeInternalServerError, + Message: ErrorMessageInternalServerError, + MoreInfo: "", + } + return &apiError +} + +func ErrorResponseBadRequest() (response *Error) { + apiError := Error{ + Status: http.StatusBadRequest, + Type: ErrorTypeBadRequest, + Message: ErrorMessageBadRequest, + MoreInfo: "", + } + return &apiError +} + +func ErrorResponseBadPayload() (response *Error) { + apiError := Error{ + Status: http.StatusBadRequest, + Type: ErrorTypeBadPayload, + Message: ErrorMessageBadRequest, + MoreInfo: "", + } + return &apiError +} + +func ErrorResponseEmptyRequest() (response *Error) { + apiErrorDetail := ErrorDetail{ + Field: "", + Type: ErrorTypeInvalidField, + Message: ErrorMessageInvalidField, + MoreInfo: "", + } + details := []ErrorDetail{apiErrorDetail} + apiError := Error{ + Status: http.StatusBadRequest, + Type: ErrorTypeBadPayload, + Message: ErrorMessageBadPayload, + MoreInfo: "", + Details: details, + } + return &apiError +} +func ErrorResponseMissingFieldSource() (response *Error) { + return createMissingFieldError(FieldSource) +} + +func ErrorResponseMissingFieldSourceType() (response *Error) { + return createMissingFieldError(FieldSourceType) +} + +func ErrorResponseMissingFieldSourceNamespace() (response *Error) { + return createMissingFieldError(FieldSourceNamespace) +} + +func ErrorResponseMissingFieldSourceEnvironment() (response *Error) { + return createMissingFieldError(FieldSourceEnvironment) +} + +func ErrorResponseMissingFieldData() (response *Error) { + return createMissingFieldError(FieldData) +} + +func ErrorResponseMissingFieldEventType() (response *Error) { + return createMissingFieldError(FieldEventType) +} + +func ErrorResponseMissingFieldEventTypeVersion() (response *Error) { + return createMissingFieldError(FieldEventTypeVersion) +} + +func ErrorResponseMissingFieldEventTime() (response *Error) { + return createMissingFieldError(FieldEventTime) +} + +func ErrorResponseWrongSourceEnvironment() (response *Error) { + return createInvalidFieldError(FieldSourceEnvironment) +} + +func ErrorResponseWrongSourceNamespace() (response *Error) { + return createInvalidFieldError(FieldSourceNamespace) +} + +func ErrorResponseWrongSourceType() (response *Error) { + return createInvalidFieldError(FieldSourceType) +} + +func ErrorResponseWrongEventType() (response *Error) { + return createInvalidFieldError(FieldEventType) +} + +func ErrorResponseWrongEventTypeVersion() (response *Error) { + return createInvalidFieldError(FieldEventTypeVersion) +} + +func ErrorResponseWrongEventTime(err error) (response *Error) { + return createInvalidFieldError(FieldEventTime) +} + +func ErrorResponseWrongEventId() (response *Error) { + return createInvalidFieldError(FieldEventId) +} + +func createMissingFieldError(field interface{}) (response *Error) { + apiErrorDetail := ErrorDetail{ + Field: field.(string), + Type: ErrorTypeMissingField, + Message: ErrorMessageMissingField, + MoreInfo: "", + } + details := []ErrorDetail{apiErrorDetail} + apiError := Error{Status: http.StatusBadRequest, Type: ErrorTypeValidationViolation, Message: ErrorMessageMissingField, MoreInfo: "", Details: details} + return &apiError +} + +func createInvalidFieldError(field interface{}) (response *Error) { + apiErrorDetail := ErrorDetail{ + Field: field.(string), + Type: ErrorTypeInvalidField, + Message: ErrorMessageInvalidField, + MoreInfo: "", + } + details := []ErrorDetail{apiErrorDetail} + apiError := Error{ + Status: http.StatusBadRequest, + Type: ErrorTypeValidationViolation, + Message: ErrorMessageInvalidField, + MoreInfo: "", + Details: details, + } + return &apiError +} diff --git a/components/event-bus/api/publish/validators.go b/components/event-bus/api/publish/validators.go new file mode 100644 index 000000000000..1680bec34761 --- /dev/null +++ b/components/event-bus/api/publish/validators.go @@ -0,0 +1,73 @@ +package publish + +import ( + "regexp" + "time" +) + +var ( + isValidID1 = regexp.MustCompile(AllowedIdChars).MatchString + isValidEventID = regexp.MustCompile(AllowedEventIDChars).MatchString + + // fully-qualified topic name components + isValidSourceEnvironment = regexp.MustCompile(AllowedSourceEnvironmentChars).MatchString + isValidSourceNamespace = regexp.MustCompile(AllowedSourceNamespaceChars).MatchString + isValidSourceType = regexp.MustCompile(AllowedSourceTypeChars).MatchString + isValidEventType = regexp.MustCompile(AllowedEventTypeChars).MatchString + isValidEventTypeVersion = regexp.MustCompile(AllowedEventTypeVersionChars).MatchString +) + +//ValidatePublish validates a publish POST request +func ValidatePublish(r *PublishRequest) *Error { + if r.Source == nil { + return ErrorResponseMissingFieldSource() + } + if r.Source.SourceType == "" { + return ErrorResponseMissingFieldSourceType() + } + if r.Source.SourceNamespace == "" { + return ErrorResponseMissingFieldSourceNamespace() + } + if r.Source.SourceEnvironment == "" { + return ErrorResponseMissingFieldSourceEnvironment() + } + if len(r.EventType) == 0 { + return ErrorResponseMissingFieldEventType() + } + if len(r.EventTypeVersion) == 0 { + return ErrorResponseMissingFieldEventTypeVersion() + } + if len(r.EventTime) == 0 { + return ErrorResponseMissingFieldEventTime() + } + if r.Data == nil { + return ErrorResponseMissingFieldData() + } else if d, ok := (r.Data).(string); ok && d == "" { + return ErrorResponseMissingFieldData() + } + + // validate the fully-qualified topic name components + if !isValidSourceEnvironment(r.Source.SourceEnvironment) { + return ErrorResponseWrongSourceEnvironment() + } + if !isValidSourceNamespace(r.Source.SourceNamespace) { + return ErrorResponseWrongSourceNamespace() + } + if !isValidSourceType(r.Source.SourceType) { + return ErrorResponseWrongSourceType() + } + if !isValidEventType(r.EventType) { + return ErrorResponseWrongEventType() + } + if !isValidEventTypeVersion(r.EventTypeVersion) { + return ErrorResponseWrongEventTypeVersion() + } + + if _, err := time.Parse(time.RFC3339, r.EventTime); err != nil { + return ErrorResponseWrongEventTime(err) + } + if len(r.EventID) > 0 && !isValidEventID(r.EventID) { + return ErrorResponseWrongEventId() + } + return nil +} diff --git a/components/event-bus/api/publish/validators_test.go b/components/event-bus/api/publish/validators_test.go new file mode 100644 index 000000000000..9f7d50162394 --- /dev/null +++ b/components/event-bus/api/publish/validators_test.go @@ -0,0 +1,233 @@ +package publish + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_ValidatePublish_MissingSource(t *testing.T) { + publishRequest := buildTestPublishRequest() + publishRequest.Source = nil + err := ValidatePublish(&publishRequest) + assert.NotEqual(t, len(err.Details), 0) + assert.Equal(t, http.StatusBadRequest, err.Status) + assert.Equal(t, FieldSource, err.Details[0].Field) +} + +func Test_ValidatePublish_MissingSourceType(t *testing.T) { + publishRequest := buildTestPublishRequest() + publishRequest.Source.SourceType = "" + err := ValidatePublish(&publishRequest) + assert.NotEqual(t, len(err.Details), 0) + assert.Equal(t, http.StatusBadRequest, err.Status) + assert.Equal(t, FieldSourceType, err.Details[0].Field) +} + +func Test_ValidatePublish_MissingSourceNamespace(t *testing.T) { + publishRequest := buildTestPublishRequest() + publishRequest.Source.SourceNamespace = "" + err := ValidatePublish(&publishRequest) + assert.NotEqual(t, len(err.Details), 0) + assert.Equal(t, http.StatusBadRequest, err.Status) + assert.Equal(t, FieldSourceNamespace, err.Details[0].Field) +} + +func Test_ValidatePublish_MissingSourceEnvironment(t *testing.T) { + publishRequest := buildTestPublishRequest() + publishRequest.Source.SourceEnvironment = "" + err := ValidatePublish(&publishRequest) + assert.NotEqual(t, len(err.Details), 0) + assert.Equal(t, http.StatusBadRequest, err.Status) + assert.Equal(t, FieldSourceEnvironment, err.Details[0].Field) +} + +func Test_ValidatePublish_MissingEventType(t *testing.T) { + publishRequest := buildTestPublishRequest() + publishRequest.EventType = "" + err := ValidatePublish(&publishRequest) + assert.NotEqual(t, len(err.Details), 0) + assert.Equal(t, http.StatusBadRequest, err.Status) + assert.Equal(t, FieldEventType, err.Details[0].Field) +} + +func Test_ValidatePublish_MissingEventTypeVersion(t *testing.T) { + publishRequest := buildTestPublishRequest() + publishRequest.EventTypeVersion = "" + err := ValidatePublish(&publishRequest) + assert.NotEqual(t, len(err.Details), 0) + assert.Equal(t, http.StatusBadRequest, err.Status) + assert.Equal(t, FieldEventTypeVersion, err.Details[0].Field) +} + +func Test_ValidatePublish_MissingEventTime(t *testing.T) { + publishRequest := buildTestPublishRequest() + publishRequest.EventTime = "" + err := ValidatePublish(&publishRequest) + assert.NotEqual(t, len(err.Details), 0) + assert.Equal(t, http.StatusBadRequest, err.Status) + assert.Equal(t, FieldEventTime, err.Details[0].Field) +} + +func Test_ValidatePublish_MissingData(t *testing.T) { + publishRequest := buildTestPublishRequest() + publishRequest.Data = nil + err := ValidatePublish(&publishRequest) + assert.NotEqual(t, len(err.Details), 0) + assert.Equal(t, http.StatusBadRequest, err.Status) + assert.Equal(t, FieldData, err.Details[0].Field) +} + +func Test_ValidatePublish_EmptyData(t *testing.T) { + publishRequest := buildTestPublishRequest() + publishRequest.Data = "" + err := ValidatePublish(&publishRequest) + assert.NotEqual(t, len(err.Details), 0) + assert.Equal(t, http.StatusBadRequest, err.Status) + assert.Equal(t, FieldData, err.Details[0].Field) +} + +func Test_ValidatePublish_InvalidSourceEnvironment(t *testing.T) { + publishRequest := buildTestPublishRequest() + publishRequest.Source.SourceEnvironment = ".invalid." + err := ValidatePublish(&publishRequest) + assert.NotEqual(t, len(err.Details), 0) + assert.Equal(t, http.StatusBadRequest, err.Status) + assert.Equal(t, FieldSourceEnvironment, err.Details[0].Field) +} + +func Test_ValidatePublish_InvalidSourceNamespace(t *testing.T) { + publishRequest := buildTestPublishRequest() + publishRequest.Source.SourceNamespace = ".invalid." + err := ValidatePublish(&publishRequest) + assert.NotEqual(t, len(err.Details), 0) + assert.Equal(t, http.StatusBadRequest, err.Status) + assert.Equal(t, FieldSourceNamespace, err.Details[0].Field) +} + +func Test_ValidatePublish_InvalidSourceType(t *testing.T) { + publishRequest := buildTestPublishRequest() + publishRequest.Source.SourceType = ".invalid." + err := ValidatePublish(&publishRequest) + assert.NotEqual(t, len(err.Details), 0) + assert.Equal(t, http.StatusBadRequest, err.Status) + assert.Equal(t, FieldSourceType, err.Details[0].Field) +} + +func Test_ValidatePublish_InvalidEventType(t *testing.T) { + publishRequest := buildTestPublishRequest() + publishRequest.EventType = "invalid/event-type" + err := ValidatePublish(&publishRequest) + assert.NotEqual(t, len(err.Details), 0) + assert.Equal(t, http.StatusBadRequest, err.Status) + assert.Equal(t, FieldEventType, err.Details[0].Field) +} + +func Test_ValidatePublish_InvalidEventTypeVersion(t *testing.T) { + publishRequest := buildTestPublishRequest() + publishRequest.EventTypeVersion = "$" + err := ValidatePublish(&publishRequest) + assert.NotEqual(t, len(err.Details), 0) + assert.Equal(t, http.StatusBadRequest, err.Status) + assert.Equal(t, FieldEventTypeVersion, err.Details[0].Field) +} + +func Test_ValidatePublish_InvalidEventTime(t *testing.T) { + publishRequest := buildTestPublishRequest() + publishRequest.EventTime = "invalid-time" + err := ValidatePublish(&publishRequest) + assert.NotEqual(t, len(err.Details), 0) + assert.Equal(t, http.StatusBadRequest, err.Status) + assert.Equal(t, FieldEventTime, err.Details[0].Field) +} + +func Test_ValidatePublish_InvalidEventID(t *testing.T) { + publishRequest := buildTestPublishRequest() + publishRequest.EventID = "invalid-id" + err := ValidatePublish(&publishRequest) + assert.NotEqual(t, len(err.Details), 0) + assert.Equal(t, http.StatusBadRequest, err.Status) + assert.Equal(t, FieldEventId, err.Details[0].Field) +} + +func Test_ValidatePublish_Success(t *testing.T) { + publishRequest := buildTestPublishRequest() + err := ValidatePublish(&publishRequest) + assert.Nil(t, err) +} + +func TestSourceEnvironmentRegex(t *testing.T) { + testRegex(t, isValidSourceEnvironment, "stage", true) // alphabet + testRegex(t, isValidSourceEnvironment, "prod123", true) // alphanumeric + testRegex(t, isValidSourceEnvironment, "my.s3.bucket", true) // . allowed + testRegex(t, isValidSourceEnvironment, "my-s3-bucket", true) // - allowed + testRegex(t, isValidSourceEnvironment, "my_s3_bucket", true) // _ allowed + testRegex(t, isValidSourceEnvironment, "1stage", false) // cannot start with number + testRegex(t, isValidSourceEnvironment, ".stage", false) // cannot start with symbol + testRegex(t, isValidSourceEnvironment, "stage.", false) // cannot end with symbol +} + +func TestSourceNamespaceRegex(t *testing.T) { + testRegex(t, isValidSourceNamespace, "kafka", true) // alphabet + testRegex(t, isValidSourceNamespace, "kafka10", true) // alphanumeric + testRegex(t, isValidSourceNamespace, "kafka.apache.org", true) // . allowed + testRegex(t, isValidSourceNamespace, "kafka-apache-org", true) // - allowed + testRegex(t, isValidSourceNamespace, "kafka_apache_org", true) // _ allowed + testRegex(t, isValidSourceNamespace, "1kafka.apache.org", false) // cannot start with number + testRegex(t, isValidSourceNamespace, ".kafka.apache.org", false) // cannot start with symbol + testRegex(t, isValidSourceNamespace, "kafka.apache.org.", false) // cannot end with symbol +} + +func TestSourceTypeRegex(t *testing.T) { + testRegex(t, isValidSourceType, "commerce", true) // alphabet + testRegex(t, isValidSourceType, "s3", true) // alphanumeric + testRegex(t, isValidSourceType, "marketing.beta", true) // . allowed + testRegex(t, isValidSourceType, "marketing-beta", true) // - allowed + testRegex(t, isValidSourceType, "marketing_beta", true) // _ allowed + testRegex(t, isValidSourceType, "1marketing", false) // cannot start with number + testRegex(t, isValidSourceType, ".marketing", false) // cannot start with symbol + testRegex(t, isValidSourceType, "marketing.", false) // cannot end with symbol +} + +func TestEventTypeRegex(t *testing.T) { + testRegex(t, isValidEventType, "created", true) // alphabet + testRegex(t, isValidEventType, "created1", true) // alphanumeric + testRegex(t, isValidEventType, "order.created", true) // . allowed + testRegex(t, isValidEventType, "order-created", true) // - allowed + testRegex(t, isValidEventType, "order_created", true) // _ allowed + testRegex(t, isValidEventType, "1order.created", false) // cannot start with number + testRegex(t, isValidEventType, ".order.created", false) // cannot start with symbol + testRegex(t, isValidEventType, "order.created.", false) // cannot end with symbol +} + +func TestEventTypeVersionRegex(t *testing.T) { + testRegex(t, isValidEventTypeVersion, "beta", true) // alphabet + testRegex(t, isValidEventTypeVersion, "v1", true) // alphanumeric + testRegex(t, isValidEventTypeVersion, "v.1", false) // . not allowed + testRegex(t, isValidEventTypeVersion, "v-1", false) // - not allowed + testRegex(t, isValidEventTypeVersion, "v_1", false) // _ not allowed + testRegex(t, isValidEventTypeVersion, "1v", true) // can start with number + testRegex(t, isValidEventTypeVersion, ".v1", false) // cannot start with symbol + testRegex(t, isValidEventTypeVersion, "v1.", false) // cannot end with symbol +} + +func testRegex(t *testing.T, match func(s string) bool, target string, expected bool) { + assert.Equal(t, expected, match(target)) +} + +func buildTestPublishRequest() PublishRequest { + publishRequest := PublishRequest{ + Data: "{'key':'value'}", + EventID: "4ea567cf-812b-49d9-a4b2-cb5ddf464094", + EventTime: "2012-11-01T22:08:41+00:00", + EventType: "test-event-type", + EventTypeVersion: "v1", + Source: &EventSource{ + SourceEnvironment: "test-source-environment", + SourceNamespace: "test-source-namespace", + SourceType: "test-source-type", + }, + } + return publishRequest +} diff --git a/components/event-bus/api/push/eventing.kyma.cx/register.go b/components/event-bus/api/push/eventing.kyma.cx/register.go new file mode 100644 index 000000000000..0f9ba960f6db --- /dev/null +++ b/components/event-bus/api/push/eventing.kyma.cx/register.go @@ -0,0 +1,3 @@ +package eventingkymaio + +const GroupName = "eventing.kyma.cx" diff --git a/components/event-bus/api/push/eventing.kyma.cx/v1alpha1/doc.go b/components/event-bus/api/push/eventing.kyma.cx/v1alpha1/doc.go new file mode 100644 index 000000000000..baa2064d8c2f --- /dev/null +++ b/components/event-bus/api/push/eventing.kyma.cx/v1alpha1/doc.go @@ -0,0 +1,5 @@ +// +k8s:deepcopy-gen=package,register + +// Package v1alpha1 is v1alpha1 version of the API. +// +groupName=eventing.kyma.cx +package v1alpha1 diff --git a/components/event-bus/api/push/eventing.kyma.cx/v1alpha1/helpers.go b/components/event-bus/api/push/eventing.kyma.cx/v1alpha1/helpers.go new file mode 100644 index 000000000000..55f332bea3af --- /dev/null +++ b/components/event-bus/api/push/eventing.kyma.cx/v1alpha1/helpers.go @@ -0,0 +1,15 @@ +package v1alpha1 + +func (in *Subscription) HasCondition(checked SubscriptionCondition) bool { + if len(in.Status.Conditions) == 0 { + return false + } + + for _, cond := range in.Status.Conditions { + if checked.Type == cond.Type && checked.Status == cond.Status { + return true + } + } + + return false +} diff --git a/components/event-bus/api/push/eventing.kyma.cx/v1alpha1/register.go b/components/event-bus/api/push/eventing.kyma.cx/v1alpha1/register.go new file mode 100644 index 000000000000..234b139165e2 --- /dev/null +++ b/components/event-bus/api/push/eventing.kyma.cx/v1alpha1/register.go @@ -0,0 +1,39 @@ +package v1alpha1 + +import ( + "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// SchemeGroupVersion is group version used to register these objects +var SchemeGroupVersion = schema.GroupVersion{Group: eventingkymaio.GroupName, Version: "v1alpha1"} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} + +var ( + // localSchemeBuilder and AddToScheme will stay in k8s.io/kubernetes. + SchemeBuilder runtime.SchemeBuilder + localSchemeBuilder = &SchemeBuilder + AddToScheme = localSchemeBuilder.AddToScheme +) + +func init() { + // We only register manually written functions here. The registration of the + // generated functions takes place in the generated files. The separation + // makes the code compile even when the generated files are missing. + localSchemeBuilder.Register(addKnownTypes) +} + +// Adds the list of known types to apis.Scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &Subscription{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/components/event-bus/api/push/eventing.kyma.cx/v1alpha1/types.go b/components/event-bus/api/push/eventing.kyma.cx/v1alpha1/types.go new file mode 100644 index 000000000000..1474ebef0d67 --- /dev/null +++ b/components/event-bus/api/push/eventing.kyma.cx/v1alpha1/types.go @@ -0,0 +1,68 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +//Subscription describes a subscription +type Subscription struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + SubscriptionSpec `json:"spec"` + Status SubscriptionStatus `json:"status,omitempty"` +} + +// SubscriptionSpec for Event Bus Push +type SubscriptionSpec struct { + Endpoint string `json:"endpoint"` + IncludeSubscriptionNameHeader bool `json:"include_subscription_name_header"` + IncludeTopicHeader bool `json:"include_topic_header"` + MaxInflight int `json:"max_inflight"` + PushRequestTimeoutMS int64 `json:"push_request_timeout_ms"` + EventType string `json:"event_type"` + EventTypeVersion string `json:"event_type_version"` + Source Source `json:"source"` +} + +type Source struct { + SourceNamespace string `json:"source_namespace"` + SourceType string `json:"source_type"` + SourceEnvironment string `json:"source_environment"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +//SubscriptionList +type SubscriptionList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + Items []Subscription `json:"items"` +} + +type SubscriptionStatus struct { + Conditions []SubscriptionCondition `json:"conditions"` +} + +type SubscriptionCondition struct { + Type SubscriptionConditionType `json:"type"` + Status ConditionStatus `json:"status"` + LastTransitionTime metav1.Time `json:"last_transition_time"` + Reason string `json:"reason"` + Message string `json:"message"` +} +type SubscriptionConditionType string + +const ( + EventsActivated SubscriptionConditionType = "events-activated" +) + +type ConditionStatus string + +const ( + ConditionTrue ConditionStatus = "True" + ConditionFalse ConditionStatus = "False" +) diff --git a/components/event-bus/api/push/eventing.kyma.cx/v1alpha1/zz_generated.deepcopy.go b/components/event-bus/api/push/eventing.kyma.cx/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 000000000000..3f7267bb4980 --- /dev/null +++ b/components/event-bus/api/push/eventing.kyma.cx/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,143 @@ +// +build !ignore_autogenerated + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + 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 *Source) DeepCopyInto(out *Source) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Source. +func (in *Source) DeepCopy() *Source { + if in == nil { + return nil + } + out := new(Source) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Subscription) DeepCopyInto(out *Subscription) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.SubscriptionSpec = in.SubscriptionSpec + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Subscription. +func (in *Subscription) DeepCopy() *Subscription { + if in == nil { + return nil + } + out := new(Subscription) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Subscription) 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 *SubscriptionCondition) DeepCopyInto(out *SubscriptionCondition) { + *out = *in + in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SubscriptionCondition. +func (in *SubscriptionCondition) DeepCopy() *SubscriptionCondition { + if in == nil { + return nil + } + out := new(SubscriptionCondition) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SubscriptionList) DeepCopyInto(out *SubscriptionList) { + *out = *in + out.TypeMeta = in.TypeMeta + out.ListMeta = in.ListMeta + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Subscription, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SubscriptionList. +func (in *SubscriptionList) DeepCopy() *SubscriptionList { + if in == nil { + return nil + } + out := new(SubscriptionList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *SubscriptionList) 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 *SubscriptionSpec) DeepCopyInto(out *SubscriptionSpec) { + *out = *in + out.Source = in.Source + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SubscriptionSpec. +func (in *SubscriptionSpec) DeepCopy() *SubscriptionSpec { + if in == nil { + return nil + } + out := new(SubscriptionSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SubscriptionStatus) DeepCopyInto(out *SubscriptionStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]SubscriptionCondition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SubscriptionStatus. +func (in *SubscriptionStatus) DeepCopy() *SubscriptionStatus { + if in == nil { + return nil + } + out := new(SubscriptionStatus) + in.DeepCopyInto(out) + return out +} diff --git a/components/event-bus/cmd/event-bus-publish/Dockerfile b/components/event-bus/cmd/event-bus-publish/Dockerfile new file mode 100644 index 000000000000..7a4268c5138e --- /dev/null +++ b/components/event-bus/cmd/event-bus-publish/Dockerfile @@ -0,0 +1,28 @@ +FROM golang:1.10 as builder + +WORKDIR /go/src/github.com/kyma-project/kyma/components/event-bus/ +COPY internal ./internal +COPY vendor ./vendor +COPY api ./api + +WORKDIR /go/src/github.com/kyma-project/kyma/components/event-bus/cmd/event-bus-publish/ +COPY main.go . +COPY controllers ./controllers +COPY handlers ./handlers +COPY application ./application +RUN CGO_ENABLED=0 GOOS=linux go build -v -a -installsuffix cgo -o event-bus-publish . + +FROM alpine:3.7 +LABEL source=git@github.com:kyma-project/kyma.git + +ARG version +ENV APP_VERSION $version + +WORKDIR /root/ +RUN apk --no-cache upgrade && apk --no-cache add curl + +COPY --from=builder /go/src/github.com/kyma-project/kyma/components/event-bus/cmd/event-bus-publish/event-bus-publish . + +EXPOSE 8080 + +ENTRYPOINT ["/root/event-bus-publish"] diff --git a/components/event-bus/cmd/event-bus-publish/Makefile b/components/event-bus/cmd/event-bus-publish/Makefile new file mode 100644 index 000000000000..bb9e48a3bd61 --- /dev/null +++ b/components/event-bus/cmd/event-bus-publish/Makefile @@ -0,0 +1,31 @@ +NAME = kyma-project/event-bus-publish +VERSION = 0.1.0 +REGISTRY = eu.gcr.io + +.PHONY: all clean build tag push + +all: clean build docker-build tag + +clean: + rm -rf bin/ + rm -rf docker/ + +build: vet test compile + +compile: + go build -o bin/event-bus-publish + +test: + go test github.com/kyma-project/kyma/components/event-bus/cmd/event-bus-publish -v + +docker-build: + ./dockerBuild.sh $(NAME) $(VERSION) + +tag: + docker tag $(NAME):$(VERSION) $(REGISTRY)/$(NAME):$(VERSION) + +push: + docker push $(REGISTRY)/$(NAME):$(VERSION) + +vet: + go list ../../... | grep -v generated | xargs go vet diff --git a/components/event-bus/cmd/event-bus-publish/application/application.go b/components/event-bus/cmd/event-bus-publish/application/application.go new file mode 100644 index 000000000000..9344c2d997f9 --- /dev/null +++ b/components/event-bus/cmd/event-bus-publish/application/application.go @@ -0,0 +1,52 @@ +package application + +import ( + "log" + "net/http" + + "github.com/kyma-project/kyma/components/event-bus/cmd/event-bus-publish/controllers" + "github.com/kyma-project/kyma/components/event-bus/cmd/event-bus-publish/handlers" + "github.com/kyma-project/kyma/components/event-bus/internal/publish" + "github.com/kyma-project/kyma/components/event-bus/internal/trace" +) + +type PublishApplication struct { + publisher controllers.Publisher + tracer trace.Tracer + ServerMux *http.ServeMux +} + +func NewPublishApplication(publishOpts *publish.Options) *PublishApplication { + log.Println("Publish :: Initializing NATS Streaming publisher") + publisher := controllers.GetPublisher(publishOpts.ClientID, publishOpts.NatsURL, publishOpts.NatsStreamingClusterID) + err := publisher.Start() + if err != nil { + log.Fatalf("Error while initializing NATS Streaming publisher. %v", err) + } + + log.Println("Publish :: Initializing tracer") + traceOpts := trace.Options{ + APIURL: publishOpts.TraceAPIURL, + HostPort: publishOpts.TraceHostPort, + ServiceName: publishOpts.ServiceName, + OperationName: publishOpts.OperationName, + Debug: publishOpts.TraceDebug, + } + + tracer := trace.StartNewTracer(&traceOpts) + + serveMux := http.NewServeMux() + serveMux.HandleFunc("/v1/events", handlers.GetPublishHandler(&publisher, &tracer)) + serveMux.HandleFunc("/v1/status/ready", handlers.GetReadinessHandler(&publisher)) + + return &PublishApplication{ + publisher: publisher, + tracer: tracer, + ServerMux: serveMux, + } +} + +func (app *PublishApplication) Stop() { + app.publisher.Stop() + app.tracer.Stop() +} diff --git a/components/event-bus/cmd/event-bus-publish/controllers/nats.go b/components/event-bus/cmd/event-bus-publish/controllers/nats.go new file mode 100644 index 000000000000..0d71b2c514f3 --- /dev/null +++ b/components/event-bus/cmd/event-bus-publish/controllers/nats.go @@ -0,0 +1,71 @@ +package controllers + +import ( + "log" + "time" + + ns "github.com/kyma-project/kyma/components/event-bus/internal/stanutil" + stan "github.com/nats-io/go-nats-streaming" +) + +const ( + numPollers = 2 // number of Poller goroutines to launch + statusInterval = 10 * time.Second // how often to log status to stdout +) + +//TODO Add reconnect logic + +// Publisher sends a message to a specific topic +type Publisher interface { + Start() error + Stop() error + Publish(subject, message string) error + IsReady() (bool, error) +} + +// streamingController is a NATS Streaming Controller that handles all communication aspects with NATS. +// It is also responsible of the NATS connection management. +type streamingController struct { + clientID string + natsURL string + clusterID string + natsConn *stan.Conn +} + +// Start Start the NATS Streaming controller +func (sc *streamingController) Start() error { + var err error + if sc.natsConn, err = ns.Connect(sc.clusterID, sc.clientID, sc.natsURL); err != nil { + log.Printf(" Create new connection failed: %+v", err) + return err + } + return nil +} + +// Stop Stop the Streaming Controller and close the NATS connection +func (sc *streamingController) Stop() error { + return ns.Close(sc.natsConn) +} + +// Publish sends a message to NATS +func (sc *streamingController) Publish(subject, message string) error { + messageArray := []byte(message) + if err := ns.Publish(sc.natsConn, subject, &messageArray); err != nil { + log.Printf(" Failed to publish message to NATS Streaming: %+v", err) + return err + } + return nil +} + +func (sc *streamingController) IsReady() (bool, error) { + return ns.IsConnected(sc.natsConn), nil +} + +//GetPublisher Factory function to created and return a new Publisher instance +func GetPublisher(clientID, natsURL, clusterID string) Publisher { + publisher := streamingController{} + publisher.clientID = clientID + publisher.natsURL = natsURL + publisher.clusterID = clusterID + return &publisher +} diff --git a/components/event-bus/cmd/event-bus-publish/dockerBuild.sh b/components/event-bus/cmd/event-bus-publish/dockerBuild.sh new file mode 100755 index 000000000000..2ead11dda655 --- /dev/null +++ b/components/event-bus/cmd/event-bus-publish/dockerBuild.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -e +set -o pipefail + +NAME=$1 +VERSION=$2 + +echo -e "Start building docker image...." +mkdir -p docker/image +cp Dockerfile docker/image/ +cp -R main.go application controllers handlers docker/image/ +cp -R ../../api docker/image/ +cp -R ../../internal docker/image/ +cp -R ../../vendor docker/image/ +tagName="${NAME}:${VERSION}" +docker build --no-cache --build-arg version=${VERSION} -t ${tagName} --rm docker/image +rm -rf docker +echo -e "Docker image with the tag [ ${tagName} ] has been built successfully ..." diff --git a/components/event-bus/cmd/event-bus-publish/handlers/handlers.go b/components/event-bus/cmd/event-bus-publish/handlers/handlers.go new file mode 100644 index 000000000000..239b94949e83 --- /dev/null +++ b/components/event-bus/cmd/event-bus-publish/handlers/handlers.go @@ -0,0 +1,180 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + + api "github.com/kyma-project/kyma/components/event-bus/api/publish" + "github.com/kyma-project/kyma/components/event-bus/cmd/event-bus-publish/controllers" + "github.com/kyma-project/kyma/components/event-bus/internal/common" + "github.com/kyma-project/kyma/components/event-bus/internal/publish" + "github.com/kyma-project/kyma/components/event-bus/internal/trace" + "github.com/opentracing/opentracing-go" + "github.com/opentracing/opentracing-go/ext" + "github.com/satori/go.uuid" +) + +// GetPublishHandler is a factory for publish events handler +// TODO research a better way for dependency injection +func GetPublishHandler(publisher *controllers.Publisher, tracer *trace.Tracer) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + handlePublishRequest(w, r, publisher, tracer) + } +} + +var status common.StatusReady + +// GetReadinessHandler is a factory for publish events handler +func GetReadinessHandler(publisher *controllers.Publisher) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if publisher != nil { + if ok, err := (*publisher).IsReady(); ok && err == nil { + if status.SetReady() { + log.Printf("GetReadinessHandler :: Status: READY") + } + w.WriteHeader(http.StatusOK) + } else { + status.SetNotReady() + log.Printf("GetReadinessHandler :: Status: NOT_READY") + w.WriteHeader(http.StatusBadGateway) + go func() { + (*publisher).Stop() + (*publisher).Start() + }() + } + } else { + status.SetNotReady() + log.Printf("GetReadinessHandler :: statusReadyHandler :: Status: NOT_READY") + w.WriteHeader(http.StatusBadGateway) + } + } +} + +/* TODO: +* Log to different levels + */ +func handlePublishRequest(w http.ResponseWriter, r *http.Request, publisher *controllers.Publisher, tracer *trace.Tracer) { + log.Println("PublishHandler :: handlePublishRequest :: Handling request.") + body, err := ioutil.ReadAll(r.Body) + defer r.Body.Close() + + log.Println("PublishHandler :: handlePublishRequest :: Creating publish-to-internal-broker trace span") + var publishSpan *opentracing.Span + var traceContext *api.TraceContext + if (*tracer).Started() { + spanContext := trace.ReadTraceHeaders(&r.Header) + publishSpan = trace.StartSpan(spanContext, &(*tracer).Options().OperationName, ext.SpanKindProducer) + traceContext = trace.WriteSpan(publishSpan) + defer trace.FinishSpan(publishSpan) + } + + if err != nil { + log.Printf("PublishHandler :: handlePublishRequest :: Unexpected error while reading request body. Error: %v", err) + publish.SendJSONError(w, api.ErrorResponseInternalServer()) + trace.TagSpanAsError(publishSpan, "error while reading request body", err.Error()) + return + } + if r.Method != "POST" { + log.Println("PublishHandler :: handlePublishRequest :: Invalid request. Got a non POST request") + publish.SendJSONError(w, api.ErrorResponseBadRequest()) + trace.TagSpanAsError(publishSpan, "error got a non POST request", "") + return + } + if r.Body == nil { + log.Println("PublishHandler :: handlePublishRequest :: Invalid request. Got a null body") + publish.SendJSONError(w, api.ErrorResponseBadRequest()) + trace.TagSpanAsError(publishSpan, "error got a null request body", "") + return + } + publishRequest, err := parseRequest(body) + if err != nil { + log.Printf("PublishHandler :: handlePublishRequest :: Error while parsing request :: Error: %v", err) + publish.SendJSONError(w, api.ErrorResponseBadPayload()) + trace.TagSpanAsError(publishSpan, "error while parsing request", err.Error()) + return + } + errResponse := api.ValidatePublish(publishRequest) + if errResponse != nil { + log.Printf("PublishHandler :: handlePublishRequest :: Request validation failed. :: Error: %v", *errResponse) + publish.SendJSONError(w, errResponse) + trace.TagSpanAsError(publishSpan, errResponse.Message, "") + return + } + if len(publishRequest.EventID) == 0 { + log.Println("PublishHandler :: handlePublishRequest :: Generating event id.") + publishRequest.EventID = generateEventID() + } + + cloudEvent := buildCloudEvent(publishRequest, traceContext) + + log.Println("PublishHandler :: handlePublishRequest :: Constructing event body") + body, err = json.Marshal(cloudEvent) + if err != nil { + log.Printf("PublishHandler :: handlePublishRequest :: Error constructing event body :: Error: %v", err) + publish.SendJSONError(w, api.ErrorResponseInternalServer()) + trace.TagSpanAsError(publishSpan, "error constructing the event", err.Error()) + return + } + + addSpanTagsForCloudEvent(publishSpan, &cloudEvent) + + log.Println("PublishHandler :: handlePublishRequest :: Publishing event.") + publishResponse, err := publishEvent(publishRequest, string(body), publisher) + if err != nil { + log.Printf("PublishHandler :: handlePublishRequest :: Error while publishing event :: Error: %v", err) + publish.SendJSONError(w, api.ErrorResponseInternalServer()) + trace.TagSpanAsError(publishSpan, "error while publishing the event", err.Error()) + return + } + log.Println("PublishHandler :: handlePublishRequest :: Event published, sending response.") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(*publishResponse) + + log.Println("PublishHandler :: handlePublishRequest :: OK.") +} + +func addSpanTagsForCloudEvent(publishSpan *opentracing.Span, cloudEvent *api.CloudEvent) { + tags := trace.CreateTraceTagsFromCloudEvent(cloudEvent) + trace.SetSpanTags(publishSpan, &tags) +} + +func parseRequest(b []byte) (*api.PublishRequest, error) { + // Unmarshal + log.Println("PublishHandler :: parseRequest :: Unmarsharlling request") + var publishRequest api.PublishRequest + err := json.Unmarshal(b, &publishRequest) + return &publishRequest, err +} + +func publishEvent(r *api.PublishRequest, body string, publisher *controllers.Publisher) (*api.PublishResponse, error) { + subj := encodeSubject(r) + log.Printf("PublishHandler :: publishEvent :: Publish to Subject: %s\n", subj) + + if err := (*publisher).Publish(subj, body); err != nil { + log.Printf("PublishHandler :: publishEvent :: Error publishing message: %v\n", err) + return nil, fmt.Errorf("error publishing message: %v", err) + } + return &api.PublishResponse{EventID: r.EventID}, nil +} + +func encodeSubject(r *api.PublishRequest) string { + return common.FromPublishRequest(r).Encode() +} + +func generateEventID() string { + return uuid.NewV4().String() +} + +func buildCloudEvent(publishRequest *api.PublishRequest, traceContext *api.TraceContext) api.CloudEvent { + cloudEvent := api.CloudEvent{} + cloudEvent.PublishRequest = *publishRequest + if traceContext != nil { + cloudEvent.Extensions = make(api.Extensions) + cloudEvent.Extensions[api.FieldTraceContext] = *traceContext + } + return cloudEvent +} diff --git a/components/event-bus/cmd/event-bus-publish/main.go b/components/event-bus/cmd/event-bus-publish/main.go new file mode 100644 index 000000000000..d023d19c671b --- /dev/null +++ b/components/event-bus/cmd/event-bus-publish/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "net/http/httputil" + "os" + "os/signal" + "regexp" + "strconv" + "syscall" + + "github.com/kyma-project/kyma/components/event-bus/cmd/event-bus-publish/application" + "github.com/kyma-project/kyma/components/event-bus/internal/publish" +) + +const ( + allowedIDChars = `^[a-zA-Z0-9_\-]+$` +) + +var ( + isValidID = regexp.MustCompile(allowedIDChars).MatchString + sema chan struct{} +) + +func main() { + log.Println("Publish :: Starting up") + publishOpts := publish.ParseFlags() + + startPublish(publishOpts) +} + +func startPublish(publishOpts *publish.Options) { + if !isValidID(publishOpts.ClientID) { + log.Fatal("invalid client_id ", publishOpts.ClientID) + } + // enforce a limit of concurrent requests processed in parallel. + sema = make(chan struct{}, publishOpts.NoOfConcurrentRequests) + + publishApplication := application.NewPublishApplication(publishOpts) + defer publishApplication.Stop() + + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) + + rtr := limitParallelRequests(publishApplication.ServerMux) + if publishOpts.TraceRequests { + logger(rtr) + } + + srv := &http.Server{ + Addr: ":" + strconv.Itoa(publishOpts.Port), + Handler: rtr, + } + go func() { + log.Fatal(srv.ListenAndServe()) + }() + + killSignal := <-interrupt + switch killSignal { + case os.Interrupt: + log.Println("Got os interrupt...") + case syscall.SIGTERM: + log.Println("Got SIGTERM") + } + + log.Println("The service is shutting down....") + srv.Shutdown(context.Background()) + log.Println("Done..") +} + +func limitParallelRequests(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sema <- struct{}{} // acquire a semaphore + defer func() { <-sema }() + h.ServeHTTP(w, r) + }) +} + +func logger(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("%s requested %s", r.RemoteAddr, r.URL) + dump, err := httputil.DumpRequest(r, true) + log.Printf("%q", dump) + if err != nil { + http.Error(w, fmt.Sprint(err), http.StatusInternalServerError) + return + } + h.ServeHTTP(w, r) + }) +} diff --git a/components/event-bus/cmd/event-bus-publish/publish_test.go b/components/event-bus/cmd/event-bus-publish/publish_test.go new file mode 100644 index 000000000000..6dd04154e499 --- /dev/null +++ b/components/event-bus/cmd/event-bus-publish/publish_test.go @@ -0,0 +1,148 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + api "github.com/kyma-project/kyma/components/event-bus/api/publish" + "github.com/kyma-project/kyma/components/event-bus/cmd/event-bus-publish/application" + "github.com/kyma-project/kyma/components/event-bus/internal/publish" + "github.com/nats-io/nats-streaming-server/server" + "github.com/stretchr/testify/assert" + + stan "github.com/nats-io/go-nats-streaming" +) + +const ( + clusterID = "kyma-nats-streaming" + iterations = 5 + interval = 2 +) + +var ( + publishServer *httptest.Server + sc stan.Conn + msg *stan.Msg +) + +func TestMain(m *testing.M) { + stanServer, err := server.RunServer(clusterID) + publishOpts := publish.DefaultOptions() + publishApplication := application.NewPublishApplication(publishOpts) + publishServer = httptest.NewServer(publishApplication.ServerMux) + sc, _ = stan.Connect(clusterID, "kyma-int-test") + if err != nil { + panic(err) + } else { + retCode := m.Run() + publishServer.Close() + publishApplication.Stop() + stanServer.Shutdown() + os.Exit(retCode) + } +} + +func TestPublish(t *testing.T) { + subject, payload := buildDefaultTestSubjectAndPayload() + sub, _ := sc.Subscribe(subject, func(m *stan.Msg) { + msg = m + }) + body, statusCode := performPublishRequest(t, publishServer.URL, payload) + if statusCode != http.StatusOK { + t.Errorf("Status code is wrong, have: %d, want: %d", statusCode, http.StatusOK) + } + respObj := &api.PublishResponse{} + err := json.Unmarshal(body, &respObj) + if err != nil { + t.Fatal(err) + } + assert.NotNil(t, respObj.EventID) + assert.NotEmpty(t, respObj.EventID) + i := 1 + for msg == nil { + if i > iterations { + t.Error("Test failed") + break + } + log.Printf("Waiting for receiving published message [%d/%d].", i, iterations) + time.Sleep(interval * time.Second) + i++ + } + verifyReceivedMsg(t, payload, msg.Data) + sub.Unsubscribe() +} + +func TestStatus(t *testing.T) { + log.Println("started nats and publish app") + res, err := http.Get(publishServer.URL + "/v1/status/ready") + assert.Nil(t, err) + assert.Equal(t, res.StatusCode, http.StatusOK) +} + +func TestPublishWithBadPayload(t *testing.T) { + payload := buildDefaultTestBadPayload() + body, statusCode := performPublishRequest(t, publishServer.URL, payload) + assertExpectedError(t, body, statusCode, http.StatusBadRequest, nil, api.ErrorTypeBadPayload) +} + +func TestPublishWithoutSource(t *testing.T) { + payload := buildDefaultTestPayloadWithoutSource() + body, statusCode := performPublishRequest(t, publishServer.URL, payload) + assertExpectedError(t, body, statusCode, http.StatusBadRequest, api.FieldSource, api.ErrorTypeValidationViolation) +} + +func TestPublishWithoutEventType(t *testing.T) { + payload := buildDefaultTestPayloadWithoutEventType() + body, statusCode := performPublishRequest(t, publishServer.URL, payload) + assertExpectedError(t, body, statusCode, http.StatusBadRequest, api.FieldEventType, api.ErrorTypeValidationViolation) +} + +func TestPublishWithoutEventTypeVersion(t *testing.T) { + payload := buildDefaultTestPayloadWithoutEventTypeVersion() + body, statusCode := performPublishRequest(t, publishServer.URL, payload) + assertExpectedError(t, body, statusCode, http.StatusBadRequest, api.FieldEventTypeVersion, api.ErrorTypeValidationViolation) +} + +func TestPublishWithoutEventTime(t *testing.T) { + payload := buildDefaultTestPayloadWithoutEventTime() + body, statusCode := performPublishRequest(t, publishServer.URL, payload) + assertExpectedError(t, body, statusCode, http.StatusBadRequest, api.FieldEventTime, api.ErrorTypeValidationViolation) +} + +func TestPublishWithoutData(t *testing.T) { + payload := buildDefaultTestPayloadWithoutData() + body, statusCode := performPublishRequest(t, publishServer.URL, payload) + assertExpectedError(t, body, statusCode, http.StatusBadRequest, api.FieldData, api.ErrorTypeValidationViolation) +} + +func TestPublishWithEmptyData(t *testing.T) { + payload := buildDefaultTestPayloadWithEmptyData() + body, statusCode := performPublishRequest(t, publishServer.URL, payload) + assertExpectedError(t, body, statusCode, http.StatusBadRequest, api.FieldData, api.ErrorTypeValidationViolation) +} + +func TestPublishInvalidEventTypeVersion(t *testing.T) { + payload := buildTestPayload(testSourceNamespace, testSourceType, testSourceEnvironment, testEventType, testEventTypeVersionInvalid, testEventID, + testEventTime, testData) + body, statusCode := performPublishRequest(t, publishServer.URL, payload) + assertExpectedError(t, body, statusCode, http.StatusBadRequest, api.FieldEventTypeVersion, api.ErrorTypeValidationViolation) +} + +func TestPublishInvalidEventTime(t *testing.T) { + payload := buildTestPayload(testSourceNamespace, testSourceType, testSourceEnvironment, testEventType, testEventTypeVersion, testEventID, + testEventTimeInvalid, testData) + body, statusCode := performPublishRequest(t, publishServer.URL, payload) + assertExpectedError(t, body, statusCode, http.StatusBadRequest, api.FieldEventTime, api.ErrorTypeValidationViolation) +} + +func TestPublishInvalidEventId(t *testing.T) { + payload := buildTestPayload(testSourceNamespace, testSourceType, testSourceEnvironment, testEventType, testEventTypeVersion, testEventIDInvalid, + testEventTime, testData) + body, statusCode := performPublishRequest(t, publishServer.URL, payload) + assertExpectedError(t, body, statusCode, http.StatusBadRequest, api.FieldEventId, api.ErrorTypeValidationViolation) +} diff --git a/components/event-bus/cmd/event-bus-publish/test_helpers.go b/components/event-bus/cmd/event-bus-publish/test_helpers.go new file mode 100644 index 000000000000..c09a2d43464b --- /dev/null +++ b/components/event-bus/cmd/event-bus-publish/test_helpers.go @@ -0,0 +1,240 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" + "testing" + + api "github.com/kyma-project/kyma/components/event-bus/api/publish" + "github.com/stretchr/testify/assert" +) + +const ( + // publish request + testUrl = "/v1/events/" + testSourceType = "test-source-type" + testSourceNamespace = "test-source-namespace" + testSourceEnvironment = "test-source-environment" + testEventType = "test-event-type" + testEventTypeVersion = "v1" + testEventTypeVersionInvalid = "#" + testEventTime = "2012-11-01T22:08:41+00:00" + testEventTimeInvalid = "2012-11-01T22" + testEventID = "4ea567cf-812b-49d9-a4b2-cb5ddf464094" + testEventIDInvalid = "4ea567cf" + testData = "{'key':'value'}" + testDataEmpty = "" + + // event payload format + eventFormat = "{%v}" + sourceFormat = "\"source\":{\"source-namespace\":\"%v\",\"source-type\":\"%v\",\"source-environment\":\"%v\"}" + eventTypeFormat = "\"event-type\":\"%v\"" + eventTypeVersionFormat = "\"event-type-version\":\"%v\"" + eventIDFormat = "\"event-id\":\"%v\"" + eventTimeFormat = "\"event-time\":\"%v\"" + dataFormat = "\"data\":\"%v\"" +) + +type eventBuilder struct { + bytes.Buffer +} + +func (b *eventBuilder) build(format string, values ...interface{}) *eventBuilder { + if b.Len() > 0 { + b.WriteString(",") + } + source := fmt.Sprintf(format, values...) + b.WriteString(source) + return b +} + +func (b *eventBuilder) String() string { + return fmt.Sprintf(eventFormat, b.Buffer.String()) +} + +func buildTestPublishRequest(sourceNamespace, sourceType, sourceEnvironment, eventType, eventTypeVersion, eventID, eventTime, data string) api.PublishRequest { + publishRequest := api.PublishRequest{ + Data: data, + EventID: eventID, + EventTime: eventTime, + EventType: eventType, + EventTypeVersion: eventTypeVersion, + Source: &api.EventSource{ + SourceEnvironment: sourceEnvironment, + SourceNamespace: sourceNamespace, + SourceType: sourceType, + }, + } + return publishRequest +} + +func buildDefaultTestPublishRequest() api.PublishRequest { + return buildTestPublishRequest(testSourceNamespace, testSourceType, testSourceEnvironment, testEventType, testEventTypeVersion, testEventID, testEventTime, testData) +} + +func buildDefaultTestSubjectAndPayload() (string, string) { + subject := buildDefaultTestSubject() + payload := buildDefaultTestPayload() + return subject, payload +} + +func buildDefaultTestSubject() string { + return buildTestSubject(testSourceEnvironment, testSourceNamespace, testSourceType, testEventType, testEventTypeVersion) +} + +func buildTestSubject(sourceEnvironment, sourceNamespace, sourceType, eventType, eventTypeVersion string) string { + return encodeSubject(buildTestPublishRequest(sourceNamespace, sourceType, sourceEnvironment, eventType, eventTypeVersion, testEventID, testEventTime, testData)) +} + +func buildDefaultTestPayload() string { + return buildTestPayload(testSourceNamespace, testSourceType, testSourceEnvironment, testEventType, testEventTypeVersion, testEventID, testEventTime, testData) +} + +func buildTestPayload(sourceNamespace, sourceType, sourceEnvironment, eventType, eventTypeVersion, eventID, eventTime, data string) string { + builder := new(eventBuilder). + build(sourceFormat, sourceNamespace, sourceType, sourceEnvironment). + build(eventTypeFormat, eventType). + build(eventTypeVersionFormat, eventTypeVersion). + build(eventIDFormat, eventID). + build(eventTimeFormat, eventTime). + build(dataFormat, data) + payload := builder.String() + return payload +} + +func buildDefaultTestBadPayload() string { + builder := new(eventBuilder). + build(sourceFormat, testSourceNamespace, testSourceType, testSourceEnvironment). + build(eventTypeFormat, testEventType). + build(eventTypeVersionFormat, testEventTypeVersion). + build(eventIDFormat, testEventID). + build(eventTimeFormat, testEventTime). + build(dataFormat, testData) + builder.WriteString(",") // spoil the payload + payload := builder.String() + return payload +} + +func buildDefaultTestPayloadWithoutSource() string { + builder := new(eventBuilder). + build(eventTypeFormat, testEventType). + build(eventTypeVersionFormat, testEventTypeVersion). + build(eventIDFormat, testEventID). + build(eventTimeFormat, testEventTime). + build(dataFormat, testData) + payload := builder.String() + return payload +} + +func buildDefaultTestPayloadWithoutEventType() string { + builder := new(eventBuilder). + build(sourceFormat, testSourceNamespace, testSourceType, testSourceEnvironment). + build(eventTypeVersionFormat, testEventTypeVersion). + build(eventIDFormat, testEventID). + build(eventTimeFormat, testEventTime). + build(dataFormat, testData) + payload := builder.String() + return payload +} + +func buildDefaultTestPayloadWithoutEventTypeVersion() string { + builder := new(eventBuilder). + build(sourceFormat, testSourceNamespace, testSourceType, testSourceEnvironment). + build(eventTypeFormat, testEventType). + build(eventIDFormat, testEventID). + build(eventTimeFormat, testEventTime). + build(dataFormat, testData) + payload := builder.String() + return payload +} + +func buildDefaultTestPayloadWithoutEventTime() string { + builder := new(eventBuilder). + build(sourceFormat, testSourceNamespace, testSourceType, testSourceEnvironment). + build(eventTypeFormat, testEventType). + build(eventTypeVersionFormat, testEventTypeVersion). + build(eventIDFormat, testEventID). + build(dataFormat, testData) + payload := builder.String() + return payload +} + +func buildDefaultTestPayloadWithoutData() string { + builder := new(eventBuilder). + build(sourceFormat, testSourceNamespace, testSourceType, testSourceEnvironment). + build(eventTypeFormat, testEventType). + build(eventTypeVersionFormat, testEventTypeVersion). + build(eventIDFormat, testEventID). + build(eventTimeFormat, testEventTime) + payload := builder.String() + return payload +} + +func buildDefaultTestPayloadWithEmptyData() string { + builder := new(eventBuilder). + build(sourceFormat, testSourceNamespace, testSourceType, testSourceEnvironment). + build(eventTypeFormat, testEventType). + build(eventTypeVersionFormat, testEventTypeVersion). + build(eventIDFormat, testEventID). + build(eventTimeFormat, testEventTime). + build(dataFormat, testDataEmpty) + payload := builder.String() + return payload +} + +func encodeSubject(r api.PublishRequest) string { + return fmt.Sprintf("%s.%s.%s.%s.%s", + r.Source.SourceEnvironment, + r.Source.SourceNamespace, + r.Source.SourceType, + r.EventType, + r.EventTypeVersion) +} + +func performPublishRequest(t *testing.T, publishURL string, payload string) ([]byte, int) { + res, err := http.Post(publishURL+"/v1/events", "application/json", strings.NewReader(payload)) + + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + + if err != nil { + t.Fatal(err) + } + + return body, res.StatusCode +} + +func verifyReceivedMsg(t *testing.T, a string, b []byte) { + var bReq api.PublishRequest + err := json.Unmarshal(b, &bReq) + if err != nil { + t.Error(err) + } + var aReq api.PublishRequest + err = json.Unmarshal([]byte(a), &aReq) + if err != nil { + t.Error(err) + } + assert.Equal(t, aReq.EventID, bReq.EventID) +} + +func assertExpectedError(t *testing.T, body []byte, actualStatusCode int, expectedStatusCode int, errorField interface{}, errorType interface{}) { + var responseError api.Error + err := json.Unmarshal(body, &responseError) + assert.Nil(t, err) + if errorType != nil { + assert.Equal(t, errorType, responseError.Type) + } + if errorField != nil { + assert.NotNil(t, responseError.Details) + assert.NotEqual(t, len(responseError.Details), 0) + assert.Equal(t, errorField, responseError.Details[0].Field) + } +} diff --git a/components/event-bus/cmd/event-bus-push/Dockerfile b/components/event-bus/cmd/event-bus-push/Dockerfile new file mode 100644 index 000000000000..4f389f46cecf --- /dev/null +++ b/components/event-bus/cmd/event-bus-push/Dockerfile @@ -0,0 +1,27 @@ +FROM golang:1.10 as builder + +WORKDIR /go/src/github.com/kyma-project/kyma/components/event-bus/ +COPY generated ./generated +COPY internal ./internal +COPY vendor ./vendor +COPY api ./api + +WORKDIR /go/src/github.com/kyma-project/kyma/components/event-bus/cmd/event-bus-push/ +COPY main.go . +COPY application ./application +RUN ls ./ +RUN CGO_ENABLED=0 GOOS=linux go build -v -a -installsuffix cgo -o event-bus-push . + +FROM scratch +LABEL source=git@github.com:kyma-project/kyma.git + +ARG version +ENV APP_VERSION $version + +WORKDIR /root/ + +COPY --from=builder /go/src/github.com/kyma-project/kyma/components/event-bus/cmd/event-bus-push/event-bus-push . + +EXPOSE 8080 + +ENTRYPOINT ["/root/event-bus-push"] diff --git a/components/event-bus/cmd/event-bus-push/Makefile b/components/event-bus/cmd/event-bus-push/Makefile new file mode 100644 index 000000000000..004576e956cd --- /dev/null +++ b/components/event-bus/cmd/event-bus-push/Makefile @@ -0,0 +1,32 @@ +NAME = kyma-project/event-bus-push +VERSION = 0.1.0 +REGISTRY = eu.gcr.io + +.PHONY: all clean build tag push + +all: clean build docker-build tag + +clean: + rm -rf bin/ + rm -rf docker/ + +build: vet test compile + +compile: + go build -o bin/event-bus-push + +test: + go test github.com/kyma-project/kyma/components/event-bus/internal/push/... -v + go test github.com/kyma-project/kyma/components/event-bus/test/acceptance/push -v + +docker-build: + ./dockerBuild.sh $(NAME) $(VERSION) + +tag: + docker tag $(NAME):$(VERSION) $(REGISTRY)/$(NAME):$(VERSION) + +push: + docker push $(REGISTRY)/$(NAME):$(VERSION) + +vet: + go list ../../... | grep -v generated | xargs go vet diff --git a/components/event-bus/cmd/event-bus-push/application/application.go b/components/event-bus/cmd/event-bus-push/application/application.go new file mode 100644 index 000000000000..e2f058362cde --- /dev/null +++ b/components/event-bus/cmd/event-bus-push/application/application.go @@ -0,0 +1,85 @@ +package application + +import ( + "log" + "net/http" + + "github.com/kyma-project/kyma/components/event-bus/internal/common" + "github.com/kyma-project/kyma/components/event-bus/internal/push/actors" + "github.com/kyma-project/kyma/components/event-bus/internal/push/controllers" + pushOpts "github.com/kyma-project/kyma/components/event-bus/internal/push/opts" + "github.com/kyma-project/kyma/components/event-bus/internal/trace" + "k8s.io/client-go/tools/cache" +) + +// PushApplication ... +type PushApplication struct { + SubscriptionsSupervisor *actors.SubscriptionsSupervisor + subscriptionsController *controllers.SubscriptionsController + ServerMux *http.ServeMux + tracer trace.Tracer +} + +// NewPushApplication ... +func NewPushApplication(pushOpts *pushOpts.Options, informer ...cache.SharedIndexInformer) *PushApplication { + log.Println("Push :: Initializing application") + tracer := trace.StartNewTracer(&pushOpts.Options) + subscriptionsSupervisor := actors.StartSubscriptionsSupervisor(pushOpts, &tracer) + var subscriptionsController *controllers.SubscriptionsController + if len(informer) > 0 { + subscriptionsController = controllers.StartSubscriptionsControllerWithInformer(subscriptionsSupervisor, informer[0], pushOpts) + } else { + subscriptionsController = controllers.StartSubscriptionsController(subscriptionsSupervisor, pushOpts) + } + + serveMux := http.NewServeMux() + serveMux.Handle("/v1/status/live", statusLiveHandler(subscriptionsSupervisor)) + serveMux.Handle("/v1/status/ready", statusReadyHandler(subscriptionsSupervisor)) + + return &PushApplication{ + SubscriptionsSupervisor: subscriptionsSupervisor, + subscriptionsController: subscriptionsController, + ServerMux: serveMux, + tracer: tracer, + } +} + +// Stop ... +func (a *PushApplication) Stop() { + a.subscriptionsController.Stop() + a.SubscriptionsSupervisor.PoisonPill() + a.tracer.Stop() +} + +var statusLive, statusReady common.StatusReady + +func statusLiveHandler(subscriptionsSupervisor *actors.SubscriptionsSupervisor) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if subscriptionsSupervisor != nil && subscriptionsSupervisor.IsRunning() { + if statusLive.SetReady() { + log.Printf("statusLiveHandler :: Status: READY") + } + w.WriteHeader(http.StatusOK) + } else { + statusLive.SetNotReady() + log.Printf("statusLiveHandler :: Status: NOT_READY") + w.WriteHeader(http.StatusBadGateway) + } + }) +} + +func statusReadyHandler(subscriptionsSupervisor *actors.SubscriptionsSupervisor) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if subscriptionsSupervisor != nil && subscriptionsSupervisor.IsNATSConnected() { + if statusReady.SetReady() { + log.Printf("statusReadyHandler :: Status: READY") + } + w.WriteHeader(http.StatusOK) + } else { + statusReady.SetNotReady() + log.Printf("statusReadyHandler :: Status: NOT_READY") + w.WriteHeader(http.StatusBadGateway) + go subscriptionsSupervisor.ReconnectToNATSStreaming() + } + }) +} diff --git a/components/event-bus/cmd/event-bus-push/dockerBuild.sh b/components/event-bus/cmd/event-bus-push/dockerBuild.sh new file mode 100755 index 000000000000..265b54c5175f --- /dev/null +++ b/components/event-bus/cmd/event-bus-push/dockerBuild.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -e +set -o pipefail + +NAME=$1 +VERSION=$2 + +echo -e "Start building docker image...." +mkdir -p docker/image +cp Dockerfile docker/image/ +cp -R main.go application docker/image/ +cp -R ../../api docker/image/ +cp -R ../../generated docker/image/ +cp -R ../../internal docker/image/ +cp -R ../../vendor docker/image/ +tagName="${NAME}:${VERSION}" +docker build --build-arg version=${VERSION} -t ${tagName} --rm docker/image +rm -rf docker +echo -e "Docker image with the tag [ ${tagName} ] has been built successfully ..." diff --git a/components/event-bus/cmd/event-bus-push/main.go b/components/event-bus/cmd/event-bus-push/main.go new file mode 100644 index 000000000000..90fbc8400d51 --- /dev/null +++ b/components/event-bus/cmd/event-bus-push/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "fmt" + "log" + "net/http" + + "github.com/kyma-project/kyma/components/event-bus/cmd/event-bus-push/application" + "github.com/kyma-project/kyma/components/event-bus/internal/push/opts" +) + +func main() { + pushOpts := opts.ParseFlags() + + pushApplication := application.NewPushApplication(pushOpts) + defer pushApplication.Stop() + + log.Printf("HTTP server starting on port %v", pushOpts.Port) + log.Fatal(http.ListenAndServe(fmt.Sprintf(":%v", pushOpts.Port), pushApplication.ServerMux)) +} diff --git a/components/event-bus/cmd/event-bus-sv/Dockerfile b/components/event-bus/cmd/event-bus-sv/Dockerfile new file mode 100644 index 000000000000..dded7e2c0582 --- /dev/null +++ b/components/event-bus/cmd/event-bus-sv/Dockerfile @@ -0,0 +1,27 @@ +FROM golang:1.10 as builder + +WORKDIR /go/src/github.com/kyma-project/kyma/components/event-bus/ +COPY generated ./generated +COPY internal ./internal +COPY vendor ./vendor +COPY api ./api + +WORKDIR /go/src/github.com/kyma-project/kyma/components/event-bus/cmd/event-bus-sv/ +COPY main.go . +COPY application ./application +RUN ls ./ +RUN CGO_ENABLED=0 GOOS=linux go build -v -a -installsuffix cgo -o event-bus-sub-validator . + +FROM scratch +LABEL source=git@github.com:kyma-project/kyma.git + +ARG version +ENV APP_VERSION $version + +WORKDIR /root/ + +COPY --from=builder /go/src/github.com/kyma-project/kyma/components/event-bus/cmd/event-bus-sv/event-bus-sub-validator . + +EXPOSE 8080 + +ENTRYPOINT ["/root/event-bus-sub-validator"] diff --git a/components/event-bus/cmd/event-bus-sv/Makefile b/components/event-bus/cmd/event-bus-sv/Makefile new file mode 100644 index 000000000000..c23b32e10283 --- /dev/null +++ b/components/event-bus/cmd/event-bus-sv/Makefile @@ -0,0 +1,37 @@ +NAME = kyma-project/event-bus-sub-validator +VERSION = 0.1.0 +REGISTRY = eu.gcr.io + +.PHONY: all clean build tag push + +all: clean build docker-build tag + +clean: + rm -rf bin/ + rm -rf docker/ + +build: vet compile + +compile: + go build -o bin/event-bus-sub-validator + +docker-build: + mkdir -p docker/image + cp Dockerfile docker/image/ + cp -R main.go application docker/image/ + cp -R ../../generated docker/image/ + cp -R ../../internal docker/image/ + cp -R ../../vendor docker/image/ + cp -R ../../api docker/image/ + docker build --build-arg version=$(VERSION) -t $(NAME):$(VERSION) --rm docker/image + rm -rf docker + +tag: + docker tag $(NAME):$(VERSION) $(REGISTRY)/$(NAME):$(VERSION) + +push: + docker push $(REGISTRY)/$(NAME):$(VERSION) + +vet: + go list ../../... | grep -v generated | xargs go vet + diff --git a/components/event-bus/cmd/event-bus-sv/application/application.go b/components/event-bus/cmd/event-bus-sv/application/application.go new file mode 100644 index 000000000000..6316a117143d --- /dev/null +++ b/components/event-bus/cmd/event-bus-sv/application/application.go @@ -0,0 +1,68 @@ +package application + +import ( + "log" + "net/http" + + "github.com/kyma-project/kyma/components/event-bus/internal/sv" + "github.com/kyma-project/kyma/components/event-bus/internal/sv/opts" + "k8s.io/client-go/tools/cache" +) + +// SubscriptionValidatorApplication ... +type SubscriptionValidatorApplication struct { + eaController *ea.EventActivationsController + subController *ea.SubscriptionsController + ServerMux *http.ServeMux +} + +// NewSubscriptionValidatorApplication ... +// func NewSubscriptionValidatorApplication(eaOpts *ea.Options, informer ...cache.SharedIndexInformer) *SubscriptionValidatorApplication { +func NewSubscriptionValidatorApplication(svOpts *opts.Options, informer ...cache.SharedIndexInformer) *SubscriptionValidatorApplication { + log.Println("Subscription Validator :: Initializing application") + var eaController *ea.EventActivationsController + if len(informer) > 0 { + eaController = ea.StartEventActivationsControllerWithInformer(informer[0]) + } else { + eaController = ea.StartEventActivationsController() + } + + subController := ea.StartSubscriptionsController() + + serveMux := http.NewServeMux() + serveMux.Handle("/v1/status/live", statusLiveHandler(eaController)) + serveMux.Handle("/v1/status/ready", statusLiveHandler(eaController)) + + return &SubscriptionValidatorApplication{ + eaController: eaController, + subController: subController, + ServerMux: serveMux, + } +} + +// Stop ... +func (a *SubscriptionValidatorApplication) Stop() { + a.eaController.Stop() + a.subController.Stop() +} + +func statusLiveHandler(controller *ea.EventActivationsController) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if controller != nil && controller.IsRunning() { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusBadGateway) + } + }) +} + +// TODO more complex readness tests +func statusReadyHandler(controller *ea.EventActivationsController) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if controller != nil && controller.IsRunning() { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusBadGateway) + } + }) +} diff --git a/components/event-bus/cmd/event-bus-sv/dockerBuild.sh b/components/event-bus/cmd/event-bus-sv/dockerBuild.sh new file mode 100755 index 000000000000..2ef254a7a241 --- /dev/null +++ b/components/event-bus/cmd/event-bus-sv/dockerBuild.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -e +set -o pipefail + +NAME=$1 +VERSION=$2 + +echo -e "Start building docker image...." +mkdir -p docker/image +cp Dockerfile docker/image/ +cp -R main.go application docker/image/ +cp -R ../../generated docker/image/ +cp -R ../../internal docker/image/ +cp -R ../../vendor docker/image/ +cp -R ../../api docker/image/ +tagName="${NAME}:${VERSION}" +docker build --build-arg version=${VERSION} -t ${tagName} --rm docker/image +rm -rf docker +echo -e "Docker image with the tag [ ${tagName} ] has been built successfully ..." \ No newline at end of file diff --git a/components/event-bus/cmd/event-bus-sv/main.go b/components/event-bus/cmd/event-bus-sv/main.go new file mode 100644 index 000000000000..4cc02e9e378d --- /dev/null +++ b/components/event-bus/cmd/event-bus-sv/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "fmt" + "log" + "net/http" + + "github.com/kyma-project/kyma/components/event-bus/cmd/event-bus-sv/application" + "github.com/kyma-project/kyma/components/event-bus/internal/sv/opts" +) + +func main() { + svOpts := opts.ParseFlags() + + svApplication := application.NewSubscriptionValidatorApplication(svOpts) + defer svApplication.Stop() + + log.Printf("HTTP server starting on port %v", svOpts.Port) + log.Fatal(http.ListenAndServe(fmt.Sprintf(":%v", svOpts.Port), svApplication.ServerMux)) +} diff --git a/components/event-bus/docs/api.yaml b/components/event-bus/docs/api.yaml new file mode 100644 index 000000000000..66b5422b1db3 --- /dev/null +++ b/components/event-bus/docs/api.yaml @@ -0,0 +1,198 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Events API +paths: + /v1/events: + post: + summary: Publish an event + operationId: publishEvent + tags: + - publish + requestBody: + description: The event to be published + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PublishRequest' + responses: + '200': + description: The event was successfully published + content: + application/json: + schema: + $ref: '#/components/schemas/PublishResponse' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + '401': + description: Authentication failure + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + '403': + description: Not authorized + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' +components: + schemas: + PublishRequest: + type: object + description: A Publish request + properties: + source: + $ref: '#/components/schemas/EventSource' + event-type: + description: Type of the event. + type: string + format: hostname + pattern: '^[a-zA-Z]+([_\-\.]?[a-zA-Z0-9]+)*$' + example: + - "order.created" + event-type-version: + description: The version of the event-type. This is applicable to the data payload alone. + type: string + pattern: '^[a-zA-Z0-9]+$' + example: + - "v1" + event-id: + description: Optional publisher provided ID (UUID v4) of the to-be-published event. When omitted, one will be automatically generated. + type: string + pattern: "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + example: + - "31109198-4d69-4ae0-972d-76117f3748c8" + event-time: + description: RFC 3339 timestamp of when the event happened. + type: string + format: date-time + example: + - "2012-11-01T22:08:41+00:00" + data: + $ref: '#/components/schemas/AnyValue' + required: + - source + - event-type + - event-type-version + - event-time + - data + PublishResponse: + type: object + description: A Publish response + properties: + event-id: + type: string + description: ID of the published event + pattern: "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + example: "31109198-4d69-4ae0-972d-76117f3748c8" + required: + - event-id + AnyValue: + nullable: false + description: Can be any value but null. + EventSource: + description: This describes the software instance that emits the event at runtime (i.e. the producer). + type: object + properties: + source-namespace: + description: Identifier that uniquely identifies the organization publishing the event. + type: string + format: hostname + pattern: '^[a-zA-Z]+([_\-\.]?[a-zA-Z0-9]+)*$' + example: + - "kafka.apache.org" + - "com.microsoft.azure" + - "local.kyma.commerce" + source-type: + description: Type of the event source, within the namespace context. + type: string + format: hostname + pattern: '^[a-zA-Z]+([_\-\.]?[a-zA-Z0-9]+)*$' + example: + - "s3" + - "commerce" + - "marketing" + source-environment: + description: Environment of the event source. + type: string + format: hostname + pattern: '^[a-zA-Z]+([_\-\.]?[a-zA-Z0-9]+)*$' + example: + - "my.s3.bucket" + - "stage" + - "production" + required: + - source-namespace + - source-type + - source-environment + APIError: + type: object + description: API Error response body + properties: + status: + type: integer + description: >- + original HTTP error code, should be consistent with the response HTTP code + minimum: 100 + maximum: 599 + type: + type: string + description: >- + classification of the error type, lower case with underscore eg + validation_failure + pattern: '[a-z]+[a-z_]*[a-z]+' + message: + type: string + description: descriptive error message for debugging + moreInfo: + type: string + format: uri + description: link to documentation to investigate further and finding support + details: + type: array + description: list of error causes + items: + $ref: '#/components/schemas/APIErrorDetail' + required: + - status + - type + APIErrorDetail: + description: schema for specific error detail + type: object + properties: + field: + type: string + description: >- + a bean notation expression specifying the element in request + data causing the error, eg product.variants[3].name, this can + be empty if violation was not field specific + type: + type: string + description: >- + classification of the error detail type, lower case with + underscore eg missing_value, this value must be always + interpreted in context of the general error type. + pattern: '[a-z]+[a-z_]*[a-z]+' + message: + type: string + description: descriptive error detail message for debugging + moreInfo: + type: string + format: uri + description: >- + link to documentation to investigate further and finding + support for error detail + required: + - type \ No newline at end of file diff --git a/components/event-bus/generated/ea/clientset/versioned/clientset.go b/components/event-bus/generated/ea/clientset/versioned/clientset.go new file mode 100644 index 000000000000..e17171fe25b1 --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/clientset.go @@ -0,0 +1,84 @@ +// Code generated by client-gen. DO NOT EDIT. + +package versioned + +import ( + glog "github.com/golang/glog" + remoteenvironmentv1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1" + discovery "k8s.io/client-go/discovery" + rest "k8s.io/client-go/rest" + flowcontrol "k8s.io/client-go/util/flowcontrol" +) + +type Interface interface { + Discovery() discovery.DiscoveryInterface + RemoteenvironmentV1alpha1() remoteenvironmentv1alpha1.RemoteenvironmentV1alpha1Interface + // Deprecated: please explicitly pick a version if possible. + Remoteenvironment() remoteenvironmentv1alpha1.RemoteenvironmentV1alpha1Interface +} + +// Clientset contains the clients for groups. Each group has exactly one +// version included in a Clientset. +type Clientset struct { + *discovery.DiscoveryClient + remoteenvironmentV1alpha1 *remoteenvironmentv1alpha1.RemoteenvironmentV1alpha1Client +} + +// RemoteenvironmentV1alpha1 retrieves the RemoteenvironmentV1alpha1Client +func (c *Clientset) RemoteenvironmentV1alpha1() remoteenvironmentv1alpha1.RemoteenvironmentV1alpha1Interface { + return c.remoteenvironmentV1alpha1 +} + +// Deprecated: Remoteenvironment retrieves the default version of RemoteenvironmentClient. +// Please explicitly pick a version. +func (c *Clientset) Remoteenvironment() remoteenvironmentv1alpha1.RemoteenvironmentV1alpha1Interface { + return c.remoteenvironmentV1alpha1 +} + +// Discovery retrieves the DiscoveryClient +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + if c == nil { + return nil + } + return c.DiscoveryClient +} + +// NewForConfig creates a new Clientset for the given config. +func NewForConfig(c *rest.Config) (*Clientset, error) { + configShallowCopy := *c + if configShallowCopy.RateLimiter == nil && configShallowCopy.QPS > 0 { + configShallowCopy.RateLimiter = flowcontrol.NewTokenBucketRateLimiter(configShallowCopy.QPS, configShallowCopy.Burst) + } + var cs Clientset + var err error + cs.remoteenvironmentV1alpha1, err = remoteenvironmentv1alpha1.NewForConfig(&configShallowCopy) + if err != nil { + return nil, err + } + + cs.DiscoveryClient, err = discovery.NewDiscoveryClientForConfig(&configShallowCopy) + if err != nil { + glog.Errorf("failed to create the DiscoveryClient: %v", err) + return nil, err + } + return &cs, nil +} + +// NewForConfigOrDie creates a new Clientset for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *Clientset { + var cs Clientset + cs.remoteenvironmentV1alpha1 = remoteenvironmentv1alpha1.NewForConfigOrDie(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClientForConfigOrDie(c) + return &cs +} + +// New creates a new Clientset for the given RESTClient. +func New(c rest.Interface) *Clientset { + var cs Clientset + cs.remoteenvironmentV1alpha1 = remoteenvironmentv1alpha1.New(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClient(c) + return &cs +} diff --git a/components/event-bus/generated/ea/clientset/versioned/doc.go b/components/event-bus/generated/ea/clientset/versioned/doc.go new file mode 100644 index 000000000000..0e0c2a8900e2 --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated clientset. +package versioned diff --git a/components/event-bus/generated/ea/clientset/versioned/fake/clientset_generated.go b/components/event-bus/generated/ea/clientset/versioned/fake/clientset_generated.go new file mode 100644 index 000000000000..dde50946a72c --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/fake/clientset_generated.go @@ -0,0 +1,65 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + clientset "github.com/kyma-project/kyma/components/event-bus/generated/ea/clientset/versioned" + remoteenvironmentv1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1" + fakeremoteenvironmentv1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/fake" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/discovery" + fakediscovery "k8s.io/client-go/discovery/fake" + "k8s.io/client-go/testing" +) + +// NewSimpleClientset returns a clientset that will respond with the provided objects. +// It's backed by a very simple object tracker that processes creates, updates and deletions as-is, +// without applying any validations and/or defaults. It shouldn't be considered a replacement +// for a real clientset and is mostly useful in simple unit tests. +func NewSimpleClientset(objects ...runtime.Object) *Clientset { + o := testing.NewObjectTracker(scheme, codecs.UniversalDecoder()) + for _, obj := range objects { + if err := o.Add(obj); err != nil { + panic(err) + } + } + + fakePtr := testing.Fake{} + fakePtr.AddReactor("*", "*", testing.ObjectReaction(o)) + fakePtr.AddWatchReactor("*", func(action testing.Action) (handled bool, ret watch.Interface, err error) { + gvr := action.GetResource() + ns := action.GetNamespace() + watch, err := o.Watch(gvr, ns) + if err != nil { + return false, nil, err + } + return true, watch, nil + }) + + return &Clientset{fakePtr, &fakediscovery.FakeDiscovery{Fake: &fakePtr}} +} + +// Clientset implements clientset.Interface. Meant to be embedded into a +// struct to get a default implementation. This makes faking out just the method +// you want to test easier. +type Clientset struct { + testing.Fake + discovery *fakediscovery.FakeDiscovery +} + +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + return c.discovery +} + +var _ clientset.Interface = &Clientset{} + +// RemoteenvironmentV1alpha1 retrieves the RemoteenvironmentV1alpha1Client +func (c *Clientset) RemoteenvironmentV1alpha1() remoteenvironmentv1alpha1.RemoteenvironmentV1alpha1Interface { + return &fakeremoteenvironmentv1alpha1.FakeRemoteenvironmentV1alpha1{Fake: &c.Fake} +} + +// Remoteenvironment retrieves the RemoteenvironmentV1alpha1Client +func (c *Clientset) Remoteenvironment() remoteenvironmentv1alpha1.RemoteenvironmentV1alpha1Interface { + return &fakeremoteenvironmentv1alpha1.FakeRemoteenvironmentV1alpha1{Fake: &c.Fake} +} diff --git a/components/event-bus/generated/ea/clientset/versioned/fake/doc.go b/components/event-bus/generated/ea/clientset/versioned/fake/doc.go new file mode 100644 index 000000000000..3630ed1cd17d --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/fake/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated fake clientset. +package fake diff --git a/components/event-bus/generated/ea/clientset/versioned/fake/register.go b/components/event-bus/generated/ea/clientset/versioned/fake/register.go new file mode 100644 index 000000000000..25744477d7c4 --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/fake/register.go @@ -0,0 +1,38 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + remoteenvironmentv1alpha1 "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var scheme = runtime.NewScheme() +var codecs = serializer.NewCodecFactory(scheme) +var parameterCodec = runtime.NewParameterCodec(scheme) + +func init() { + v1.AddToGroupVersion(scheme, schema.GroupVersion{Version: "v1"}) + AddToScheme(scheme) +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +func AddToScheme(scheme *runtime.Scheme) { + remoteenvironmentv1alpha1.AddToScheme(scheme) +} diff --git a/components/event-bus/generated/ea/clientset/versioned/scheme/doc.go b/components/event-bus/generated/ea/clientset/versioned/scheme/doc.go new file mode 100644 index 000000000000..14db57a58f8d --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/scheme/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package contains the scheme of the automatically generated clientset. +package scheme diff --git a/components/event-bus/generated/ea/clientset/versioned/scheme/register.go b/components/event-bus/generated/ea/clientset/versioned/scheme/register.go new file mode 100644 index 000000000000..7e767a37fb3f --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/scheme/register.go @@ -0,0 +1,38 @@ +// Code generated by client-gen. DO NOT EDIT. + +package scheme + +import ( + remoteenvironmentv1alpha1 "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var Scheme = runtime.NewScheme() +var Codecs = serializer.NewCodecFactory(Scheme) +var ParameterCodec = runtime.NewParameterCodec(Scheme) + +func init() { + v1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"}) + AddToScheme(Scheme) +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +func AddToScheme(scheme *runtime.Scheme) { + remoteenvironmentv1alpha1.AddToScheme(scheme) +} diff --git a/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/doc.go b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/doc.go new file mode 100644 index 000000000000..93a7ca4e0e2b --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated typed clients. +package v1alpha1 diff --git a/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/eventactivation.go b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/eventactivation.go new file mode 100644 index 000000000000..105e92d032e5 --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/eventactivation.go @@ -0,0 +1,141 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + scheme "github.com/kyma-project/kyma/components/event-bus/generated/ea/clientset/versioned/scheme" + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// EventActivationsGetter has a method to return a EventActivationInterface. +// A group's client should implement this interface. +type EventActivationsGetter interface { + EventActivations(namespace string) EventActivationInterface +} + +// EventActivationInterface has methods to work with EventActivation resources. +type EventActivationInterface interface { + Create(*v1alpha1.EventActivation) (*v1alpha1.EventActivation, error) + Update(*v1alpha1.EventActivation) (*v1alpha1.EventActivation, error) + Delete(name string, options *v1.DeleteOptions) error + DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error + Get(name string, options v1.GetOptions) (*v1alpha1.EventActivation, error) + List(opts v1.ListOptions) (*v1alpha1.EventActivationList, error) + Watch(opts v1.ListOptions) (watch.Interface, error) + Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.EventActivation, err error) + EventActivationExpansion +} + +// eventActivations implements EventActivationInterface +type eventActivations struct { + client rest.Interface + ns string +} + +// newEventActivations returns a EventActivations +func newEventActivations(c *RemoteenvironmentV1alpha1Client, namespace string) *eventActivations { + return &eventActivations{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the eventActivation, and returns the corresponding eventActivation object, and an error if there is any. +func (c *eventActivations) Get(name string, options v1.GetOptions) (result *v1alpha1.EventActivation, err error) { + result = &v1alpha1.EventActivation{} + err = c.client.Get(). + Namespace(c.ns). + Resource("eventactivations"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of EventActivations that match those selectors. +func (c *eventActivations) List(opts v1.ListOptions) (result *v1alpha1.EventActivationList, err error) { + result = &v1alpha1.EventActivationList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("eventactivations"). + VersionedParams(&opts, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested eventActivations. +func (c *eventActivations) Watch(opts v1.ListOptions) (watch.Interface, error) { + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("eventactivations"). + VersionedParams(&opts, scheme.ParameterCodec). + Watch() +} + +// Create takes the representation of a eventActivation and creates it. Returns the server's representation of the eventActivation, and an error, if there is any. +func (c *eventActivations) Create(eventActivation *v1alpha1.EventActivation) (result *v1alpha1.EventActivation, err error) { + result = &v1alpha1.EventActivation{} + err = c.client.Post(). + Namespace(c.ns). + Resource("eventactivations"). + Body(eventActivation). + Do(). + Into(result) + return +} + +// Update takes the representation of a eventActivation and updates it. Returns the server's representation of the eventActivation, and an error, if there is any. +func (c *eventActivations) Update(eventActivation *v1alpha1.EventActivation) (result *v1alpha1.EventActivation, err error) { + result = &v1alpha1.EventActivation{} + err = c.client.Put(). + Namespace(c.ns). + Resource("eventactivations"). + Name(eventActivation.Name). + Body(eventActivation). + Do(). + Into(result) + return +} + +// Delete takes name of the eventActivation and deletes it. Returns an error if one occurs. +func (c *eventActivations) Delete(name string, options *v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("eventactivations"). + Name(name). + Body(options). + Do(). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *eventActivations) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("eventactivations"). + VersionedParams(&listOptions, scheme.ParameterCodec). + Body(options). + Do(). + Error() +} + +// Patch applies the patch and returns the patched eventActivation. +func (c *eventActivations) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.EventActivation, err error) { + result = &v1alpha1.EventActivation{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("eventactivations"). + SubResource(subresources...). + Name(name). + Body(data). + Do(). + Into(result) + return +} diff --git a/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/fake/doc.go b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/fake/doc.go new file mode 100644 index 000000000000..2b5ba4c8e442 --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/fake/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// Package fake has the automatically generated clients. +package fake diff --git a/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/fake/fake_eventactivation.go b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/fake/fake_eventactivation.go new file mode 100644 index 000000000000..76d50acc77b1 --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/fake/fake_eventactivation.go @@ -0,0 +1,112 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeEventActivations implements EventActivationInterface +type FakeEventActivations struct { + Fake *FakeRemoteenvironmentV1alpha1 + ns string +} + +var eventactivationsResource = schema.GroupVersionResource{Group: "remoteenvironment.kyma.cx", Version: "v1alpha1", Resource: "eventactivations"} + +var eventactivationsKind = schema.GroupVersionKind{Group: "remoteenvironment.kyma.cx", Version: "v1alpha1", Kind: "EventActivation"} + +// Get takes name of the eventActivation, and returns the corresponding eventActivation object, and an error if there is any. +func (c *FakeEventActivations) Get(name string, options v1.GetOptions) (result *v1alpha1.EventActivation, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(eventactivationsResource, c.ns, name), &v1alpha1.EventActivation{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.EventActivation), err +} + +// List takes label and field selectors, and returns the list of EventActivations that match those selectors. +func (c *FakeEventActivations) List(opts v1.ListOptions) (result *v1alpha1.EventActivationList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(eventactivationsResource, eventactivationsKind, c.ns, opts), &v1alpha1.EventActivationList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha1.EventActivationList{} + for _, item := range obj.(*v1alpha1.EventActivationList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested eventActivations. +func (c *FakeEventActivations) Watch(opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(eventactivationsResource, c.ns, opts)) + +} + +// Create takes the representation of a eventActivation and creates it. Returns the server's representation of the eventActivation, and an error, if there is any. +func (c *FakeEventActivations) Create(eventActivation *v1alpha1.EventActivation) (result *v1alpha1.EventActivation, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(eventactivationsResource, c.ns, eventActivation), &v1alpha1.EventActivation{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.EventActivation), err +} + +// Update takes the representation of a eventActivation and updates it. Returns the server's representation of the eventActivation, and an error, if there is any. +func (c *FakeEventActivations) Update(eventActivation *v1alpha1.EventActivation) (result *v1alpha1.EventActivation, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(eventactivationsResource, c.ns, eventActivation), &v1alpha1.EventActivation{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.EventActivation), err +} + +// Delete takes name of the eventActivation and deletes it. Returns an error if one occurs. +func (c *FakeEventActivations) Delete(name string, options *v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(eventactivationsResource, c.ns, name), &v1alpha1.EventActivation{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeEventActivations) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(eventactivationsResource, c.ns, listOptions) + + _, err := c.Fake.Invokes(action, &v1alpha1.EventActivationList{}) + return err +} + +// Patch applies the patch and returns the patched eventActivation. +func (c *FakeEventActivations) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.EventActivation, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(eventactivationsResource, c.ns, name, data, subresources...), &v1alpha1.EventActivation{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.EventActivation), err +} diff --git a/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/fake/fake_remoteenvironment.kyma.cx_client.go b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/fake/fake_remoteenvironment.kyma.cx_client.go new file mode 100644 index 000000000000..7da335601b90 --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/fake/fake_remoteenvironment.kyma.cx_client.go @@ -0,0 +1,24 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1" + rest "k8s.io/client-go/rest" + testing "k8s.io/client-go/testing" +) + +type FakeRemoteenvironmentV1alpha1 struct { + *testing.Fake +} + +func (c *FakeRemoteenvironmentV1alpha1) EventActivations(namespace string) v1alpha1.EventActivationInterface { + return &FakeEventActivations{c, namespace} +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *FakeRemoteenvironmentV1alpha1) RESTClient() rest.Interface { + var ret *rest.RESTClient + return ret +} diff --git a/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/generated_expansion.go b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/generated_expansion.go new file mode 100644 index 000000000000..bd24a79751dd --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/generated_expansion.go @@ -0,0 +1,5 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +type EventActivationExpansion interface{} diff --git a/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/remoteenvironment.kyma.cx_client.go b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/remoteenvironment.kyma.cx_client.go new file mode 100644 index 000000000000..4b27b56f34a4 --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1/remoteenvironment.kyma.cx_client.go @@ -0,0 +1,74 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "github.com/kyma-project/kyma/components/event-bus/generated/ea/clientset/versioned/scheme" + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + rest "k8s.io/client-go/rest" +) + +type RemoteenvironmentV1alpha1Interface interface { + RESTClient() rest.Interface + EventActivationsGetter +} + +// RemoteenvironmentV1alpha1Client is used to interact with features provided by the remoteenvironment.kyma.cx group. +type RemoteenvironmentV1alpha1Client struct { + restClient rest.Interface +} + +func (c *RemoteenvironmentV1alpha1Client) EventActivations(namespace string) EventActivationInterface { + return newEventActivations(c, namespace) +} + +// NewForConfig creates a new RemoteenvironmentV1alpha1Client for the given config. +func NewForConfig(c *rest.Config) (*RemoteenvironmentV1alpha1Client, error) { + config := *c + if err := setConfigDefaults(&config); err != nil { + return nil, err + } + client, err := rest.RESTClientFor(&config) + if err != nil { + return nil, err + } + return &RemoteenvironmentV1alpha1Client{client}, nil +} + +// NewForConfigOrDie creates a new RemoteenvironmentV1alpha1Client for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *RemoteenvironmentV1alpha1Client { + client, err := NewForConfig(c) + if err != nil { + panic(err) + } + return client +} + +// New creates a new RemoteenvironmentV1alpha1Client for the given RESTClient. +func New(c rest.Interface) *RemoteenvironmentV1alpha1Client { + return &RemoteenvironmentV1alpha1Client{c} +} + +func setConfigDefaults(config *rest.Config) error { + gv := v1alpha1.SchemeGroupVersion + config.GroupVersion = &gv + config.APIPath = "/apis" + config.NegotiatedSerializer = serializer.DirectCodecFactory{CodecFactory: scheme.Codecs} + + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } + + return nil +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *RemoteenvironmentV1alpha1Client) RESTClient() rest.Interface { + if c == nil { + return nil + } + return c.restClient +} diff --git a/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/doc.go b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/doc.go new file mode 100644 index 000000000000..592392b59c59 --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/doc.go @@ -0,0 +1,2 @@ +// This package has the automatically generated typed clients. +package v1alpha1 diff --git a/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/eventactivation.go b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/eventactivation.go new file mode 100644 index 000000000000..fa290661344a --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/eventactivation.go @@ -0,0 +1,139 @@ +package v1alpha1 + +import ( + scheme "github.com/kyma-project/kyma/components/event-bus/generated/ea/clientset/versioned/scheme" + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// EventActivationsGetter has a method to return a EventActivationInterface. +// A group's client should implement this interface. +type EventActivationsGetter interface { + EventActivations(namespace string) EventActivationInterface +} + +// EventActivationInterface has methods to work with EventActivation resources. +type EventActivationInterface interface { + Create(*v1alpha1.EventActivation) (*v1alpha1.EventActivation, error) + Update(*v1alpha1.EventActivation) (*v1alpha1.EventActivation, error) + Delete(name string, options *v1.DeleteOptions) error + DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error + Get(name string, options v1.GetOptions) (*v1alpha1.EventActivation, error) + List(opts v1.ListOptions) (*v1alpha1.EventActivationList, error) + Watch(opts v1.ListOptions) (watch.Interface, error) + Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.EventActivation, err error) + EventActivationExpansion +} + +// eventActivations implements EventActivationInterface +type eventActivations struct { + client rest.Interface + ns string +} + +// newEventActivations returns a EventActivations +func newEventActivations(c *RemoteenvironmentV1alpha1Client, namespace string) *eventActivations { + return &eventActivations{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the eventActivation, and returns the corresponding eventActivation object, and an error if there is any. +func (c *eventActivations) Get(name string, options v1.GetOptions) (result *v1alpha1.EventActivation, err error) { + result = &v1alpha1.EventActivation{} + err = c.client.Get(). + Namespace(c.ns). + Resource("eventactivations"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of EventActivations that match those selectors. +func (c *eventActivations) List(opts v1.ListOptions) (result *v1alpha1.EventActivationList, err error) { + result = &v1alpha1.EventActivationList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("eventactivations"). + VersionedParams(&opts, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested eventActivations. +func (c *eventActivations) Watch(opts v1.ListOptions) (watch.Interface, error) { + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("eventactivations"). + VersionedParams(&opts, scheme.ParameterCodec). + Watch() +} + +// Create takes the representation of a eventActivation and creates it. Returns the server's representation of the eventActivation, and an error, if there is any. +func (c *eventActivations) Create(eventActivation *v1alpha1.EventActivation) (result *v1alpha1.EventActivation, err error) { + result = &v1alpha1.EventActivation{} + err = c.client.Post(). + Namespace(c.ns). + Resource("eventactivations"). + Body(eventActivation). + Do(). + Into(result) + return +} + +// Update takes the representation of a eventActivation and updates it. Returns the server's representation of the eventActivation, and an error, if there is any. +func (c *eventActivations) Update(eventActivation *v1alpha1.EventActivation) (result *v1alpha1.EventActivation, err error) { + result = &v1alpha1.EventActivation{} + err = c.client.Put(). + Namespace(c.ns). + Resource("eventactivations"). + Name(eventActivation.Name). + Body(eventActivation). + Do(). + Into(result) + return +} + +// Delete takes name of the eventActivation and deletes it. Returns an error if one occurs. +func (c *eventActivations) Delete(name string, options *v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("eventactivations"). + Name(name). + Body(options). + Do(). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *eventActivations) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("eventactivations"). + VersionedParams(&listOptions, scheme.ParameterCodec). + Body(options). + Do(). + Error() +} + +// Patch applies the patch and returns the patched eventActivation. +func (c *eventActivations) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.EventActivation, err error) { + result = &v1alpha1.EventActivation{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("eventactivations"). + SubResource(subresources...). + Name(name). + Body(data). + Do(). + Into(result) + return +} diff --git a/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/fake/doc.go b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/fake/doc.go new file mode 100644 index 000000000000..798b81175562 --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/fake/doc.go @@ -0,0 +1,2 @@ +// Package fake has the automatically generated clients. +package fake diff --git a/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/fake/fake_eventactivation.go b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/fake/fake_eventactivation.go new file mode 100644 index 000000000000..ddecf0aad2b7 --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/fake/fake_eventactivation.go @@ -0,0 +1,110 @@ +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeEventActivations implements EventActivationInterface +type FakeEventActivations struct { + Fake *FakeRemoteenvironmentV1alpha1 + ns string +} + +var eventactivationsResource = schema.GroupVersionResource{Group: "remoteenvironment.kyma.cx", Version: "v1alpha1", Resource: "eventactivations"} + +var eventactivationsKind = schema.GroupVersionKind{Group: "remoteenvironment.kyma.cx", Version: "v1alpha1", Kind: "EventActivation"} + +// Get takes name of the eventActivation, and returns the corresponding eventActivation object, and an error if there is any. +func (c *FakeEventActivations) Get(name string, options v1.GetOptions) (result *v1alpha1.EventActivation, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(eventactivationsResource, c.ns, name), &v1alpha1.EventActivation{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.EventActivation), err +} + +// List takes label and field selectors, and returns the list of EventActivations that match those selectors. +func (c *FakeEventActivations) List(opts v1.ListOptions) (result *v1alpha1.EventActivationList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(eventactivationsResource, eventactivationsKind, c.ns, opts), &v1alpha1.EventActivationList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha1.EventActivationList{} + for _, item := range obj.(*v1alpha1.EventActivationList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested eventActivations. +func (c *FakeEventActivations) Watch(opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(eventactivationsResource, c.ns, opts)) + +} + +// Create takes the representation of a eventActivation and creates it. Returns the server's representation of the eventActivation, and an error, if there is any. +func (c *FakeEventActivations) Create(eventActivation *v1alpha1.EventActivation) (result *v1alpha1.EventActivation, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(eventactivationsResource, c.ns, eventActivation), &v1alpha1.EventActivation{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.EventActivation), err +} + +// Update takes the representation of a eventActivation and updates it. Returns the server's representation of the eventActivation, and an error, if there is any. +func (c *FakeEventActivations) Update(eventActivation *v1alpha1.EventActivation) (result *v1alpha1.EventActivation, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(eventactivationsResource, c.ns, eventActivation), &v1alpha1.EventActivation{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.EventActivation), err +} + +// Delete takes name of the eventActivation and deletes it. Returns an error if one occurs. +func (c *FakeEventActivations) Delete(name string, options *v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(eventactivationsResource, c.ns, name), &v1alpha1.EventActivation{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeEventActivations) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(eventactivationsResource, c.ns, listOptions) + + _, err := c.Fake.Invokes(action, &v1alpha1.EventActivationList{}) + return err +} + +// Patch applies the patch and returns the patched eventActivation. +func (c *FakeEventActivations) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.EventActivation, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(eventactivationsResource, c.ns, name, data, subresources...), &v1alpha1.EventActivation{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.EventActivation), err +} diff --git a/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/fake/fake_remoteenvironment.kyma.io_client.go b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/fake/fake_remoteenvironment.kyma.io_client.go new file mode 100644 index 000000000000..5eb90377bbba --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/fake/fake_remoteenvironment.kyma.io_client.go @@ -0,0 +1,22 @@ +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1" + rest "k8s.io/client-go/rest" + testing "k8s.io/client-go/testing" +) + +type FakeRemoteenvironmentV1alpha1 struct { + *testing.Fake +} + +func (c *FakeRemoteenvironmentV1alpha1) EventActivations(namespace string) v1alpha1.EventActivationInterface { + return &FakeEventActivations{c, namespace} +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *FakeRemoteenvironmentV1alpha1) RESTClient() rest.Interface { + var ret *rest.RESTClient + return ret +} diff --git a/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/generated_expansion.go b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/generated_expansion.go new file mode 100644 index 000000000000..ef961872cca6 --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/generated_expansion.go @@ -0,0 +1,3 @@ +package v1alpha1 + +type EventActivationExpansion interface{} diff --git a/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/remoteenvironment.kyma.io_client.go b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/remoteenvironment.kyma.io_client.go new file mode 100644 index 000000000000..f3b5079d916d --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.io/v1alpha1/remoteenvironment.kyma.io_client.go @@ -0,0 +1,72 @@ +package v1alpha1 + +import ( + "github.com/kyma-project/kyma/components/event-bus/generated/ea/clientset/versioned/scheme" + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + rest "k8s.io/client-go/rest" +) + +type RemoteenvironmentV1alpha1Interface interface { + RESTClient() rest.Interface + EventActivationsGetter +} + +// RemoteenvironmentV1alpha1Client is used to interact with features provided by the remoteenvironment.kyma.cx group. +type RemoteenvironmentV1alpha1Client struct { + restClient rest.Interface +} + +func (c *RemoteenvironmentV1alpha1Client) EventActivations(namespace string) EventActivationInterface { + return newEventActivations(c, namespace) +} + +// NewForConfig creates a new RemoteenvironmentV1alpha1Client for the given config. +func NewForConfig(c *rest.Config) (*RemoteenvironmentV1alpha1Client, error) { + config := *c + if err := setConfigDefaults(&config); err != nil { + return nil, err + } + client, err := rest.RESTClientFor(&config) + if err != nil { + return nil, err + } + return &RemoteenvironmentV1alpha1Client{client}, nil +} + +// NewForConfigOrDie creates a new RemoteenvironmentV1alpha1Client for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *RemoteenvironmentV1alpha1Client { + client, err := NewForConfig(c) + if err != nil { + panic(err) + } + return client +} + +// New creates a new RemoteenvironmentV1alpha1Client for the given RESTClient. +func New(c rest.Interface) *RemoteenvironmentV1alpha1Client { + return &RemoteenvironmentV1alpha1Client{c} +} + +func setConfigDefaults(config *rest.Config) error { + gv := v1alpha1.SchemeGroupVersion + config.GroupVersion = &gv + config.APIPath = "/apis" + config.NegotiatedSerializer = serializer.DirectCodecFactory{CodecFactory: scheme.Codecs} + + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } + + return nil +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *RemoteenvironmentV1alpha1Client) RESTClient() rest.Interface { + if c == nil { + return nil + } + return c.restClient +} diff --git a/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/doc.go b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/doc.go new file mode 100644 index 000000000000..592392b59c59 --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/doc.go @@ -0,0 +1,2 @@ +// This package has the automatically generated typed clients. +package v1alpha1 diff --git a/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/eventactivation.go b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/eventactivation.go new file mode 100644 index 000000000000..fa290661344a --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/eventactivation.go @@ -0,0 +1,139 @@ +package v1alpha1 + +import ( + scheme "github.com/kyma-project/kyma/components/event-bus/generated/ea/clientset/versioned/scheme" + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// EventActivationsGetter has a method to return a EventActivationInterface. +// A group's client should implement this interface. +type EventActivationsGetter interface { + EventActivations(namespace string) EventActivationInterface +} + +// EventActivationInterface has methods to work with EventActivation resources. +type EventActivationInterface interface { + Create(*v1alpha1.EventActivation) (*v1alpha1.EventActivation, error) + Update(*v1alpha1.EventActivation) (*v1alpha1.EventActivation, error) + Delete(name string, options *v1.DeleteOptions) error + DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error + Get(name string, options v1.GetOptions) (*v1alpha1.EventActivation, error) + List(opts v1.ListOptions) (*v1alpha1.EventActivationList, error) + Watch(opts v1.ListOptions) (watch.Interface, error) + Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.EventActivation, err error) + EventActivationExpansion +} + +// eventActivations implements EventActivationInterface +type eventActivations struct { + client rest.Interface + ns string +} + +// newEventActivations returns a EventActivations +func newEventActivations(c *RemoteenvironmentV1alpha1Client, namespace string) *eventActivations { + return &eventActivations{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the eventActivation, and returns the corresponding eventActivation object, and an error if there is any. +func (c *eventActivations) Get(name string, options v1.GetOptions) (result *v1alpha1.EventActivation, err error) { + result = &v1alpha1.EventActivation{} + err = c.client.Get(). + Namespace(c.ns). + Resource("eventactivations"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of EventActivations that match those selectors. +func (c *eventActivations) List(opts v1.ListOptions) (result *v1alpha1.EventActivationList, err error) { + result = &v1alpha1.EventActivationList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("eventactivations"). + VersionedParams(&opts, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested eventActivations. +func (c *eventActivations) Watch(opts v1.ListOptions) (watch.Interface, error) { + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("eventactivations"). + VersionedParams(&opts, scheme.ParameterCodec). + Watch() +} + +// Create takes the representation of a eventActivation and creates it. Returns the server's representation of the eventActivation, and an error, if there is any. +func (c *eventActivations) Create(eventActivation *v1alpha1.EventActivation) (result *v1alpha1.EventActivation, err error) { + result = &v1alpha1.EventActivation{} + err = c.client.Post(). + Namespace(c.ns). + Resource("eventactivations"). + Body(eventActivation). + Do(). + Into(result) + return +} + +// Update takes the representation of a eventActivation and updates it. Returns the server's representation of the eventActivation, and an error, if there is any. +func (c *eventActivations) Update(eventActivation *v1alpha1.EventActivation) (result *v1alpha1.EventActivation, err error) { + result = &v1alpha1.EventActivation{} + err = c.client.Put(). + Namespace(c.ns). + Resource("eventactivations"). + Name(eventActivation.Name). + Body(eventActivation). + Do(). + Into(result) + return +} + +// Delete takes name of the eventActivation and deletes it. Returns an error if one occurs. +func (c *eventActivations) Delete(name string, options *v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("eventactivations"). + Name(name). + Body(options). + Do(). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *eventActivations) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("eventactivations"). + VersionedParams(&listOptions, scheme.ParameterCodec). + Body(options). + Do(). + Error() +} + +// Patch applies the patch and returns the patched eventActivation. +func (c *eventActivations) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.EventActivation, err error) { + result = &v1alpha1.EventActivation{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("eventactivations"). + SubResource(subresources...). + Name(name). + Body(data). + Do(). + Into(result) + return +} diff --git a/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/fake/doc.go b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/fake/doc.go new file mode 100644 index 000000000000..798b81175562 --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/fake/doc.go @@ -0,0 +1,2 @@ +// Package fake has the automatically generated clients. +package fake diff --git a/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/fake/fake_eventactivation.go b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/fake/fake_eventactivation.go new file mode 100644 index 000000000000..ddecf0aad2b7 --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/fake/fake_eventactivation.go @@ -0,0 +1,110 @@ +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeEventActivations implements EventActivationInterface +type FakeEventActivations struct { + Fake *FakeRemoteenvironmentV1alpha1 + ns string +} + +var eventactivationsResource = schema.GroupVersionResource{Group: "remoteenvironment.kyma.cx", Version: "v1alpha1", Resource: "eventactivations"} + +var eventactivationsKind = schema.GroupVersionKind{Group: "remoteenvironment.kyma.cx", Version: "v1alpha1", Kind: "EventActivation"} + +// Get takes name of the eventActivation, and returns the corresponding eventActivation object, and an error if there is any. +func (c *FakeEventActivations) Get(name string, options v1.GetOptions) (result *v1alpha1.EventActivation, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(eventactivationsResource, c.ns, name), &v1alpha1.EventActivation{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.EventActivation), err +} + +// List takes label and field selectors, and returns the list of EventActivations that match those selectors. +func (c *FakeEventActivations) List(opts v1.ListOptions) (result *v1alpha1.EventActivationList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(eventactivationsResource, eventactivationsKind, c.ns, opts), &v1alpha1.EventActivationList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha1.EventActivationList{} + for _, item := range obj.(*v1alpha1.EventActivationList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested eventActivations. +func (c *FakeEventActivations) Watch(opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(eventactivationsResource, c.ns, opts)) + +} + +// Create takes the representation of a eventActivation and creates it. Returns the server's representation of the eventActivation, and an error, if there is any. +func (c *FakeEventActivations) Create(eventActivation *v1alpha1.EventActivation) (result *v1alpha1.EventActivation, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(eventactivationsResource, c.ns, eventActivation), &v1alpha1.EventActivation{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.EventActivation), err +} + +// Update takes the representation of a eventActivation and updates it. Returns the server's representation of the eventActivation, and an error, if there is any. +func (c *FakeEventActivations) Update(eventActivation *v1alpha1.EventActivation) (result *v1alpha1.EventActivation, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(eventactivationsResource, c.ns, eventActivation), &v1alpha1.EventActivation{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.EventActivation), err +} + +// Delete takes name of the eventActivation and deletes it. Returns an error if one occurs. +func (c *FakeEventActivations) Delete(name string, options *v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(eventactivationsResource, c.ns, name), &v1alpha1.EventActivation{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeEventActivations) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(eventactivationsResource, c.ns, listOptions) + + _, err := c.Fake.Invokes(action, &v1alpha1.EventActivationList{}) + return err +} + +// Patch applies the patch and returns the patched eventActivation. +func (c *FakeEventActivations) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.EventActivation, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(eventactivationsResource, c.ns, name, data, subresources...), &v1alpha1.EventActivation{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.EventActivation), err +} diff --git a/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/fake/fake_remoteenvironment.ysf.io_client.go b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/fake/fake_remoteenvironment.ysf.io_client.go new file mode 100644 index 000000000000..5eb90377bbba --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/fake/fake_remoteenvironment.ysf.io_client.go @@ -0,0 +1,22 @@ +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.kyma.cx/v1alpha1" + rest "k8s.io/client-go/rest" + testing "k8s.io/client-go/testing" +) + +type FakeRemoteenvironmentV1alpha1 struct { + *testing.Fake +} + +func (c *FakeRemoteenvironmentV1alpha1) EventActivations(namespace string) v1alpha1.EventActivationInterface { + return &FakeEventActivations{c, namespace} +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *FakeRemoteenvironmentV1alpha1) RESTClient() rest.Interface { + var ret *rest.RESTClient + return ret +} diff --git a/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/generated_expansion.go b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/generated_expansion.go new file mode 100644 index 000000000000..ef961872cca6 --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/generated_expansion.go @@ -0,0 +1,3 @@ +package v1alpha1 + +type EventActivationExpansion interface{} diff --git a/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/remoteenvironment.ysf.io_client.go b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/remoteenvironment.ysf.io_client.go new file mode 100644 index 000000000000..f3b5079d916d --- /dev/null +++ b/components/event-bus/generated/ea/clientset/versioned/typed/remoteenvironment.ysf.io/v1alpha1/remoteenvironment.ysf.io_client.go @@ -0,0 +1,72 @@ +package v1alpha1 + +import ( + "github.com/kyma-project/kyma/components/event-bus/generated/ea/clientset/versioned/scheme" + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + rest "k8s.io/client-go/rest" +) + +type RemoteenvironmentV1alpha1Interface interface { + RESTClient() rest.Interface + EventActivationsGetter +} + +// RemoteenvironmentV1alpha1Client is used to interact with features provided by the remoteenvironment.kyma.cx group. +type RemoteenvironmentV1alpha1Client struct { + restClient rest.Interface +} + +func (c *RemoteenvironmentV1alpha1Client) EventActivations(namespace string) EventActivationInterface { + return newEventActivations(c, namespace) +} + +// NewForConfig creates a new RemoteenvironmentV1alpha1Client for the given config. +func NewForConfig(c *rest.Config) (*RemoteenvironmentV1alpha1Client, error) { + config := *c + if err := setConfigDefaults(&config); err != nil { + return nil, err + } + client, err := rest.RESTClientFor(&config) + if err != nil { + return nil, err + } + return &RemoteenvironmentV1alpha1Client{client}, nil +} + +// NewForConfigOrDie creates a new RemoteenvironmentV1alpha1Client for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *RemoteenvironmentV1alpha1Client { + client, err := NewForConfig(c) + if err != nil { + panic(err) + } + return client +} + +// New creates a new RemoteenvironmentV1alpha1Client for the given RESTClient. +func New(c rest.Interface) *RemoteenvironmentV1alpha1Client { + return &RemoteenvironmentV1alpha1Client{c} +} + +func setConfigDefaults(config *rest.Config) error { + gv := v1alpha1.SchemeGroupVersion + config.GroupVersion = &gv + config.APIPath = "/apis" + config.NegotiatedSerializer = serializer.DirectCodecFactory{CodecFactory: scheme.Codecs} + + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } + + return nil +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *RemoteenvironmentV1alpha1Client) RESTClient() rest.Interface { + if c == nil { + return nil + } + return c.restClient +} diff --git a/components/event-bus/generated/ea/informers/externalversions/factory.go b/components/event-bus/generated/ea/informers/externalversions/factory.go new file mode 100644 index 000000000000..ede240f1d1bf --- /dev/null +++ b/components/event-bus/generated/ea/informers/externalversions/factory.go @@ -0,0 +1,115 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + reflect "reflect" + sync "sync" + time "time" + + versioned "github.com/kyma-project/kyma/components/event-bus/generated/ea/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/event-bus/generated/ea/informers/externalversions/internalinterfaces" + remoteenvironmentkymacx "github.com/kyma-project/kyma/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.cx" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +type sharedInformerFactory struct { + client versioned.Interface + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc + lock sync.Mutex + defaultResync time.Duration + + informers map[reflect.Type]cache.SharedIndexInformer + // startedInformers is used for tracking which informers have been started. + // This allows Start() to be called multiple times safely. + startedInformers map[reflect.Type]bool +} + +// NewSharedInformerFactory constructs a new instance of sharedInformerFactory +func NewSharedInformerFactory(client versioned.Interface, defaultResync time.Duration) SharedInformerFactory { + return NewFilteredSharedInformerFactory(client, defaultResync, v1.NamespaceAll, nil) +} + +// NewFilteredSharedInformerFactory constructs a new instance of sharedInformerFactory. +// Listers obtained via this SharedInformerFactory will be subject to the same filters +// as specified here. +func NewFilteredSharedInformerFactory(client versioned.Interface, defaultResync time.Duration, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) SharedInformerFactory { + return &sharedInformerFactory{ + client: client, + namespace: namespace, + tweakListOptions: tweakListOptions, + defaultResync: defaultResync, + informers: make(map[reflect.Type]cache.SharedIndexInformer), + startedInformers: make(map[reflect.Type]bool), + } +} + +// Start initializes all requested informers. +func (f *sharedInformerFactory) Start(stopCh <-chan struct{}) { + f.lock.Lock() + defer f.lock.Unlock() + + for informerType, informer := range f.informers { + if !f.startedInformers[informerType] { + go informer.Run(stopCh) + f.startedInformers[informerType] = true + } + } +} + +// WaitForCacheSync waits for all started informers' cache were synced. +func (f *sharedInformerFactory) WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool { + informers := func() map[reflect.Type]cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informers := map[reflect.Type]cache.SharedIndexInformer{} + for informerType, informer := range f.informers { + if f.startedInformers[informerType] { + informers[informerType] = informer + } + } + return informers + }() + + res := map[reflect.Type]bool{} + for informType, informer := range informers { + res[informType] = cache.WaitForCacheSync(stopCh, informer.HasSynced) + } + return res +} + +// InternalInformerFor returns the SharedIndexInformer for obj using an internal +// client. +func (f *sharedInformerFactory) InformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informerType := reflect.TypeOf(obj) + informer, exists := f.informers[informerType] + if exists { + return informer + } + informer = newFunc(f.client, f.defaultResync) + f.informers[informerType] = informer + + return informer +} + +// SharedInformerFactory provides shared informers for resources in all known +// API group versions. +type SharedInformerFactory interface { + internalinterfaces.SharedInformerFactory + ForResource(resource schema.GroupVersionResource) (GenericInformer, error) + WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool + + Remoteenvironment() remoteenvironmentkymacx.Interface +} + +func (f *sharedInformerFactory) Remoteenvironment() remoteenvironmentkymacx.Interface { + return remoteenvironmentkymacx.New(f, f.namespace, f.tweakListOptions) +} diff --git a/components/event-bus/generated/ea/informers/externalversions/generic.go b/components/event-bus/generated/ea/informers/externalversions/generic.go new file mode 100644 index 000000000000..ae5a2dd482fe --- /dev/null +++ b/components/event-bus/generated/ea/informers/externalversions/generic.go @@ -0,0 +1,46 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + "fmt" + + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +// GenericInformer is type of SharedIndexInformer which will locate and delegate to other +// sharedInformers based on type +type GenericInformer interface { + Informer() cache.SharedIndexInformer + Lister() cache.GenericLister +} + +type genericInformer struct { + informer cache.SharedIndexInformer + resource schema.GroupResource +} + +// Informer returns the SharedIndexInformer. +func (f *genericInformer) Informer() cache.SharedIndexInformer { + return f.informer +} + +// Lister returns the GenericLister. +func (f *genericInformer) Lister() cache.GenericLister { + return cache.NewGenericLister(f.Informer().GetIndexer(), f.resource) +} + +// ForResource gives generic access to a shared informer of the matching type +// TODO extend this to unknown resources with a client pool +func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { + switch resource { + // Group=remoteenvironment.kyma.cx, Version=v1alpha1 + case v1alpha1.SchemeGroupVersion.WithResource("eventactivations"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Remoteenvironment().V1alpha1().EventActivations().Informer()}, nil + + } + + return nil, fmt.Errorf("no informer found for %v", resource) +} diff --git a/components/event-bus/generated/ea/informers/externalversions/internalinterfaces/factory_interfaces.go b/components/event-bus/generated/ea/informers/externalversions/internalinterfaces/factory_interfaces.go new file mode 100644 index 000000000000..b4a857cd761f --- /dev/null +++ b/components/event-bus/generated/ea/informers/externalversions/internalinterfaces/factory_interfaces.go @@ -0,0 +1,22 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package internalinterfaces + +import ( + time "time" + + versioned "github.com/kyma-project/kyma/components/event-bus/generated/ea/clientset/versioned" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + cache "k8s.io/client-go/tools/cache" +) + +type NewInformerFunc func(versioned.Interface, time.Duration) cache.SharedIndexInformer + +// SharedInformerFactory a small interface to allow for adding an informer without an import cycle +type SharedInformerFactory interface { + Start(stopCh <-chan struct{}) + InformerFor(obj runtime.Object, newFunc NewInformerFunc) cache.SharedIndexInformer +} + +type TweakListOptionsFunc func(*v1.ListOptions) diff --git a/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.cx/interface.go b/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.cx/interface.go new file mode 100644 index 000000000000..4ece2064b86d --- /dev/null +++ b/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.cx/interface.go @@ -0,0 +1,30 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package remoteenvironment + +import ( + internalinterfaces "github.com/kyma-project/kyma/components/event-bus/generated/ea/informers/externalversions/internalinterfaces" + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.cx/v1alpha1" +) + +// Interface provides access to each of this group's versions. +type Interface interface { + // V1alpha1 provides access to shared informers for resources in V1alpha1. + V1alpha1() v1alpha1.Interface +} + +type group struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// V1alpha1 returns a new v1alpha1.Interface. +func (g *group) V1alpha1() v1alpha1.Interface { + return v1alpha1.New(g.factory, g.namespace, g.tweakListOptions) +} diff --git a/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.cx/v1alpha1/eventactivation.go b/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.cx/v1alpha1/eventactivation.go new file mode 100644 index 000000000000..2fa05692e161 --- /dev/null +++ b/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.cx/v1alpha1/eventactivation.go @@ -0,0 +1,73 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + time "time" + + versioned "github.com/kyma-project/kyma/components/event-bus/generated/ea/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/event-bus/generated/ea/informers/externalversions/internalinterfaces" + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/ea/listers/remoteenvironment.kyma.cx/v1alpha1" + remoteenvironmentkymacxv1alpha1 "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// EventActivationInformer provides access to a shared informer and lister for +// EventActivations. +type EventActivationInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha1.EventActivationLister +} + +type eventActivationInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewEventActivationInformer constructs a new informer for EventActivation type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewEventActivationInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredEventActivationInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredEventActivationInformer constructs a new informer for EventActivation type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredEventActivationInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.RemoteenvironmentV1alpha1().EventActivations(namespace).List(options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.RemoteenvironmentV1alpha1().EventActivations(namespace).Watch(options) + }, + }, + &remoteenvironmentkymacxv1alpha1.EventActivation{}, + resyncPeriod, + indexers, + ) +} + +func (f *eventActivationInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredEventActivationInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *eventActivationInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&remoteenvironmentkymacxv1alpha1.EventActivation{}, f.defaultInformer) +} + +func (f *eventActivationInformer) Lister() v1alpha1.EventActivationLister { + return v1alpha1.NewEventActivationLister(f.Informer().GetIndexer()) +} diff --git a/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.cx/v1alpha1/interface.go b/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.cx/v1alpha1/interface.go new file mode 100644 index 000000000000..7327740c58b5 --- /dev/null +++ b/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.cx/v1alpha1/interface.go @@ -0,0 +1,29 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + internalinterfaces "github.com/kyma-project/kyma/components/event-bus/generated/ea/informers/externalversions/internalinterfaces" +) + +// Interface provides access to all the informers in this group version. +type Interface interface { + // EventActivations returns a EventActivationInformer. + EventActivations() EventActivationInformer +} + +type version struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// EventActivations returns a EventActivationInformer. +func (v *version) EventActivations() EventActivationInformer { + return &eventActivationInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} diff --git a/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.io/interface.go b/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.io/interface.go new file mode 100644 index 000000000000..88da0e2716c9 --- /dev/null +++ b/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.io/interface.go @@ -0,0 +1,30 @@ +// This file was automatically generated by informer-gen + +package remoteenvironment + +import ( + internalinterfaces "github.com/kyma-project/kyma/components/event-bus/generated/ea/informers/externalversions/internalinterfaces" + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.cx/v1alpha1" +) + +// Interface provides access to each of this group's versions. +type Interface interface { + // V1alpha1 provides access to shared informers for resources in V1alpha1. + V1alpha1() v1alpha1.Interface +} + +type group struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// V1alpha1 returns a new v1alpha1.Interface. +func (g *group) V1alpha1() v1alpha1.Interface { + return v1alpha1.New(g.factory, g.namespace, g.tweakListOptions) +} diff --git a/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.io/v1alpha1/eventactivation.go b/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.io/v1alpha1/eventactivation.go new file mode 100644 index 000000000000..dfa1b858f4e5 --- /dev/null +++ b/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.io/v1alpha1/eventactivation.go @@ -0,0 +1,73 @@ +// This file was automatically generated by informer-gen + +package v1alpha1 + +import ( + time "time" + + versioned "github.com/kyma-project/kyma/components/event-bus/generated/ea/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/event-bus/generated/ea/informers/externalversions/internalinterfaces" + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/ea/listers/remoteenvironment.kyma.cx/v1alpha1" + remoteenvironment_kyma_io_v1alpha1 "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// EventActivationInformer provides access to a shared informer and lister for +// EventActivations. +type EventActivationInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha1.EventActivationLister +} + +type eventActivationInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewEventActivationInformer constructs a new informer for EventActivation type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewEventActivationInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredEventActivationInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredEventActivationInformer constructs a new informer for EventActivation type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredEventActivationInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.RemoteenvironmentV1alpha1().EventActivations(namespace).List(options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.RemoteenvironmentV1alpha1().EventActivations(namespace).Watch(options) + }, + }, + &remoteenvironment_kyma_io_v1alpha1.EventActivation{}, + resyncPeriod, + indexers, + ) +} + +func (f *eventActivationInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredEventActivationInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *eventActivationInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&remoteenvironment_kyma_io_v1alpha1.EventActivation{}, f.defaultInformer) +} + +func (f *eventActivationInformer) Lister() v1alpha1.EventActivationLister { + return v1alpha1.NewEventActivationLister(f.Informer().GetIndexer()) +} diff --git a/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.io/v1alpha1/interface.go b/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.io/v1alpha1/interface.go new file mode 100644 index 000000000000..3d5519b967d0 --- /dev/null +++ b/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.io/v1alpha1/interface.go @@ -0,0 +1,29 @@ +// This file was automatically generated by informer-gen + +package v1alpha1 + +import ( + internalinterfaces "github.com/kyma-project/kyma/components/event-bus/generated/ea/informers/externalversions/internalinterfaces" +) + +// Interface provides access to all the informers in this group version. +type Interface interface { + // EventActivations returns a EventActivationInformer. + EventActivations() EventActivationInformer +} + +type version struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// EventActivations returns a EventActivationInformer. +func (v *version) EventActivations() EventActivationInformer { + return &eventActivationInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} diff --git a/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.ysf.io/interface.go b/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.ysf.io/interface.go new file mode 100644 index 000000000000..88da0e2716c9 --- /dev/null +++ b/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.ysf.io/interface.go @@ -0,0 +1,30 @@ +// This file was automatically generated by informer-gen + +package remoteenvironment + +import ( + internalinterfaces "github.com/kyma-project/kyma/components/event-bus/generated/ea/informers/externalversions/internalinterfaces" + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.cx/v1alpha1" +) + +// Interface provides access to each of this group's versions. +type Interface interface { + // V1alpha1 provides access to shared informers for resources in V1alpha1. + V1alpha1() v1alpha1.Interface +} + +type group struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// V1alpha1 returns a new v1alpha1.Interface. +func (g *group) V1alpha1() v1alpha1.Interface { + return v1alpha1.New(g.factory, g.namespace, g.tweakListOptions) +} diff --git a/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.ysf.io/v1alpha1/eventactivation.go b/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.ysf.io/v1alpha1/eventactivation.go new file mode 100644 index 000000000000..dfa1b858f4e5 --- /dev/null +++ b/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.ysf.io/v1alpha1/eventactivation.go @@ -0,0 +1,73 @@ +// This file was automatically generated by informer-gen + +package v1alpha1 + +import ( + time "time" + + versioned "github.com/kyma-project/kyma/components/event-bus/generated/ea/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/event-bus/generated/ea/informers/externalversions/internalinterfaces" + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/ea/listers/remoteenvironment.kyma.cx/v1alpha1" + remoteenvironment_kyma_io_v1alpha1 "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// EventActivationInformer provides access to a shared informer and lister for +// EventActivations. +type EventActivationInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha1.EventActivationLister +} + +type eventActivationInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewEventActivationInformer constructs a new informer for EventActivation type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewEventActivationInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredEventActivationInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredEventActivationInformer constructs a new informer for EventActivation type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredEventActivationInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.RemoteenvironmentV1alpha1().EventActivations(namespace).List(options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.RemoteenvironmentV1alpha1().EventActivations(namespace).Watch(options) + }, + }, + &remoteenvironment_kyma_io_v1alpha1.EventActivation{}, + resyncPeriod, + indexers, + ) +} + +func (f *eventActivationInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredEventActivationInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *eventActivationInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&remoteenvironment_kyma_io_v1alpha1.EventActivation{}, f.defaultInformer) +} + +func (f *eventActivationInformer) Lister() v1alpha1.EventActivationLister { + return v1alpha1.NewEventActivationLister(f.Informer().GetIndexer()) +} diff --git a/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.ysf.io/v1alpha1/interface.go b/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.ysf.io/v1alpha1/interface.go new file mode 100644 index 000000000000..3d5519b967d0 --- /dev/null +++ b/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.ysf.io/v1alpha1/interface.go @@ -0,0 +1,29 @@ +// This file was automatically generated by informer-gen + +package v1alpha1 + +import ( + internalinterfaces "github.com/kyma-project/kyma/components/event-bus/generated/ea/informers/externalversions/internalinterfaces" +) + +// Interface provides access to all the informers in this group version. +type Interface interface { + // EventActivations returns a EventActivationInformer. + EventActivations() EventActivationInformer +} + +type version struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// EventActivations returns a EventActivationInformer. +func (v *version) EventActivations() EventActivationInformer { + return &eventActivationInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} diff --git a/components/event-bus/generated/ea/listers/remoteenvironment.kyma.cx/v1alpha1/eventactivation.go b/components/event-bus/generated/ea/listers/remoteenvironment.kyma.cx/v1alpha1/eventactivation.go new file mode 100644 index 000000000000..7d05553a5e13 --- /dev/null +++ b/components/event-bus/generated/ea/listers/remoteenvironment.kyma.cx/v1alpha1/eventactivation.go @@ -0,0 +1,78 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// EventActivationLister helps list EventActivations. +type EventActivationLister interface { + // List lists all EventActivations in the indexer. + List(selector labels.Selector) (ret []*v1alpha1.EventActivation, err error) + // EventActivations returns an object that can list and get EventActivations. + EventActivations(namespace string) EventActivationNamespaceLister + EventActivationListerExpansion +} + +// eventActivationLister implements the EventActivationLister interface. +type eventActivationLister struct { + indexer cache.Indexer +} + +// NewEventActivationLister returns a new EventActivationLister. +func NewEventActivationLister(indexer cache.Indexer) EventActivationLister { + return &eventActivationLister{indexer: indexer} +} + +// List lists all EventActivations in the indexer. +func (s *eventActivationLister) List(selector labels.Selector) (ret []*v1alpha1.EventActivation, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.EventActivation)) + }) + return ret, err +} + +// EventActivations returns an object that can list and get EventActivations. +func (s *eventActivationLister) EventActivations(namespace string) EventActivationNamespaceLister { + return eventActivationNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// EventActivationNamespaceLister helps list and get EventActivations. +type EventActivationNamespaceLister interface { + // List lists all EventActivations in the indexer for a given namespace. + List(selector labels.Selector) (ret []*v1alpha1.EventActivation, err error) + // Get retrieves the EventActivation from the indexer for a given namespace and name. + Get(name string) (*v1alpha1.EventActivation, error) + EventActivationNamespaceListerExpansion +} + +// eventActivationNamespaceLister implements the EventActivationNamespaceLister +// interface. +type eventActivationNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all EventActivations in the indexer for a given namespace. +func (s eventActivationNamespaceLister) List(selector labels.Selector) (ret []*v1alpha1.EventActivation, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.EventActivation)) + }) + return ret, err +} + +// Get retrieves the EventActivation from the indexer for a given namespace and name. +func (s eventActivationNamespaceLister) Get(name string) (*v1alpha1.EventActivation, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha1.Resource("eventactivation"), name) + } + return obj.(*v1alpha1.EventActivation), nil +} diff --git a/components/event-bus/generated/ea/listers/remoteenvironment.kyma.cx/v1alpha1/expansion_generated.go b/components/event-bus/generated/ea/listers/remoteenvironment.kyma.cx/v1alpha1/expansion_generated.go new file mode 100644 index 000000000000..adddd131e644 --- /dev/null +++ b/components/event-bus/generated/ea/listers/remoteenvironment.kyma.cx/v1alpha1/expansion_generated.go @@ -0,0 +1,11 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +// EventActivationListerExpansion allows custom methods to be added to +// EventActivationLister. +type EventActivationListerExpansion interface{} + +// EventActivationNamespaceListerExpansion allows custom methods to be added to +// EventActivationNamespaceLister. +type EventActivationNamespaceListerExpansion interface{} diff --git a/components/event-bus/generated/ea/listers/remoteenvironment.kyma.io/v1alpha1/eventactivation.go b/components/event-bus/generated/ea/listers/remoteenvironment.kyma.io/v1alpha1/eventactivation.go new file mode 100644 index 000000000000..1d846b593283 --- /dev/null +++ b/components/event-bus/generated/ea/listers/remoteenvironment.kyma.io/v1alpha1/eventactivation.go @@ -0,0 +1,78 @@ +// This file was automatically generated by lister-gen + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// EventActivationLister helps list EventActivations. +type EventActivationLister interface { + // List lists all EventActivations in the indexer. + List(selector labels.Selector) (ret []*v1alpha1.EventActivation, err error) + // EventActivations returns an object that can list and get EventActivations. + EventActivations(namespace string) EventActivationNamespaceLister + EventActivationListerExpansion +} + +// eventActivationLister implements the EventActivationLister interface. +type eventActivationLister struct { + indexer cache.Indexer +} + +// NewEventActivationLister returns a new EventActivationLister. +func NewEventActivationLister(indexer cache.Indexer) EventActivationLister { + return &eventActivationLister{indexer: indexer} +} + +// List lists all EventActivations in the indexer. +func (s *eventActivationLister) List(selector labels.Selector) (ret []*v1alpha1.EventActivation, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.EventActivation)) + }) + return ret, err +} + +// EventActivations returns an object that can list and get EventActivations. +func (s *eventActivationLister) EventActivations(namespace string) EventActivationNamespaceLister { + return eventActivationNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// EventActivationNamespaceLister helps list and get EventActivations. +type EventActivationNamespaceLister interface { + // List lists all EventActivations in the indexer for a given namespace. + List(selector labels.Selector) (ret []*v1alpha1.EventActivation, err error) + // Get retrieves the EventActivation from the indexer for a given namespace and name. + Get(name string) (*v1alpha1.EventActivation, error) + EventActivationNamespaceListerExpansion +} + +// eventActivationNamespaceLister implements the EventActivationNamespaceLister +// interface. +type eventActivationNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all EventActivations in the indexer for a given namespace. +func (s eventActivationNamespaceLister) List(selector labels.Selector) (ret []*v1alpha1.EventActivation, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.EventActivation)) + }) + return ret, err +} + +// Get retrieves the EventActivation from the indexer for a given namespace and name. +func (s eventActivationNamespaceLister) Get(name string) (*v1alpha1.EventActivation, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha1.Resource("eventactivation"), name) + } + return obj.(*v1alpha1.EventActivation), nil +} diff --git a/components/event-bus/generated/ea/listers/remoteenvironment.kyma.io/v1alpha1/expansion_generated.go b/components/event-bus/generated/ea/listers/remoteenvironment.kyma.io/v1alpha1/expansion_generated.go new file mode 100644 index 000000000000..5d636769cd52 --- /dev/null +++ b/components/event-bus/generated/ea/listers/remoteenvironment.kyma.io/v1alpha1/expansion_generated.go @@ -0,0 +1,11 @@ +// This file was automatically generated by lister-gen + +package v1alpha1 + +// EventActivationListerExpansion allows custom methods to be added to +// EventActivationLister. +type EventActivationListerExpansion interface{} + +// EventActivationNamespaceListerExpansion allows custom methods to be added to +// EventActivationNamespaceLister. +type EventActivationNamespaceListerExpansion interface{} diff --git a/components/event-bus/generated/ea/listers/remoteenvironment.ysf.io/v1alpha1/eventactivation.go b/components/event-bus/generated/ea/listers/remoteenvironment.ysf.io/v1alpha1/eventactivation.go new file mode 100644 index 000000000000..1d846b593283 --- /dev/null +++ b/components/event-bus/generated/ea/listers/remoteenvironment.ysf.io/v1alpha1/eventactivation.go @@ -0,0 +1,78 @@ +// This file was automatically generated by lister-gen + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// EventActivationLister helps list EventActivations. +type EventActivationLister interface { + // List lists all EventActivations in the indexer. + List(selector labels.Selector) (ret []*v1alpha1.EventActivation, err error) + // EventActivations returns an object that can list and get EventActivations. + EventActivations(namespace string) EventActivationNamespaceLister + EventActivationListerExpansion +} + +// eventActivationLister implements the EventActivationLister interface. +type eventActivationLister struct { + indexer cache.Indexer +} + +// NewEventActivationLister returns a new EventActivationLister. +func NewEventActivationLister(indexer cache.Indexer) EventActivationLister { + return &eventActivationLister{indexer: indexer} +} + +// List lists all EventActivations in the indexer. +func (s *eventActivationLister) List(selector labels.Selector) (ret []*v1alpha1.EventActivation, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.EventActivation)) + }) + return ret, err +} + +// EventActivations returns an object that can list and get EventActivations. +func (s *eventActivationLister) EventActivations(namespace string) EventActivationNamespaceLister { + return eventActivationNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// EventActivationNamespaceLister helps list and get EventActivations. +type EventActivationNamespaceLister interface { + // List lists all EventActivations in the indexer for a given namespace. + List(selector labels.Selector) (ret []*v1alpha1.EventActivation, err error) + // Get retrieves the EventActivation from the indexer for a given namespace and name. + Get(name string) (*v1alpha1.EventActivation, error) + EventActivationNamespaceListerExpansion +} + +// eventActivationNamespaceLister implements the EventActivationNamespaceLister +// interface. +type eventActivationNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all EventActivations in the indexer for a given namespace. +func (s eventActivationNamespaceLister) List(selector labels.Selector) (ret []*v1alpha1.EventActivation, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.EventActivation)) + }) + return ret, err +} + +// Get retrieves the EventActivation from the indexer for a given namespace and name. +func (s eventActivationNamespaceLister) Get(name string) (*v1alpha1.EventActivation, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha1.Resource("eventactivation"), name) + } + return obj.(*v1alpha1.EventActivation), nil +} diff --git a/components/event-bus/generated/ea/listers/remoteenvironment.ysf.io/v1alpha1/expansion_generated.go b/components/event-bus/generated/ea/listers/remoteenvironment.ysf.io/v1alpha1/expansion_generated.go new file mode 100644 index 000000000000..5d636769cd52 --- /dev/null +++ b/components/event-bus/generated/ea/listers/remoteenvironment.ysf.io/v1alpha1/expansion_generated.go @@ -0,0 +1,11 @@ +// This file was automatically generated by lister-gen + +package v1alpha1 + +// EventActivationListerExpansion allows custom methods to be added to +// EventActivationLister. +type EventActivationListerExpansion interface{} + +// EventActivationNamespaceListerExpansion allows custom methods to be added to +// EventActivationNamespaceLister. +type EventActivationNamespaceListerExpansion interface{} diff --git a/components/event-bus/generated/push/clientset/versioned/clientset.go b/components/event-bus/generated/push/clientset/versioned/clientset.go new file mode 100644 index 000000000000..92c80d04d209 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/clientset.go @@ -0,0 +1,84 @@ +// Code generated by client-gen. DO NOT EDIT. + +package versioned + +import ( + glog "github.com/golang/glog" + eventingv1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1" + discovery "k8s.io/client-go/discovery" + rest "k8s.io/client-go/rest" + flowcontrol "k8s.io/client-go/util/flowcontrol" +) + +type Interface interface { + Discovery() discovery.DiscoveryInterface + EventingV1alpha1() eventingv1alpha1.EventingV1alpha1Interface + // Deprecated: please explicitly pick a version if possible. + Eventing() eventingv1alpha1.EventingV1alpha1Interface +} + +// Clientset contains the clients for groups. Each group has exactly one +// version included in a Clientset. +type Clientset struct { + *discovery.DiscoveryClient + eventingV1alpha1 *eventingv1alpha1.EventingV1alpha1Client +} + +// EventingV1alpha1 retrieves the EventingV1alpha1Client +func (c *Clientset) EventingV1alpha1() eventingv1alpha1.EventingV1alpha1Interface { + return c.eventingV1alpha1 +} + +// Deprecated: Eventing retrieves the default version of EventingClient. +// Please explicitly pick a version. +func (c *Clientset) Eventing() eventingv1alpha1.EventingV1alpha1Interface { + return c.eventingV1alpha1 +} + +// Discovery retrieves the DiscoveryClient +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + if c == nil { + return nil + } + return c.DiscoveryClient +} + +// NewForConfig creates a new Clientset for the given config. +func NewForConfig(c *rest.Config) (*Clientset, error) { + configShallowCopy := *c + if configShallowCopy.RateLimiter == nil && configShallowCopy.QPS > 0 { + configShallowCopy.RateLimiter = flowcontrol.NewTokenBucketRateLimiter(configShallowCopy.QPS, configShallowCopy.Burst) + } + var cs Clientset + var err error + cs.eventingV1alpha1, err = eventingv1alpha1.NewForConfig(&configShallowCopy) + if err != nil { + return nil, err + } + + cs.DiscoveryClient, err = discovery.NewDiscoveryClientForConfig(&configShallowCopy) + if err != nil { + glog.Errorf("failed to create the DiscoveryClient: %v", err) + return nil, err + } + return &cs, nil +} + +// NewForConfigOrDie creates a new Clientset for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *Clientset { + var cs Clientset + cs.eventingV1alpha1 = eventingv1alpha1.NewForConfigOrDie(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClientForConfigOrDie(c) + return &cs +} + +// New creates a new Clientset for the given RESTClient. +func New(c rest.Interface) *Clientset { + var cs Clientset + cs.eventingV1alpha1 = eventingv1alpha1.New(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClient(c) + return &cs +} diff --git a/components/event-bus/generated/push/clientset/versioned/doc.go b/components/event-bus/generated/push/clientset/versioned/doc.go new file mode 100644 index 000000000000..0e0c2a8900e2 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated clientset. +package versioned diff --git a/components/event-bus/generated/push/clientset/versioned/fake/clientset_generated.go b/components/event-bus/generated/push/clientset/versioned/fake/clientset_generated.go new file mode 100644 index 000000000000..d7fabfcc25ab --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/fake/clientset_generated.go @@ -0,0 +1,65 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + clientset "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned" + eventingv1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1" + fakeeventingv1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/fake" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/discovery" + fakediscovery "k8s.io/client-go/discovery/fake" + "k8s.io/client-go/testing" +) + +// NewSimpleClientset returns a clientset that will respond with the provided objects. +// It's backed by a very simple object tracker that processes creates, updates and deletions as-is, +// without applying any validations and/or defaults. It shouldn't be considered a replacement +// for a real clientset and is mostly useful in simple unit tests. +func NewSimpleClientset(objects ...runtime.Object) *Clientset { + o := testing.NewObjectTracker(scheme, codecs.UniversalDecoder()) + for _, obj := range objects { + if err := o.Add(obj); err != nil { + panic(err) + } + } + + fakePtr := testing.Fake{} + fakePtr.AddReactor("*", "*", testing.ObjectReaction(o)) + fakePtr.AddWatchReactor("*", func(action testing.Action) (handled bool, ret watch.Interface, err error) { + gvr := action.GetResource() + ns := action.GetNamespace() + watch, err := o.Watch(gvr, ns) + if err != nil { + return false, nil, err + } + return true, watch, nil + }) + + return &Clientset{fakePtr, &fakediscovery.FakeDiscovery{Fake: &fakePtr}} +} + +// Clientset implements clientset.Interface. Meant to be embedded into a +// struct to get a default implementation. This makes faking out just the method +// you want to test easier. +type Clientset struct { + testing.Fake + discovery *fakediscovery.FakeDiscovery +} + +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + return c.discovery +} + +var _ clientset.Interface = &Clientset{} + +// EventingV1alpha1 retrieves the EventingV1alpha1Client +func (c *Clientset) EventingV1alpha1() eventingv1alpha1.EventingV1alpha1Interface { + return &fakeeventingv1alpha1.FakeEventingV1alpha1{Fake: &c.Fake} +} + +// Eventing retrieves the EventingV1alpha1Client +func (c *Clientset) Eventing() eventingv1alpha1.EventingV1alpha1Interface { + return &fakeeventingv1alpha1.FakeEventingV1alpha1{Fake: &c.Fake} +} diff --git a/components/event-bus/generated/push/clientset/versioned/fake/doc.go b/components/event-bus/generated/push/clientset/versioned/fake/doc.go new file mode 100644 index 000000000000..3630ed1cd17d --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/fake/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated fake clientset. +package fake diff --git a/components/event-bus/generated/push/clientset/versioned/fake/register.go b/components/event-bus/generated/push/clientset/versioned/fake/register.go new file mode 100644 index 000000000000..9c8578517590 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/fake/register.go @@ -0,0 +1,38 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + eventingv1alpha1 "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var scheme = runtime.NewScheme() +var codecs = serializer.NewCodecFactory(scheme) +var parameterCodec = runtime.NewParameterCodec(scheme) + +func init() { + v1.AddToGroupVersion(scheme, schema.GroupVersion{Version: "v1"}) + AddToScheme(scheme) +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +func AddToScheme(scheme *runtime.Scheme) { + eventingv1alpha1.AddToScheme(scheme) +} diff --git a/components/event-bus/generated/push/clientset/versioned/scheme/doc.go b/components/event-bus/generated/push/clientset/versioned/scheme/doc.go new file mode 100644 index 000000000000..14db57a58f8d --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/scheme/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package contains the scheme of the automatically generated clientset. +package scheme diff --git a/components/event-bus/generated/push/clientset/versioned/scheme/register.go b/components/event-bus/generated/push/clientset/versioned/scheme/register.go new file mode 100644 index 000000000000..01c9d9ef6eff --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/scheme/register.go @@ -0,0 +1,38 @@ +// Code generated by client-gen. DO NOT EDIT. + +package scheme + +import ( + eventingv1alpha1 "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var Scheme = runtime.NewScheme() +var Codecs = serializer.NewCodecFactory(Scheme) +var ParameterCodec = runtime.NewParameterCodec(Scheme) + +func init() { + v1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"}) + AddToScheme(Scheme) +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +func AddToScheme(scheme *runtime.Scheme) { + eventingv1alpha1.AddToScheme(scheme) +} diff --git a/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/doc.go b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/doc.go new file mode 100644 index 000000000000..93a7ca4e0e2b --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated typed clients. +package v1alpha1 diff --git a/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/eventing.kyma.cx_client.go b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/eventing.kyma.cx_client.go new file mode 100644 index 000000000000..7898190bc408 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/eventing.kyma.cx_client.go @@ -0,0 +1,74 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned/scheme" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + rest "k8s.io/client-go/rest" +) + +type EventingV1alpha1Interface interface { + RESTClient() rest.Interface + SubscriptionsGetter +} + +// EventingV1alpha1Client is used to interact with features provided by the eventing.kyma.cx group. +type EventingV1alpha1Client struct { + restClient rest.Interface +} + +func (c *EventingV1alpha1Client) Subscriptions(namespace string) SubscriptionInterface { + return newSubscriptions(c, namespace) +} + +// NewForConfig creates a new EventingV1alpha1Client for the given config. +func NewForConfig(c *rest.Config) (*EventingV1alpha1Client, error) { + config := *c + if err := setConfigDefaults(&config); err != nil { + return nil, err + } + client, err := rest.RESTClientFor(&config) + if err != nil { + return nil, err + } + return &EventingV1alpha1Client{client}, nil +} + +// NewForConfigOrDie creates a new EventingV1alpha1Client for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *EventingV1alpha1Client { + client, err := NewForConfig(c) + if err != nil { + panic(err) + } + return client +} + +// New creates a new EventingV1alpha1Client for the given RESTClient. +func New(c rest.Interface) *EventingV1alpha1Client { + return &EventingV1alpha1Client{c} +} + +func setConfigDefaults(config *rest.Config) error { + gv := v1alpha1.SchemeGroupVersion + config.GroupVersion = &gv + config.APIPath = "/apis" + config.NegotiatedSerializer = serializer.DirectCodecFactory{CodecFactory: scheme.Codecs} + + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } + + return nil +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *EventingV1alpha1Client) RESTClient() rest.Interface { + if c == nil { + return nil + } + return c.restClient +} diff --git a/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/fake/doc.go b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/fake/doc.go new file mode 100644 index 000000000000..2b5ba4c8e442 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/fake/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// Package fake has the automatically generated clients. +package fake diff --git a/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/fake/fake_eventing.kyma.cx_client.go b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/fake/fake_eventing.kyma.cx_client.go new file mode 100644 index 000000000000..2382864eef19 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/fake/fake_eventing.kyma.cx_client.go @@ -0,0 +1,24 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1" + rest "k8s.io/client-go/rest" + testing "k8s.io/client-go/testing" +) + +type FakeEventingV1alpha1 struct { + *testing.Fake +} + +func (c *FakeEventingV1alpha1) Subscriptions(namespace string) v1alpha1.SubscriptionInterface { + return &FakeSubscriptions{c, namespace} +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *FakeEventingV1alpha1) RESTClient() rest.Interface { + var ret *rest.RESTClient + return ret +} diff --git a/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/fake/fake_subscription.go b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/fake/fake_subscription.go new file mode 100644 index 000000000000..d20693955be5 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/fake/fake_subscription.go @@ -0,0 +1,124 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeSubscriptions implements SubscriptionInterface +type FakeSubscriptions struct { + Fake *FakeEventingV1alpha1 + ns string +} + +var subscriptionsResource = schema.GroupVersionResource{Group: "eventing.kyma.cx", Version: "v1alpha1", Resource: "subscriptions"} + +var subscriptionsKind = schema.GroupVersionKind{Group: "eventing.kyma.cx", Version: "v1alpha1", Kind: "Subscription"} + +// Get takes name of the subscription, and returns the corresponding subscription object, and an error if there is any. +func (c *FakeSubscriptions) Get(name string, options v1.GetOptions) (result *v1alpha1.Subscription, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(subscriptionsResource, c.ns, name), &v1alpha1.Subscription{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Subscription), err +} + +// List takes label and field selectors, and returns the list of Subscriptions that match those selectors. +func (c *FakeSubscriptions) List(opts v1.ListOptions) (result *v1alpha1.SubscriptionList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(subscriptionsResource, subscriptionsKind, c.ns, opts), &v1alpha1.SubscriptionList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha1.SubscriptionList{} + for _, item := range obj.(*v1alpha1.SubscriptionList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested subscriptions. +func (c *FakeSubscriptions) Watch(opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(subscriptionsResource, c.ns, opts)) + +} + +// Create takes the representation of a subscription and creates it. Returns the server's representation of the subscription, and an error, if there is any. +func (c *FakeSubscriptions) Create(subscription *v1alpha1.Subscription) (result *v1alpha1.Subscription, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(subscriptionsResource, c.ns, subscription), &v1alpha1.Subscription{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Subscription), err +} + +// Update takes the representation of a subscription and updates it. Returns the server's representation of the subscription, and an error, if there is any. +func (c *FakeSubscriptions) Update(subscription *v1alpha1.Subscription) (result *v1alpha1.Subscription, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(subscriptionsResource, c.ns, subscription), &v1alpha1.Subscription{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Subscription), err +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). +func (c *FakeSubscriptions) UpdateStatus(subscription *v1alpha1.Subscription) (*v1alpha1.Subscription, error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateSubresourceAction(subscriptionsResource, "status", c.ns, subscription), &v1alpha1.Subscription{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Subscription), err +} + +// Delete takes name of the subscription and deletes it. Returns an error if one occurs. +func (c *FakeSubscriptions) Delete(name string, options *v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(subscriptionsResource, c.ns, name), &v1alpha1.Subscription{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeSubscriptions) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(subscriptionsResource, c.ns, listOptions) + + _, err := c.Fake.Invokes(action, &v1alpha1.SubscriptionList{}) + return err +} + +// Patch applies the patch and returns the patched subscription. +func (c *FakeSubscriptions) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.Subscription, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(subscriptionsResource, c.ns, name, data, subresources...), &v1alpha1.Subscription{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Subscription), err +} diff --git a/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/generated_expansion.go b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/generated_expansion.go new file mode 100644 index 000000000000..bfb9bf224b03 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/generated_expansion.go @@ -0,0 +1,5 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +type SubscriptionExpansion interface{} diff --git a/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/subscription.go b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/subscription.go new file mode 100644 index 000000000000..587e9cb81ac8 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1/subscription.go @@ -0,0 +1,158 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + scheme "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// SubscriptionsGetter has a method to return a SubscriptionInterface. +// A group's client should implement this interface. +type SubscriptionsGetter interface { + Subscriptions(namespace string) SubscriptionInterface +} + +// SubscriptionInterface has methods to work with Subscription resources. +type SubscriptionInterface interface { + Create(*v1alpha1.Subscription) (*v1alpha1.Subscription, error) + Update(*v1alpha1.Subscription) (*v1alpha1.Subscription, error) + UpdateStatus(*v1alpha1.Subscription) (*v1alpha1.Subscription, error) + Delete(name string, options *v1.DeleteOptions) error + DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error + Get(name string, options v1.GetOptions) (*v1alpha1.Subscription, error) + List(opts v1.ListOptions) (*v1alpha1.SubscriptionList, error) + Watch(opts v1.ListOptions) (watch.Interface, error) + Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.Subscription, err error) + SubscriptionExpansion +} + +// subscriptions implements SubscriptionInterface +type subscriptions struct { + client rest.Interface + ns string +} + +// newSubscriptions returns a Subscriptions +func newSubscriptions(c *EventingV1alpha1Client, namespace string) *subscriptions { + return &subscriptions{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the subscription, and returns the corresponding subscription object, and an error if there is any. +func (c *subscriptions) Get(name string, options v1.GetOptions) (result *v1alpha1.Subscription, err error) { + result = &v1alpha1.Subscription{} + err = c.client.Get(). + Namespace(c.ns). + Resource("subscriptions"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of Subscriptions that match those selectors. +func (c *subscriptions) List(opts v1.ListOptions) (result *v1alpha1.SubscriptionList, err error) { + result = &v1alpha1.SubscriptionList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("subscriptions"). + VersionedParams(&opts, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested subscriptions. +func (c *subscriptions) Watch(opts v1.ListOptions) (watch.Interface, error) { + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("subscriptions"). + VersionedParams(&opts, scheme.ParameterCodec). + Watch() +} + +// Create takes the representation of a subscription and creates it. Returns the server's representation of the subscription, and an error, if there is any. +func (c *subscriptions) Create(subscription *v1alpha1.Subscription) (result *v1alpha1.Subscription, err error) { + result = &v1alpha1.Subscription{} + err = c.client.Post(). + Namespace(c.ns). + Resource("subscriptions"). + Body(subscription). + Do(). + Into(result) + return +} + +// Update takes the representation of a subscription and updates it. Returns the server's representation of the subscription, and an error, if there is any. +func (c *subscriptions) Update(subscription *v1alpha1.Subscription) (result *v1alpha1.Subscription, err error) { + result = &v1alpha1.Subscription{} + err = c.client.Put(). + Namespace(c.ns). + Resource("subscriptions"). + Name(subscription.Name). + Body(subscription). + Do(). + Into(result) + return +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + +func (c *subscriptions) UpdateStatus(subscription *v1alpha1.Subscription) (result *v1alpha1.Subscription, err error) { + result = &v1alpha1.Subscription{} + err = c.client.Put(). + Namespace(c.ns). + Resource("subscriptions"). + Name(subscription.Name). + SubResource("status"). + Body(subscription). + Do(). + Into(result) + return +} + +// Delete takes name of the subscription and deletes it. Returns an error if one occurs. +func (c *subscriptions) Delete(name string, options *v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("subscriptions"). + Name(name). + Body(options). + Do(). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *subscriptions) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("subscriptions"). + VersionedParams(&listOptions, scheme.ParameterCodec). + Body(options). + Do(). + Error() +} + +// Patch applies the patch and returns the patched subscription. +func (c *subscriptions) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.Subscription, err error) { + result = &v1alpha1.Subscription{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("subscriptions"). + SubResource(subresources...). + Name(name). + Body(data). + Do(). + Into(result) + return +} diff --git a/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/doc.go b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/doc.go new file mode 100644 index 000000000000..592392b59c59 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/doc.go @@ -0,0 +1,2 @@ +// This package has the automatically generated typed clients. +package v1alpha1 diff --git a/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/eventing.kyma.io_client.go b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/eventing.kyma.io_client.go new file mode 100644 index 000000000000..1dbeff798291 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/eventing.kyma.io_client.go @@ -0,0 +1,72 @@ +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned/scheme" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + rest "k8s.io/client-go/rest" +) + +type EventingV1alpha1Interface interface { + RESTClient() rest.Interface + SubscriptionsGetter +} + +// EventingV1alpha1Client is used to interact with features provided by the eventing.kyma.cx group. +type EventingV1alpha1Client struct { + restClient rest.Interface +} + +func (c *EventingV1alpha1Client) Subscriptions(namespace string) SubscriptionInterface { + return newSubscriptions(c, namespace) +} + +// NewForConfig creates a new EventingV1alpha1Client for the given config. +func NewForConfig(c *rest.Config) (*EventingV1alpha1Client, error) { + config := *c + if err := setConfigDefaults(&config); err != nil { + return nil, err + } + client, err := rest.RESTClientFor(&config) + if err != nil { + return nil, err + } + return &EventingV1alpha1Client{client}, nil +} + +// NewForConfigOrDie creates a new EventingV1alpha1Client for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *EventingV1alpha1Client { + client, err := NewForConfig(c) + if err != nil { + panic(err) + } + return client +} + +// New creates a new EventingV1alpha1Client for the given RESTClient. +func New(c rest.Interface) *EventingV1alpha1Client { + return &EventingV1alpha1Client{c} +} + +func setConfigDefaults(config *rest.Config) error { + gv := v1alpha1.SchemeGroupVersion + config.GroupVersion = &gv + config.APIPath = "/apis" + config.NegotiatedSerializer = serializer.DirectCodecFactory{CodecFactory: scheme.Codecs} + + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } + + return nil +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *EventingV1alpha1Client) RESTClient() rest.Interface { + if c == nil { + return nil + } + return c.restClient +} diff --git a/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/fake/doc.go b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/fake/doc.go new file mode 100644 index 000000000000..798b81175562 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/fake/doc.go @@ -0,0 +1,2 @@ +// Package fake has the automatically generated clients. +package fake diff --git a/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/fake/fake_eventing.kyma.io_client.go b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/fake/fake_eventing.kyma.io_client.go new file mode 100644 index 000000000000..13d748d05895 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/fake/fake_eventing.kyma.io_client.go @@ -0,0 +1,22 @@ +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1" + rest "k8s.io/client-go/rest" + testing "k8s.io/client-go/testing" +) + +type FakeEventingV1alpha1 struct { + *testing.Fake +} + +func (c *FakeEventingV1alpha1) Subscriptions(namespace string) v1alpha1.SubscriptionInterface { + return &FakeSubscriptions{c, namespace} +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *FakeEventingV1alpha1) RESTClient() rest.Interface { + var ret *rest.RESTClient + return ret +} diff --git a/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/fake/fake_subscription.go b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/fake/fake_subscription.go new file mode 100644 index 000000000000..2eee96f96983 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/fake/fake_subscription.go @@ -0,0 +1,122 @@ +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeSubscriptions implements SubscriptionInterface +type FakeSubscriptions struct { + Fake *FakeEventingV1alpha1 + ns string +} + +var subscriptionsResource = schema.GroupVersionResource{Group: "eventing.kyma.cx", Version: "v1alpha1", Resource: "subscriptions"} + +var subscriptionsKind = schema.GroupVersionKind{Group: "eventing.kyma.cx", Version: "v1alpha1", Kind: "Subscription"} + +// Get takes name of the subscription, and returns the corresponding subscription object, and an error if there is any. +func (c *FakeSubscriptions) Get(name string, options v1.GetOptions) (result *v1alpha1.Subscription, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(subscriptionsResource, c.ns, name), &v1alpha1.Subscription{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Subscription), err +} + +// List takes label and field selectors, and returns the list of Subscriptions that match those selectors. +func (c *FakeSubscriptions) List(opts v1.ListOptions) (result *v1alpha1.SubscriptionList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(subscriptionsResource, subscriptionsKind, c.ns, opts), &v1alpha1.SubscriptionList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha1.SubscriptionList{} + for _, item := range obj.(*v1alpha1.SubscriptionList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested subscriptions. +func (c *FakeSubscriptions) Watch(opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(subscriptionsResource, c.ns, opts)) + +} + +// Create takes the representation of a subscription and creates it. Returns the server's representation of the subscription, and an error, if there is any. +func (c *FakeSubscriptions) Create(subscription *v1alpha1.Subscription) (result *v1alpha1.Subscription, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(subscriptionsResource, c.ns, subscription), &v1alpha1.Subscription{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Subscription), err +} + +// Update takes the representation of a subscription and updates it. Returns the server's representation of the subscription, and an error, if there is any. +func (c *FakeSubscriptions) Update(subscription *v1alpha1.Subscription) (result *v1alpha1.Subscription, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(subscriptionsResource, c.ns, subscription), &v1alpha1.Subscription{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Subscription), err +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). +func (c *FakeSubscriptions) UpdateStatus(subscription *v1alpha1.Subscription) (*v1alpha1.Subscription, error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateSubresourceAction(subscriptionsResource, "status", c.ns, subscription), &v1alpha1.Subscription{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Subscription), err +} + +// Delete takes name of the subscription and deletes it. Returns an error if one occurs. +func (c *FakeSubscriptions) Delete(name string, options *v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(subscriptionsResource, c.ns, name), &v1alpha1.Subscription{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeSubscriptions) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(subscriptionsResource, c.ns, listOptions) + + _, err := c.Fake.Invokes(action, &v1alpha1.SubscriptionList{}) + return err +} + +// Patch applies the patch and returns the patched subscription. +func (c *FakeSubscriptions) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.Subscription, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(subscriptionsResource, c.ns, name, data, subresources...), &v1alpha1.Subscription{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Subscription), err +} diff --git a/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/generated_expansion.go b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/generated_expansion.go new file mode 100644 index 000000000000..b287d652abb5 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/generated_expansion.go @@ -0,0 +1,3 @@ +package v1alpha1 + +type SubscriptionExpansion interface{} diff --git a/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/subscription.go b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/subscription.go new file mode 100644 index 000000000000..184349b7a932 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.io/v1alpha1/subscription.go @@ -0,0 +1,156 @@ +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + scheme "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// SubscriptionsGetter has a method to return a SubscriptionInterface. +// A group's client should implement this interface. +type SubscriptionsGetter interface { + Subscriptions(namespace string) SubscriptionInterface +} + +// SubscriptionInterface has methods to work with Subscription resources. +type SubscriptionInterface interface { + Create(*v1alpha1.Subscription) (*v1alpha1.Subscription, error) + Update(*v1alpha1.Subscription) (*v1alpha1.Subscription, error) + UpdateStatus(*v1alpha1.Subscription) (*v1alpha1.Subscription, error) + Delete(name string, options *v1.DeleteOptions) error + DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error + Get(name string, options v1.GetOptions) (*v1alpha1.Subscription, error) + List(opts v1.ListOptions) (*v1alpha1.SubscriptionList, error) + Watch(opts v1.ListOptions) (watch.Interface, error) + Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.Subscription, err error) + SubscriptionExpansion +} + +// subscriptions implements SubscriptionInterface +type subscriptions struct { + client rest.Interface + ns string +} + +// newSubscriptions returns a Subscriptions +func newSubscriptions(c *EventingV1alpha1Client, namespace string) *subscriptions { + return &subscriptions{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the subscription, and returns the corresponding subscription object, and an error if there is any. +func (c *subscriptions) Get(name string, options v1.GetOptions) (result *v1alpha1.Subscription, err error) { + result = &v1alpha1.Subscription{} + err = c.client.Get(). + Namespace(c.ns). + Resource("subscriptions"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of Subscriptions that match those selectors. +func (c *subscriptions) List(opts v1.ListOptions) (result *v1alpha1.SubscriptionList, err error) { + result = &v1alpha1.SubscriptionList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("subscriptions"). + VersionedParams(&opts, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested subscriptions. +func (c *subscriptions) Watch(opts v1.ListOptions) (watch.Interface, error) { + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("subscriptions"). + VersionedParams(&opts, scheme.ParameterCodec). + Watch() +} + +// Create takes the representation of a subscription and creates it. Returns the server's representation of the subscription, and an error, if there is any. +func (c *subscriptions) Create(subscription *v1alpha1.Subscription) (result *v1alpha1.Subscription, err error) { + result = &v1alpha1.Subscription{} + err = c.client.Post(). + Namespace(c.ns). + Resource("subscriptions"). + Body(subscription). + Do(). + Into(result) + return +} + +// Update takes the representation of a subscription and updates it. Returns the server's representation of the subscription, and an error, if there is any. +func (c *subscriptions) Update(subscription *v1alpha1.Subscription) (result *v1alpha1.Subscription, err error) { + result = &v1alpha1.Subscription{} + err = c.client.Put(). + Namespace(c.ns). + Resource("subscriptions"). + Name(subscription.Name). + Body(subscription). + Do(). + Into(result) + return +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + +func (c *subscriptions) UpdateStatus(subscription *v1alpha1.Subscription) (result *v1alpha1.Subscription, err error) { + result = &v1alpha1.Subscription{} + err = c.client.Put(). + Namespace(c.ns). + Resource("subscriptions"). + Name(subscription.Name). + SubResource("status"). + Body(subscription). + Do(). + Into(result) + return +} + +// Delete takes name of the subscription and deletes it. Returns an error if one occurs. +func (c *subscriptions) Delete(name string, options *v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("subscriptions"). + Name(name). + Body(options). + Do(). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *subscriptions) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("subscriptions"). + VersionedParams(&listOptions, scheme.ParameterCodec). + Body(options). + Do(). + Error() +} + +// Patch applies the patch and returns the patched subscription. +func (c *subscriptions) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.Subscription, err error) { + result = &v1alpha1.Subscription{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("subscriptions"). + SubResource(subresources...). + Name(name). + Body(data). + Do(). + Into(result) + return +} diff --git a/components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/doc.go b/components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/doc.go new file mode 100644 index 000000000000..592392b59c59 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/doc.go @@ -0,0 +1,2 @@ +// This package has the automatically generated typed clients. +package v1alpha1 diff --git a/components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/eventing.ysf.io_client.go b/components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/eventing.ysf.io_client.go new file mode 100644 index 000000000000..1dbeff798291 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/eventing.ysf.io_client.go @@ -0,0 +1,72 @@ +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned/scheme" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + rest "k8s.io/client-go/rest" +) + +type EventingV1alpha1Interface interface { + RESTClient() rest.Interface + SubscriptionsGetter +} + +// EventingV1alpha1Client is used to interact with features provided by the eventing.kyma.cx group. +type EventingV1alpha1Client struct { + restClient rest.Interface +} + +func (c *EventingV1alpha1Client) Subscriptions(namespace string) SubscriptionInterface { + return newSubscriptions(c, namespace) +} + +// NewForConfig creates a new EventingV1alpha1Client for the given config. +func NewForConfig(c *rest.Config) (*EventingV1alpha1Client, error) { + config := *c + if err := setConfigDefaults(&config); err != nil { + return nil, err + } + client, err := rest.RESTClientFor(&config) + if err != nil { + return nil, err + } + return &EventingV1alpha1Client{client}, nil +} + +// NewForConfigOrDie creates a new EventingV1alpha1Client for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *EventingV1alpha1Client { + client, err := NewForConfig(c) + if err != nil { + panic(err) + } + return client +} + +// New creates a new EventingV1alpha1Client for the given RESTClient. +func New(c rest.Interface) *EventingV1alpha1Client { + return &EventingV1alpha1Client{c} +} + +func setConfigDefaults(config *rest.Config) error { + gv := v1alpha1.SchemeGroupVersion + config.GroupVersion = &gv + config.APIPath = "/apis" + config.NegotiatedSerializer = serializer.DirectCodecFactory{CodecFactory: scheme.Codecs} + + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } + + return nil +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *EventingV1alpha1Client) RESTClient() rest.Interface { + if c == nil { + return nil + } + return c.restClient +} diff --git a/components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/fake/doc.go b/components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/fake/doc.go new file mode 100644 index 000000000000..798b81175562 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/fake/doc.go @@ -0,0 +1,2 @@ +// Package fake has the automatically generated clients. +package fake diff --git a/components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/fake/fake_eventing.ysf.io_client.go b/components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/fake/fake_eventing.ysf.io_client.go new file mode 100644 index 000000000000..13d748d05895 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/fake/fake_eventing.ysf.io_client.go @@ -0,0 +1,22 @@ +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned/typed/eventing.kyma.cx/v1alpha1" + rest "k8s.io/client-go/rest" + testing "k8s.io/client-go/testing" +) + +type FakeEventingV1alpha1 struct { + *testing.Fake +} + +func (c *FakeEventingV1alpha1) Subscriptions(namespace string) v1alpha1.SubscriptionInterface { + return &FakeSubscriptions{c, namespace} +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *FakeEventingV1alpha1) RESTClient() rest.Interface { + var ret *rest.RESTClient + return ret +} diff --git a/components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/fake/fake_subscription.go b/components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/fake/fake_subscription.go new file mode 100644 index 000000000000..2eee96f96983 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/fake/fake_subscription.go @@ -0,0 +1,122 @@ +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeSubscriptions implements SubscriptionInterface +type FakeSubscriptions struct { + Fake *FakeEventingV1alpha1 + ns string +} + +var subscriptionsResource = schema.GroupVersionResource{Group: "eventing.kyma.cx", Version: "v1alpha1", Resource: "subscriptions"} + +var subscriptionsKind = schema.GroupVersionKind{Group: "eventing.kyma.cx", Version: "v1alpha1", Kind: "Subscription"} + +// Get takes name of the subscription, and returns the corresponding subscription object, and an error if there is any. +func (c *FakeSubscriptions) Get(name string, options v1.GetOptions) (result *v1alpha1.Subscription, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(subscriptionsResource, c.ns, name), &v1alpha1.Subscription{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Subscription), err +} + +// List takes label and field selectors, and returns the list of Subscriptions that match those selectors. +func (c *FakeSubscriptions) List(opts v1.ListOptions) (result *v1alpha1.SubscriptionList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(subscriptionsResource, subscriptionsKind, c.ns, opts), &v1alpha1.SubscriptionList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha1.SubscriptionList{} + for _, item := range obj.(*v1alpha1.SubscriptionList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested subscriptions. +func (c *FakeSubscriptions) Watch(opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(subscriptionsResource, c.ns, opts)) + +} + +// Create takes the representation of a subscription and creates it. Returns the server's representation of the subscription, and an error, if there is any. +func (c *FakeSubscriptions) Create(subscription *v1alpha1.Subscription) (result *v1alpha1.Subscription, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(subscriptionsResource, c.ns, subscription), &v1alpha1.Subscription{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Subscription), err +} + +// Update takes the representation of a subscription and updates it. Returns the server's representation of the subscription, and an error, if there is any. +func (c *FakeSubscriptions) Update(subscription *v1alpha1.Subscription) (result *v1alpha1.Subscription, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(subscriptionsResource, c.ns, subscription), &v1alpha1.Subscription{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Subscription), err +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). +func (c *FakeSubscriptions) UpdateStatus(subscription *v1alpha1.Subscription) (*v1alpha1.Subscription, error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateSubresourceAction(subscriptionsResource, "status", c.ns, subscription), &v1alpha1.Subscription{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Subscription), err +} + +// Delete takes name of the subscription and deletes it. Returns an error if one occurs. +func (c *FakeSubscriptions) Delete(name string, options *v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(subscriptionsResource, c.ns, name), &v1alpha1.Subscription{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeSubscriptions) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(subscriptionsResource, c.ns, listOptions) + + _, err := c.Fake.Invokes(action, &v1alpha1.SubscriptionList{}) + return err +} + +// Patch applies the patch and returns the patched subscription. +func (c *FakeSubscriptions) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.Subscription, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(subscriptionsResource, c.ns, name, data, subresources...), &v1alpha1.Subscription{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Subscription), err +} diff --git a/components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/generated_expansion.go b/components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/generated_expansion.go new file mode 100644 index 000000000000..b287d652abb5 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/generated_expansion.go @@ -0,0 +1,3 @@ +package v1alpha1 + +type SubscriptionExpansion interface{} diff --git a/components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/subscription.go b/components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/subscription.go new file mode 100644 index 000000000000..184349b7a932 --- /dev/null +++ b/components/event-bus/generated/push/clientset/versioned/typed/eventing.ysf.io/v1alpha1/subscription.go @@ -0,0 +1,156 @@ +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + scheme "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// SubscriptionsGetter has a method to return a SubscriptionInterface. +// A group's client should implement this interface. +type SubscriptionsGetter interface { + Subscriptions(namespace string) SubscriptionInterface +} + +// SubscriptionInterface has methods to work with Subscription resources. +type SubscriptionInterface interface { + Create(*v1alpha1.Subscription) (*v1alpha1.Subscription, error) + Update(*v1alpha1.Subscription) (*v1alpha1.Subscription, error) + UpdateStatus(*v1alpha1.Subscription) (*v1alpha1.Subscription, error) + Delete(name string, options *v1.DeleteOptions) error + DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error + Get(name string, options v1.GetOptions) (*v1alpha1.Subscription, error) + List(opts v1.ListOptions) (*v1alpha1.SubscriptionList, error) + Watch(opts v1.ListOptions) (watch.Interface, error) + Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.Subscription, err error) + SubscriptionExpansion +} + +// subscriptions implements SubscriptionInterface +type subscriptions struct { + client rest.Interface + ns string +} + +// newSubscriptions returns a Subscriptions +func newSubscriptions(c *EventingV1alpha1Client, namespace string) *subscriptions { + return &subscriptions{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the subscription, and returns the corresponding subscription object, and an error if there is any. +func (c *subscriptions) Get(name string, options v1.GetOptions) (result *v1alpha1.Subscription, err error) { + result = &v1alpha1.Subscription{} + err = c.client.Get(). + Namespace(c.ns). + Resource("subscriptions"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of Subscriptions that match those selectors. +func (c *subscriptions) List(opts v1.ListOptions) (result *v1alpha1.SubscriptionList, err error) { + result = &v1alpha1.SubscriptionList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("subscriptions"). + VersionedParams(&opts, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested subscriptions. +func (c *subscriptions) Watch(opts v1.ListOptions) (watch.Interface, error) { + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("subscriptions"). + VersionedParams(&opts, scheme.ParameterCodec). + Watch() +} + +// Create takes the representation of a subscription and creates it. Returns the server's representation of the subscription, and an error, if there is any. +func (c *subscriptions) Create(subscription *v1alpha1.Subscription) (result *v1alpha1.Subscription, err error) { + result = &v1alpha1.Subscription{} + err = c.client.Post(). + Namespace(c.ns). + Resource("subscriptions"). + Body(subscription). + Do(). + Into(result) + return +} + +// Update takes the representation of a subscription and updates it. Returns the server's representation of the subscription, and an error, if there is any. +func (c *subscriptions) Update(subscription *v1alpha1.Subscription) (result *v1alpha1.Subscription, err error) { + result = &v1alpha1.Subscription{} + err = c.client.Put(). + Namespace(c.ns). + Resource("subscriptions"). + Name(subscription.Name). + Body(subscription). + Do(). + Into(result) + return +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + +func (c *subscriptions) UpdateStatus(subscription *v1alpha1.Subscription) (result *v1alpha1.Subscription, err error) { + result = &v1alpha1.Subscription{} + err = c.client.Put(). + Namespace(c.ns). + Resource("subscriptions"). + Name(subscription.Name). + SubResource("status"). + Body(subscription). + Do(). + Into(result) + return +} + +// Delete takes name of the subscription and deletes it. Returns an error if one occurs. +func (c *subscriptions) Delete(name string, options *v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("subscriptions"). + Name(name). + Body(options). + Do(). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *subscriptions) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("subscriptions"). + VersionedParams(&listOptions, scheme.ParameterCodec). + Body(options). + Do(). + Error() +} + +// Patch applies the patch and returns the patched subscription. +func (c *subscriptions) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.Subscription, err error) { + result = &v1alpha1.Subscription{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("subscriptions"). + SubResource(subresources...). + Name(name). + Body(data). + Do(). + Into(result) + return +} diff --git a/components/event-bus/generated/push/informers/externalversions/eventing.kyma.cx/interface.go b/components/event-bus/generated/push/informers/externalversions/eventing.kyma.cx/interface.go new file mode 100644 index 000000000000..135ee1376890 --- /dev/null +++ b/components/event-bus/generated/push/informers/externalversions/eventing.kyma.cx/interface.go @@ -0,0 +1,30 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package eventing + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/push/informers/externalversions/eventing.kyma.cx/v1alpha1" + internalinterfaces "github.com/kyma-project/kyma/components/event-bus/generated/push/informers/externalversions/internalinterfaces" +) + +// Interface provides access to each of this group's versions. +type Interface interface { + // V1alpha1 provides access to shared informers for resources in V1alpha1. + V1alpha1() v1alpha1.Interface +} + +type group struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// V1alpha1 returns a new v1alpha1.Interface. +func (g *group) V1alpha1() v1alpha1.Interface { + return v1alpha1.New(g.factory, g.namespace, g.tweakListOptions) +} diff --git a/components/event-bus/generated/push/informers/externalversions/eventing.kyma.cx/v1alpha1/interface.go b/components/event-bus/generated/push/informers/externalversions/eventing.kyma.cx/v1alpha1/interface.go new file mode 100644 index 000000000000..2e72e4f39441 --- /dev/null +++ b/components/event-bus/generated/push/informers/externalversions/eventing.kyma.cx/v1alpha1/interface.go @@ -0,0 +1,29 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + internalinterfaces "github.com/kyma-project/kyma/components/event-bus/generated/push/informers/externalversions/internalinterfaces" +) + +// Interface provides access to all the informers in this group version. +type Interface interface { + // Subscriptions returns a SubscriptionInformer. + Subscriptions() SubscriptionInformer +} + +type version struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// Subscriptions returns a SubscriptionInformer. +func (v *version) Subscriptions() SubscriptionInformer { + return &subscriptionInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} diff --git a/components/event-bus/generated/push/informers/externalversions/eventing.kyma.cx/v1alpha1/subscription.go b/components/event-bus/generated/push/informers/externalversions/eventing.kyma.cx/v1alpha1/subscription.go new file mode 100644 index 000000000000..2cfef4a87b8d --- /dev/null +++ b/components/event-bus/generated/push/informers/externalversions/eventing.kyma.cx/v1alpha1/subscription.go @@ -0,0 +1,73 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + time "time" + + eventingkymacxv1alpha1 "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + versioned "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/event-bus/generated/push/informers/externalversions/internalinterfaces" + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/push/listers/eventing.kyma.cx/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// SubscriptionInformer provides access to a shared informer and lister for +// Subscriptions. +type SubscriptionInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha1.SubscriptionLister +} + +type subscriptionInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewSubscriptionInformer constructs a new informer for Subscription type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewSubscriptionInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredSubscriptionInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredSubscriptionInformer constructs a new informer for Subscription type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredSubscriptionInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.EventingV1alpha1().Subscriptions(namespace).List(options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.EventingV1alpha1().Subscriptions(namespace).Watch(options) + }, + }, + &eventingkymacxv1alpha1.Subscription{}, + resyncPeriod, + indexers, + ) +} + +func (f *subscriptionInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredSubscriptionInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *subscriptionInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&eventingkymacxv1alpha1.Subscription{}, f.defaultInformer) +} + +func (f *subscriptionInformer) Lister() v1alpha1.SubscriptionLister { + return v1alpha1.NewSubscriptionLister(f.Informer().GetIndexer()) +} diff --git a/components/event-bus/generated/push/informers/externalversions/eventing.kyma.io/interface.go b/components/event-bus/generated/push/informers/externalversions/eventing.kyma.io/interface.go new file mode 100644 index 000000000000..1726a038c924 --- /dev/null +++ b/components/event-bus/generated/push/informers/externalversions/eventing.kyma.io/interface.go @@ -0,0 +1,30 @@ +// This file was automatically generated by informer-gen + +package eventing + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/push/informers/externalversions/eventing.kyma.cx/v1alpha1" + internalinterfaces "github.com/kyma-project/kyma/components/event-bus/generated/push/informers/externalversions/internalinterfaces" +) + +// Interface provides access to each of this group's versions. +type Interface interface { + // V1alpha1 provides access to shared informers for resources in V1alpha1. + V1alpha1() v1alpha1.Interface +} + +type group struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// V1alpha1 returns a new v1alpha1.Interface. +func (g *group) V1alpha1() v1alpha1.Interface { + return v1alpha1.New(g.factory, g.namespace, g.tweakListOptions) +} diff --git a/components/event-bus/generated/push/informers/externalversions/eventing.kyma.io/v1alpha1/interface.go b/components/event-bus/generated/push/informers/externalversions/eventing.kyma.io/v1alpha1/interface.go new file mode 100644 index 000000000000..7386bbc1eee5 --- /dev/null +++ b/components/event-bus/generated/push/informers/externalversions/eventing.kyma.io/v1alpha1/interface.go @@ -0,0 +1,29 @@ +// This file was automatically generated by informer-gen + +package v1alpha1 + +import ( + internalinterfaces "github.com/kyma-project/kyma/components/event-bus/generated/push/informers/externalversions/internalinterfaces" +) + +// Interface provides access to all the informers in this group version. +type Interface interface { + // Subscriptions returns a SubscriptionInformer. + Subscriptions() SubscriptionInformer +} + +type version struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// Subscriptions returns a SubscriptionInformer. +func (v *version) Subscriptions() SubscriptionInformer { + return &subscriptionInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} diff --git a/components/event-bus/generated/push/informers/externalversions/eventing.kyma.io/v1alpha1/subscription.go b/components/event-bus/generated/push/informers/externalversions/eventing.kyma.io/v1alpha1/subscription.go new file mode 100644 index 000000000000..835d06fd06f3 --- /dev/null +++ b/components/event-bus/generated/push/informers/externalversions/eventing.kyma.io/v1alpha1/subscription.go @@ -0,0 +1,73 @@ +// This file was automatically generated by informer-gen + +package v1alpha1 + +import ( + time "time" + + eventing_kyma_io_v1alpha1 "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + versioned "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/event-bus/generated/push/informers/externalversions/internalinterfaces" + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/push/listers/eventing.kyma.cx/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// SubscriptionInformer provides access to a shared informer and lister for +// Subscriptions. +type SubscriptionInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha1.SubscriptionLister +} + +type subscriptionInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewSubscriptionInformer constructs a new informer for Subscription type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewSubscriptionInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredSubscriptionInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredSubscriptionInformer constructs a new informer for Subscription type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredSubscriptionInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.EventingV1alpha1().Subscriptions(namespace).List(options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.EventingV1alpha1().Subscriptions(namespace).Watch(options) + }, + }, + &eventing_kyma_io_v1alpha1.Subscription{}, + resyncPeriod, + indexers, + ) +} + +func (f *subscriptionInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredSubscriptionInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *subscriptionInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&eventing_kyma_io_v1alpha1.Subscription{}, f.defaultInformer) +} + +func (f *subscriptionInformer) Lister() v1alpha1.SubscriptionLister { + return v1alpha1.NewSubscriptionLister(f.Informer().GetIndexer()) +} diff --git a/components/event-bus/generated/push/informers/externalversions/eventing.ysf.io/interface.go b/components/event-bus/generated/push/informers/externalversions/eventing.ysf.io/interface.go new file mode 100644 index 000000000000..1726a038c924 --- /dev/null +++ b/components/event-bus/generated/push/informers/externalversions/eventing.ysf.io/interface.go @@ -0,0 +1,30 @@ +// This file was automatically generated by informer-gen + +package eventing + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/push/informers/externalversions/eventing.kyma.cx/v1alpha1" + internalinterfaces "github.com/kyma-project/kyma/components/event-bus/generated/push/informers/externalversions/internalinterfaces" +) + +// Interface provides access to each of this group's versions. +type Interface interface { + // V1alpha1 provides access to shared informers for resources in V1alpha1. + V1alpha1() v1alpha1.Interface +} + +type group struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// V1alpha1 returns a new v1alpha1.Interface. +func (g *group) V1alpha1() v1alpha1.Interface { + return v1alpha1.New(g.factory, g.namespace, g.tweakListOptions) +} diff --git a/components/event-bus/generated/push/informers/externalversions/eventing.ysf.io/v1alpha1/interface.go b/components/event-bus/generated/push/informers/externalversions/eventing.ysf.io/v1alpha1/interface.go new file mode 100644 index 000000000000..7386bbc1eee5 --- /dev/null +++ b/components/event-bus/generated/push/informers/externalversions/eventing.ysf.io/v1alpha1/interface.go @@ -0,0 +1,29 @@ +// This file was automatically generated by informer-gen + +package v1alpha1 + +import ( + internalinterfaces "github.com/kyma-project/kyma/components/event-bus/generated/push/informers/externalversions/internalinterfaces" +) + +// Interface provides access to all the informers in this group version. +type Interface interface { + // Subscriptions returns a SubscriptionInformer. + Subscriptions() SubscriptionInformer +} + +type version struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// Subscriptions returns a SubscriptionInformer. +func (v *version) Subscriptions() SubscriptionInformer { + return &subscriptionInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} diff --git a/components/event-bus/generated/push/informers/externalversions/eventing.ysf.io/v1alpha1/subscription.go b/components/event-bus/generated/push/informers/externalversions/eventing.ysf.io/v1alpha1/subscription.go new file mode 100644 index 000000000000..835d06fd06f3 --- /dev/null +++ b/components/event-bus/generated/push/informers/externalversions/eventing.ysf.io/v1alpha1/subscription.go @@ -0,0 +1,73 @@ +// This file was automatically generated by informer-gen + +package v1alpha1 + +import ( + time "time" + + eventing_kyma_io_v1alpha1 "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + versioned "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/event-bus/generated/push/informers/externalversions/internalinterfaces" + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/push/listers/eventing.kyma.cx/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// SubscriptionInformer provides access to a shared informer and lister for +// Subscriptions. +type SubscriptionInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha1.SubscriptionLister +} + +type subscriptionInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewSubscriptionInformer constructs a new informer for Subscription type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewSubscriptionInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredSubscriptionInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredSubscriptionInformer constructs a new informer for Subscription type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredSubscriptionInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.EventingV1alpha1().Subscriptions(namespace).List(options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.EventingV1alpha1().Subscriptions(namespace).Watch(options) + }, + }, + &eventing_kyma_io_v1alpha1.Subscription{}, + resyncPeriod, + indexers, + ) +} + +func (f *subscriptionInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredSubscriptionInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *subscriptionInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&eventing_kyma_io_v1alpha1.Subscription{}, f.defaultInformer) +} + +func (f *subscriptionInformer) Lister() v1alpha1.SubscriptionLister { + return v1alpha1.NewSubscriptionLister(f.Informer().GetIndexer()) +} diff --git a/components/event-bus/generated/push/informers/externalversions/factory.go b/components/event-bus/generated/push/informers/externalversions/factory.go new file mode 100644 index 000000000000..b4294d4a2887 --- /dev/null +++ b/components/event-bus/generated/push/informers/externalversions/factory.go @@ -0,0 +1,115 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + reflect "reflect" + sync "sync" + time "time" + + versioned "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned" + eventingkymacx "github.com/kyma-project/kyma/components/event-bus/generated/push/informers/externalversions/eventing.kyma.cx" + internalinterfaces "github.com/kyma-project/kyma/components/event-bus/generated/push/informers/externalversions/internalinterfaces" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +type sharedInformerFactory struct { + client versioned.Interface + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc + lock sync.Mutex + defaultResync time.Duration + + informers map[reflect.Type]cache.SharedIndexInformer + // startedInformers is used for tracking which informers have been started. + // This allows Start() to be called multiple times safely. + startedInformers map[reflect.Type]bool +} + +// NewSharedInformerFactory constructs a new instance of sharedInformerFactory +func NewSharedInformerFactory(client versioned.Interface, defaultResync time.Duration) SharedInformerFactory { + return NewFilteredSharedInformerFactory(client, defaultResync, v1.NamespaceAll, nil) +} + +// NewFilteredSharedInformerFactory constructs a new instance of sharedInformerFactory. +// Listers obtained via this SharedInformerFactory will be subject to the same filters +// as specified here. +func NewFilteredSharedInformerFactory(client versioned.Interface, defaultResync time.Duration, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) SharedInformerFactory { + return &sharedInformerFactory{ + client: client, + namespace: namespace, + tweakListOptions: tweakListOptions, + defaultResync: defaultResync, + informers: make(map[reflect.Type]cache.SharedIndexInformer), + startedInformers: make(map[reflect.Type]bool), + } +} + +// Start initializes all requested informers. +func (f *sharedInformerFactory) Start(stopCh <-chan struct{}) { + f.lock.Lock() + defer f.lock.Unlock() + + for informerType, informer := range f.informers { + if !f.startedInformers[informerType] { + go informer.Run(stopCh) + f.startedInformers[informerType] = true + } + } +} + +// WaitForCacheSync waits for all started informers' cache were synced. +func (f *sharedInformerFactory) WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool { + informers := func() map[reflect.Type]cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informers := map[reflect.Type]cache.SharedIndexInformer{} + for informerType, informer := range f.informers { + if f.startedInformers[informerType] { + informers[informerType] = informer + } + } + return informers + }() + + res := map[reflect.Type]bool{} + for informType, informer := range informers { + res[informType] = cache.WaitForCacheSync(stopCh, informer.HasSynced) + } + return res +} + +// InternalInformerFor returns the SharedIndexInformer for obj using an internal +// client. +func (f *sharedInformerFactory) InformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informerType := reflect.TypeOf(obj) + informer, exists := f.informers[informerType] + if exists { + return informer + } + informer = newFunc(f.client, f.defaultResync) + f.informers[informerType] = informer + + return informer +} + +// SharedInformerFactory provides shared informers for resources in all known +// API group versions. +type SharedInformerFactory interface { + internalinterfaces.SharedInformerFactory + ForResource(resource schema.GroupVersionResource) (GenericInformer, error) + WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool + + Eventing() eventingkymacx.Interface +} + +func (f *sharedInformerFactory) Eventing() eventingkymacx.Interface { + return eventingkymacx.New(f, f.namespace, f.tweakListOptions) +} diff --git a/components/event-bus/generated/push/informers/externalversions/generic.go b/components/event-bus/generated/push/informers/externalversions/generic.go new file mode 100644 index 000000000000..d17d0511cb80 --- /dev/null +++ b/components/event-bus/generated/push/informers/externalversions/generic.go @@ -0,0 +1,46 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + "fmt" + + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +// GenericInformer is type of SharedIndexInformer which will locate and delegate to other +// sharedInformers based on type +type GenericInformer interface { + Informer() cache.SharedIndexInformer + Lister() cache.GenericLister +} + +type genericInformer struct { + informer cache.SharedIndexInformer + resource schema.GroupResource +} + +// Informer returns the SharedIndexInformer. +func (f *genericInformer) Informer() cache.SharedIndexInformer { + return f.informer +} + +// Lister returns the GenericLister. +func (f *genericInformer) Lister() cache.GenericLister { + return cache.NewGenericLister(f.Informer().GetIndexer(), f.resource) +} + +// ForResource gives generic access to a shared informer of the matching type +// TODO extend this to unknown resources with a client pool +func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { + switch resource { + // Group=eventing.kyma.cx, Version=v1alpha1 + case v1alpha1.SchemeGroupVersion.WithResource("subscriptions"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Eventing().V1alpha1().Subscriptions().Informer()}, nil + + } + + return nil, fmt.Errorf("no informer found for %v", resource) +} diff --git a/components/event-bus/generated/push/informers/externalversions/internalinterfaces/factory_interfaces.go b/components/event-bus/generated/push/informers/externalversions/internalinterfaces/factory_interfaces.go new file mode 100644 index 000000000000..73d08b2df7ac --- /dev/null +++ b/components/event-bus/generated/push/informers/externalversions/internalinterfaces/factory_interfaces.go @@ -0,0 +1,22 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package internalinterfaces + +import ( + time "time" + + versioned "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + cache "k8s.io/client-go/tools/cache" +) + +type NewInformerFunc func(versioned.Interface, time.Duration) cache.SharedIndexInformer + +// SharedInformerFactory a small interface to allow for adding an informer without an import cycle +type SharedInformerFactory interface { + Start(stopCh <-chan struct{}) + InformerFor(obj runtime.Object, newFunc NewInformerFunc) cache.SharedIndexInformer +} + +type TweakListOptionsFunc func(*v1.ListOptions) diff --git a/components/event-bus/generated/push/listers/eventing.kyma.cx/v1alpha1/expansion_generated.go b/components/event-bus/generated/push/listers/eventing.kyma.cx/v1alpha1/expansion_generated.go new file mode 100644 index 000000000000..404fc138a13a --- /dev/null +++ b/components/event-bus/generated/push/listers/eventing.kyma.cx/v1alpha1/expansion_generated.go @@ -0,0 +1,11 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +// SubscriptionListerExpansion allows custom methods to be added to +// SubscriptionLister. +type SubscriptionListerExpansion interface{} + +// SubscriptionNamespaceListerExpansion allows custom methods to be added to +// SubscriptionNamespaceLister. +type SubscriptionNamespaceListerExpansion interface{} diff --git a/components/event-bus/generated/push/listers/eventing.kyma.cx/v1alpha1/subscription.go b/components/event-bus/generated/push/listers/eventing.kyma.cx/v1alpha1/subscription.go new file mode 100644 index 000000000000..32f1403bed7d --- /dev/null +++ b/components/event-bus/generated/push/listers/eventing.kyma.cx/v1alpha1/subscription.go @@ -0,0 +1,78 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// SubscriptionLister helps list Subscriptions. +type SubscriptionLister interface { + // List lists all Subscriptions in the indexer. + List(selector labels.Selector) (ret []*v1alpha1.Subscription, err error) + // Subscriptions returns an object that can list and get Subscriptions. + Subscriptions(namespace string) SubscriptionNamespaceLister + SubscriptionListerExpansion +} + +// subscriptionLister implements the SubscriptionLister interface. +type subscriptionLister struct { + indexer cache.Indexer +} + +// NewSubscriptionLister returns a new SubscriptionLister. +func NewSubscriptionLister(indexer cache.Indexer) SubscriptionLister { + return &subscriptionLister{indexer: indexer} +} + +// List lists all Subscriptions in the indexer. +func (s *subscriptionLister) List(selector labels.Selector) (ret []*v1alpha1.Subscription, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.Subscription)) + }) + return ret, err +} + +// Subscriptions returns an object that can list and get Subscriptions. +func (s *subscriptionLister) Subscriptions(namespace string) SubscriptionNamespaceLister { + return subscriptionNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// SubscriptionNamespaceLister helps list and get Subscriptions. +type SubscriptionNamespaceLister interface { + // List lists all Subscriptions in the indexer for a given namespace. + List(selector labels.Selector) (ret []*v1alpha1.Subscription, err error) + // Get retrieves the Subscription from the indexer for a given namespace and name. + Get(name string) (*v1alpha1.Subscription, error) + SubscriptionNamespaceListerExpansion +} + +// subscriptionNamespaceLister implements the SubscriptionNamespaceLister +// interface. +type subscriptionNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all Subscriptions in the indexer for a given namespace. +func (s subscriptionNamespaceLister) List(selector labels.Selector) (ret []*v1alpha1.Subscription, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.Subscription)) + }) + return ret, err +} + +// Get retrieves the Subscription from the indexer for a given namespace and name. +func (s subscriptionNamespaceLister) Get(name string) (*v1alpha1.Subscription, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha1.Resource("subscription"), name) + } + return obj.(*v1alpha1.Subscription), nil +} diff --git a/components/event-bus/generated/push/listers/eventing.kyma.io/v1alpha1/expansion_generated.go b/components/event-bus/generated/push/listers/eventing.kyma.io/v1alpha1/expansion_generated.go new file mode 100644 index 000000000000..c50dc84ac455 --- /dev/null +++ b/components/event-bus/generated/push/listers/eventing.kyma.io/v1alpha1/expansion_generated.go @@ -0,0 +1,11 @@ +// This file was automatically generated by lister-gen + +package v1alpha1 + +// SubscriptionListerExpansion allows custom methods to be added to +// SubscriptionLister. +type SubscriptionListerExpansion interface{} + +// SubscriptionNamespaceListerExpansion allows custom methods to be added to +// SubscriptionNamespaceLister. +type SubscriptionNamespaceListerExpansion interface{} diff --git a/components/event-bus/generated/push/listers/eventing.kyma.io/v1alpha1/subscription.go b/components/event-bus/generated/push/listers/eventing.kyma.io/v1alpha1/subscription.go new file mode 100644 index 000000000000..0a62b5dff48a --- /dev/null +++ b/components/event-bus/generated/push/listers/eventing.kyma.io/v1alpha1/subscription.go @@ -0,0 +1,78 @@ +// This file was automatically generated by lister-gen + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// SubscriptionLister helps list Subscriptions. +type SubscriptionLister interface { + // List lists all Subscriptions in the indexer. + List(selector labels.Selector) (ret []*v1alpha1.Subscription, err error) + // Subscriptions returns an object that can list and get Subscriptions. + Subscriptions(namespace string) SubscriptionNamespaceLister + SubscriptionListerExpansion +} + +// subscriptionLister implements the SubscriptionLister interface. +type subscriptionLister struct { + indexer cache.Indexer +} + +// NewSubscriptionLister returns a new SubscriptionLister. +func NewSubscriptionLister(indexer cache.Indexer) SubscriptionLister { + return &subscriptionLister{indexer: indexer} +} + +// List lists all Subscriptions in the indexer. +func (s *subscriptionLister) List(selector labels.Selector) (ret []*v1alpha1.Subscription, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.Subscription)) + }) + return ret, err +} + +// Subscriptions returns an object that can list and get Subscriptions. +func (s *subscriptionLister) Subscriptions(namespace string) SubscriptionNamespaceLister { + return subscriptionNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// SubscriptionNamespaceLister helps list and get Subscriptions. +type SubscriptionNamespaceLister interface { + // List lists all Subscriptions in the indexer for a given namespace. + List(selector labels.Selector) (ret []*v1alpha1.Subscription, err error) + // Get retrieves the Subscription from the indexer for a given namespace and name. + Get(name string) (*v1alpha1.Subscription, error) + SubscriptionNamespaceListerExpansion +} + +// subscriptionNamespaceLister implements the SubscriptionNamespaceLister +// interface. +type subscriptionNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all Subscriptions in the indexer for a given namespace. +func (s subscriptionNamespaceLister) List(selector labels.Selector) (ret []*v1alpha1.Subscription, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.Subscription)) + }) + return ret, err +} + +// Get retrieves the Subscription from the indexer for a given namespace and name. +func (s subscriptionNamespaceLister) Get(name string) (*v1alpha1.Subscription, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha1.Resource("subscription"), name) + } + return obj.(*v1alpha1.Subscription), nil +} diff --git a/components/event-bus/generated/push/listers/eventing.ysf.io/v1alpha1/expansion_generated.go b/components/event-bus/generated/push/listers/eventing.ysf.io/v1alpha1/expansion_generated.go new file mode 100644 index 000000000000..c50dc84ac455 --- /dev/null +++ b/components/event-bus/generated/push/listers/eventing.ysf.io/v1alpha1/expansion_generated.go @@ -0,0 +1,11 @@ +// This file was automatically generated by lister-gen + +package v1alpha1 + +// SubscriptionListerExpansion allows custom methods to be added to +// SubscriptionLister. +type SubscriptionListerExpansion interface{} + +// SubscriptionNamespaceListerExpansion allows custom methods to be added to +// SubscriptionNamespaceLister. +type SubscriptionNamespaceListerExpansion interface{} diff --git a/components/event-bus/generated/push/listers/eventing.ysf.io/v1alpha1/subscription.go b/components/event-bus/generated/push/listers/eventing.ysf.io/v1alpha1/subscription.go new file mode 100644 index 000000000000..0a62b5dff48a --- /dev/null +++ b/components/event-bus/generated/push/listers/eventing.ysf.io/v1alpha1/subscription.go @@ -0,0 +1,78 @@ +// This file was automatically generated by lister-gen + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// SubscriptionLister helps list Subscriptions. +type SubscriptionLister interface { + // List lists all Subscriptions in the indexer. + List(selector labels.Selector) (ret []*v1alpha1.Subscription, err error) + // Subscriptions returns an object that can list and get Subscriptions. + Subscriptions(namespace string) SubscriptionNamespaceLister + SubscriptionListerExpansion +} + +// subscriptionLister implements the SubscriptionLister interface. +type subscriptionLister struct { + indexer cache.Indexer +} + +// NewSubscriptionLister returns a new SubscriptionLister. +func NewSubscriptionLister(indexer cache.Indexer) SubscriptionLister { + return &subscriptionLister{indexer: indexer} +} + +// List lists all Subscriptions in the indexer. +func (s *subscriptionLister) List(selector labels.Selector) (ret []*v1alpha1.Subscription, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.Subscription)) + }) + return ret, err +} + +// Subscriptions returns an object that can list and get Subscriptions. +func (s *subscriptionLister) Subscriptions(namespace string) SubscriptionNamespaceLister { + return subscriptionNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// SubscriptionNamespaceLister helps list and get Subscriptions. +type SubscriptionNamespaceLister interface { + // List lists all Subscriptions in the indexer for a given namespace. + List(selector labels.Selector) (ret []*v1alpha1.Subscription, err error) + // Get retrieves the Subscription from the indexer for a given namespace and name. + Get(name string) (*v1alpha1.Subscription, error) + SubscriptionNamespaceListerExpansion +} + +// subscriptionNamespaceLister implements the SubscriptionNamespaceLister +// interface. +type subscriptionNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all Subscriptions in the indexer for a given namespace. +func (s subscriptionNamespaceLister) List(selector labels.Selector) (ret []*v1alpha1.Subscription, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.Subscription)) + }) + return ret, err +} + +// Get retrieves the Subscription from the indexer for a given namespace and name. +func (s subscriptionNamespaceLister) Get(name string) (*v1alpha1.Subscription, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha1.Resource("subscription"), name) + } + return obj.(*v1alpha1.Subscription), nil +} diff --git a/components/event-bus/hack/boilerplate/boilerplate.go.txt b/components/event-bus/hack/boilerplate/boilerplate.go.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/components/event-bus/hack/update-client-gen.sh b/components/event-bus/hack/update-client-gen.sh new file mode 100755 index 000000000000..78f45f2270b5 --- /dev/null +++ b/components/event-bus/hack/update-client-gen.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +set -o errexit +set -o nounset +set -o pipefail + +vendor/k8s.io/code-generator/generate-groups.sh all \ + github.com/kyma-project/kyma/components/event-bus/generated/push \ + github.com/kyma-project/kyma/components/event-bus/api/push \ + "eventing.kyma.cx:v1alpha1" \ + --go-header-file hack/boilerplate/boilerplate.go.txt diff --git a/components/event-bus/hack/update-ea-client-gen.sh b/components/event-bus/hack/update-ea-client-gen.sh new file mode 100755 index 000000000000..6e010a74c096 --- /dev/null +++ b/components/event-bus/hack/update-ea-client-gen.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +set -o errexit +set -o nounset +set -o pipefail + +vendor/k8s.io/code-generator/generate-groups.sh all \ + github.com/kyma-project/kyma/components/event-bus/generated/ea \ + github.com/kyma-project/kyma/components/event-bus/internal/ea/apis \ + "remoteenvironment.kyma.cx:v1alpha1" \ + --go-header-file hack/boilerplate/boilerplate.go.txt diff --git a/components/event-bus/internal/common/common.go b/components/event-bus/internal/common/common.go new file mode 100644 index 000000000000..bb07bf44dd14 --- /dev/null +++ b/components/event-bus/internal/common/common.go @@ -0,0 +1,92 @@ +package common + +import ( + "fmt" + "strings" + "sync" + + api "github.com/kyma-project/kyma/components/event-bus/api/publish" + "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" +) + +type EventDetails struct { + eventType string + eventTypeVersion string + source *source +} + +type source struct { + sourceEnvironment string + sourceNamespace string + sourceType string +} + +func FromPublishRequest(r *api.PublishRequest) *EventDetails { + sourceStruct := &source{ + sourceEnvironment: r.Source.SourceEnvironment, + sourceNamespace: r.Source.SourceNamespace, + sourceType: r.Source.SourceType, + } + return &EventDetails{ + eventType: r.EventType, + eventTypeVersion: r.EventTypeVersion, + source: sourceStruct, + } +} + +//Replace all occurrences of the `.` with `\.` as well as `\` with `\\` +func escapePeriodsAndBackSlashes(in *string) string { + s := strings.Replace(*in, `\`, `\\`, -1) + return strings.Replace(s, `.`, `\.`, -1) +} + +/* +Encode formats the event details into a NATS streaming compliant subject name literal. +Encoded subject is constructed by using the Period (`.`) character be added between tokens as a delimeter. +Period character in a token literal will be escaped with a forward slash (`\.`), ex: `env.prod` will be `env\.prod`. +Return value is astring literal composed of the event details tokens as: `sourceEnvironment + sourceNamespace + sourceType + eventType + eventTypeVersion`. +*/ +func (e *EventDetails) Encode() string { + return fmt.Sprintf(`%s.%s.%s.%s.%s`, + escapePeriodsAndBackSlashes(&e.source.sourceEnvironment), + escapePeriodsAndBackSlashes(&e.source.sourceNamespace), + escapePeriodsAndBackSlashes(&e.source.sourceType), + escapePeriodsAndBackSlashes(&e.eventType), + escapePeriodsAndBackSlashes(&e.eventTypeVersion)) +} + +func FromSubscriptionSpec(s v1alpha1.SubscriptionSpec) *EventDetails { + sourceStruct := &source{ + sourceEnvironment: s.Source.SourceEnvironment, + sourceNamespace: s.Source.SourceNamespace, + sourceType: s.Source.SourceType, + } + return &EventDetails{ + eventType: s.EventType, + eventTypeVersion: s.EventTypeVersion, + source: sourceStruct, + } +} + +type StatusReady struct { + mu sync.RWMutex + ready bool +} + +func (s *StatusReady) SetReady() bool { + s.mu.Lock() + defer s.mu.Unlock() + if !s.ready { + s.ready = true + return true + } + return false +} + +func (s *StatusReady) SetNotReady() { + s.mu.Lock() + defer s.mu.Unlock() + if s.ready { + s.ready = false + } +} diff --git a/components/event-bus/internal/common/common_test.go b/components/event-bus/internal/common/common_test.go new file mode 100644 index 000000000000..0d3303e3a322 --- /dev/null +++ b/components/event-bus/internal/common/common_test.go @@ -0,0 +1,76 @@ +package common + +import ( + "testing" +) + +func TestEventDetails_Encode(t *testing.T) { + type fields struct { + eventType string + eventTypeVersion string + source *source + } + tests := []struct { + name string + fields fields + want string + }{ + {name: "event1", + fields: fields{ + eventType: "order.created", + eventTypeVersion: "v1", + source: &source{ + sourceEnvironment: "prod", + sourceNamespace: "local.kyma.commerce", + sourceType: "ec", + }, + }, + want: `prod.com\.sap\.hybris.ec.order\.created.v1`}, + {name: "event2", + fields: fields{ + eventType: "order.created", + eventTypeVersion: "v1", + source: &source{ + sourceEnvironment: "prod.com", + sourceNamespace: "sap.hybris", + sourceType: "ec", + }, + }, + want: `prod\.com.sap\.hybris.ec.order\.created.v1`}, + {name: "event3", + fields: fields{ + eventType: "order.created.v1", + eventTypeVersion: "", + source: &source{ + sourceEnvironment: "local.kyma.commerce.prod", + sourceNamespace: "local.kyma.commerce", + sourceType: "ec", + }, + }, + want: `com\.sap\.hybris\.prod.com\.sap\.hybris.ec.order\.created\.v1.`}, + {name: "event4", + fields: fields{ + eventType: `order\.created`, + eventTypeVersion: "v1", + source: &source{ + sourceEnvironment: `prod\`, + sourceNamespace: `com.sap.\hybris`, + sourceType: "ec", + }, + }, + want: `prod\\.com\.sap\.\\hybris.ec.order\\\.created.v1`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := &EventDetails{ + eventType: tt.fields.eventType, + eventTypeVersion: tt.fields.eventTypeVersion, + source: tt.fields.source, + } + if got := e.Encode(); got != tt.want { + t.Errorf("\nat %v EventDetails.Encode() = %v, want %v", tt.name, got, tt.want) + } + }) + } +} diff --git a/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/register.go b/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/register.go new file mode 100644 index 000000000000..a563cd60fc23 --- /dev/null +++ b/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/register.go @@ -0,0 +1,5 @@ +package remoteenvironmentkymaio + +const ( + GroupName = "remoteenvironment.kyma.cx" +) diff --git a/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1/doc.go b/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1/doc.go new file mode 100644 index 000000000000..31565ea5a197 --- /dev/null +++ b/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1/doc.go @@ -0,0 +1,5 @@ +// +k8s:deepcopy-gen=package,register + +// Package v1alpha1 is the v1alpha1 version of the API. +// +groupName=remoteenvironment.kyma.cx +package v1alpha1 diff --git a/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1/register.go b/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1/register.go new file mode 100644 index 000000000000..ec50223198f9 --- /dev/null +++ b/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1/register.go @@ -0,0 +1,41 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + remoteenvironmentkymaio "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx" +) + +// SchemeGroupVersion is group version used to register these objects +var SchemeGroupVersion = schema.GroupVersion{Group: remoteenvironmentkymaio.GroupName, Version: "v1alpha1"} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} + +var ( + // localSchemeBuilder and AddToScheme will stay in k8s.io/kubernetes. + SchemeBuilder runtime.SchemeBuilder + localSchemeBuilder = &SchemeBuilder + AddToScheme = localSchemeBuilder.AddToScheme +) + +func init() { + // We only register manually written functions here. The registration of the + // generated functions takes place in the generated files. The separation + // makes the code compile even when the generated files are missing. + localSchemeBuilder.Register(addKnownTypes) +} + +// Adds the list of known types to api.Scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &EventActivation{}, + &EventActivationList{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1/types.go b/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1/types.go new file mode 100644 index 000000000000..2726cc920b74 --- /dev/null +++ b/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1/types.go @@ -0,0 +1,39 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +genclient +// +genclient:noStatus +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// EventActivation describes an EventActivation. +type EventActivation struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + EventActivationSpec `json:"spec"` +} + +// EventActivationSpec for Event Activation of EventBus +type EventActivationSpec struct { + DisplayName string `json:"displayName"` + Source Source `json:"source"` +} + +type Source struct { + Environment string `json:"environment"` + Type string `json:"type"` + Namespace string `json:"namespace"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// EventActivationList is a list of EventActivation resources +type EventActivationList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + Items []EventActivation `json:"items"` +} diff --git a/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1/zz_generated.deepcopy.go b/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 000000000000..03cb16d54210 --- /dev/null +++ b/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,102 @@ +// +build !ignore_autogenerated + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + 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 *EventActivation) DeepCopyInto(out *EventActivation) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.EventActivationSpec = in.EventActivationSpec + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventActivation. +func (in *EventActivation) DeepCopy() *EventActivation { + if in == nil { + return nil + } + out := new(EventActivation) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EventActivation) 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 *EventActivationList) DeepCopyInto(out *EventActivationList) { + *out = *in + out.TypeMeta = in.TypeMeta + out.ListMeta = in.ListMeta + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]EventActivation, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventActivationList. +func (in *EventActivationList) DeepCopy() *EventActivationList { + if in == nil { + return nil + } + out := new(EventActivationList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EventActivationList) 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 *EventActivationSpec) DeepCopyInto(out *EventActivationSpec) { + *out = *in + out.Source = in.Source + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventActivationSpec. +func (in *EventActivationSpec) DeepCopy() *EventActivationSpec { + if in == nil { + return nil + } + out := new(EventActivationSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Source) DeepCopyInto(out *Source) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Source. +func (in *Source) DeepCopy() *Source { + if in == nil { + return nil + } + out := new(Source) + in.DeepCopyInto(out) + return out +} diff --git a/components/event-bus/internal/publish/opts.go b/components/event-bus/internal/publish/opts.go new file mode 100644 index 000000000000..922d0cf4c89c --- /dev/null +++ b/components/event-bus/internal/publish/opts.go @@ -0,0 +1,93 @@ +package publish + +import ( + "flag" + "log" + "os" + + "github.com/kyma-project/kyma/components/event-bus/internal/trace" + "github.com/nats-io/go-nats-streaming" +) + +var version = os.Getenv("APP_VERSION") + +const ( + defaultPort = 8080 + defaultNatsURL = stan.DefaultNatsURL + defaultClientID = "kyma-publish" + defaultMaxRequests = 100 + defaultNatsStreamingClusterID = "kyma-nats-streaming" + defaultTraceAPIURL = "http://localhost:9411/api/v1/spans" + defaultTraceHostPort = "0.0.0.0:0" + defaultServiceName = "publish-service" + defaultOperationName = "publish-to-NATS" +) + +type Options struct { + Port int + NatsURL string + ClientID string + NoOfConcurrentRequests int + NatsStreamingClusterID string + TraceRequests bool + TraceAPIURL string + TraceHostPort string + ServiceName string + TraceDebug bool + OperationName string +} + +func ParseFlags() *Options { + fs := flag.NewFlagSet("publish", flag.ExitOnError) + opts, err := configureOptions(fs, os.Args[1:]) + + if err != nil { + log.Fatalf("failed to parse command line flags: %v", err.Error()) + } + + return opts +} + +func configureOptions(fs *flag.FlagSet, args []string) (*Options, error) { + opts := DefaultOptions() + var showHelp bool + + fs.IntVar(&opts.Port, "port", defaultPort, "The publish listen port") + fs.StringVar(&opts.NatsURL, "nats_url", defaultNatsURL, "The NATS URL") + fs.StringVar(&opts.ClientID, "client_id", defaultClientID, "client ID to use") + fs.IntVar(&opts.NoOfConcurrentRequests, "max_requests", defaultMaxRequests, "The max number of accepted concurrent requests") + fs.StringVar(&opts.NatsStreamingClusterID, "nats_streaming_cluster_id", defaultNatsStreamingClusterID, "The NATS Streaming cluster id") + fs.BoolVar(&opts.TraceRequests, "trace", false, "Log verbosily the received HTTP requests traces.") + fs.BoolVar(&showHelp, "showHelp", false, "Print the command line options") + fs.StringVar(&opts.TraceAPIURL, "trace_api_url", defaultTraceAPIURL, "Trace API URL") + fs.StringVar(&opts.TraceHostPort, "trace_host_port", defaultTraceHostPort, "Trace host port") + fs.StringVar(&opts.ServiceName, "service_name", defaultServiceName, "Publish service name") + fs.StringVar(&opts.OperationName, "operation_name", defaultOperationName, "Publish operation name") + fs.BoolVar(&opts.TraceDebug, "trace_debug", false, "Trace debug") + fs.StringVar(&opts.OperationName, "trace_operation_name", trace.DefaultTraceOperationName, "Publish operation name") + fs.StringVar(&opts.ServiceName, "trace_service_name", trace.DefaultTraceServiceName, "Publish service name") + + if err := fs.Parse(args); err != nil { + return nil, err + } + + if showHelp { + flag.PrintDefaults() + os.Exit(0) + } + return opts, nil +} + +func DefaultOptions() *Options { + return &Options{ + Port: defaultPort, + ClientID: defaultClientID, + NoOfConcurrentRequests: defaultMaxRequests, + OperationName: defaultOperationName, + ServiceName: defaultServiceName, + TraceHostPort: defaultTraceHostPort, + TraceAPIURL: defaultTraceAPIURL, + NatsStreamingClusterID: defaultNatsStreamingClusterID, + NatsURL: defaultNatsURL, + } +} diff --git a/components/event-bus/internal/publish/util.go b/components/event-bus/internal/publish/util.go new file mode 100644 index 000000000000..33b83154b382 --- /dev/null +++ b/components/event-bus/internal/publish/util.go @@ -0,0 +1,15 @@ +package publish + +import ( + "encoding/json" + "net/http" + + api "github.com/kyma-project/kyma/components/event-bus/api/publish" +) + +//SendJSONError sends an HTTP response containing a JSON error +func SendJSONError(w http.ResponseWriter, err *api.Error) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader((*err).Status) + return json.NewEncoder(w).Encode(*err) +} diff --git a/components/event-bus/internal/push/actors/subscriptions_supervisor.go b/components/event-bus/internal/push/actors/subscriptions_supervisor.go new file mode 100644 index 000000000000..0ad1b68510ab --- /dev/null +++ b/components/event-bus/internal/push/actors/subscriptions_supervisor.go @@ -0,0 +1,204 @@ +package actors + +import ( + "log" + "time" + + "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + "github.com/kyma-project/kyma/components/event-bus/internal/common" + "github.com/kyma-project/kyma/components/event-bus/internal/push/handlers" + "github.com/kyma-project/kyma/components/event-bus/internal/push/opts" + "github.com/kyma-project/kyma/components/event-bus/internal/stanutil" + trc "github.com/kyma-project/kyma/components/event-bus/internal/trace" + "github.com/nats-io/go-nats" + "github.com/nats-io/go-nats-streaming" + "k8s.io/apimachinery/pkg/types" +) + +type subscriptionCRWithNATSStreamingSubscription struct { + subscriptionCR *v1alpha1.Subscription + natsStreamingSubscription *stan.Subscription +} + +const ( + actionChannelSize = 16 + retryConnectionAndSubscriptionsInterval = 30 * time.Second +) + +type action func() + +type SubscriptionsSupervisorInterface interface { + PoisonPill() + IsRunning() bool + IsNATSConnected() bool + StartSubscriptionReq(sub *v1alpha1.Subscription) + StopSubscriptionReq(sub *v1alpha1.Subscription) +} + +// SubscriptionsSupervisor manages the state of NATS Streaming subscriptions +type SubscriptionsSupervisor struct { + running bool + actionChan chan action + opts *opts.Options + stanConn *stan.Conn + subscriptions map[types.UID]*subscriptionCRWithNATSStreamingSubscription + mhf *handlers.MessageHandlerFactory +} + +// StartSubscriptionsSupervisor starts supervisor actor +func StartSubscriptionsSupervisor(opts *opts.Options, tracer *trc.Tracer) *SubscriptionsSupervisor { + log.Print("starting subscriptions supervisor actor") + ch := make(chan action, actionChannelSize) + mhf := handlers.NewMessageHandlerFactory(opts, tracer) + s := &SubscriptionsSupervisor{running: true, actionChan: ch, opts: opts, mhf: mhf} + + s.initialize() + go s.actorLoop(ch) + return s +} + +func (s *SubscriptionsSupervisor) initialize() { + log.Print("initializing subscriptions supervisor actor") + s.subscriptions = make(map[types.UID]*subscriptionCRWithNATSStreamingSubscription) + + s.connectToNATSStreamingCluster() +} + +func (s *SubscriptionsSupervisor) connectToNATSStreamingCluster() { + var err error + + if s.stanConn, err = stanutil.Connect(s.opts.NatsStreamingClusterID, s.opts.ClientID, s.opts.NatsURL); err != nil { + log.Fatalf("Can't connect to NATS Streaming: %v\n", err) + } +} + +func pingInterval(i time.Duration) nats.Option { + return func(o *nats.Options) error { + o.PingInterval = i + return nil + } +} + +func (s *SubscriptionsSupervisor) actorLoop(ch <-chan action) { + timer := time.NewTimer(retryConnectionAndSubscriptionsInterval) + + for s.running { + select { + case action := <-ch: + action() + case <-timer.C: + s.retryNATSStreamingSubscriptions() + timer = time.NewTimer(retryConnectionAndSubscriptionsInterval) // TODO improve, schedule new retry only if needed + } + } +} + +// PoisonPill requests actor to stop +func (s *SubscriptionsSupervisor) PoisonPill() { + s.actionChan <- func() { + s.running = false + close(s.actionChan) + stanutil.Close(s.stanConn) + } +} + +// IsRunning returns the status of supervisor actor +func (s *SubscriptionsSupervisor) IsRunning() bool { + resultChan := make(chan bool, 0) + s.actionChan <- func() { + resultChan <- s.running + } + return <-resultChan +} + +// IsNATSConnected returns the status of NATS connection +func (s *SubscriptionsSupervisor) IsNATSConnected() bool { + resultChan := make(chan bool, 0) + s.actionChan <- func() { + resultChan <- stanutil.IsConnected(s.stanConn) + } + return <-resultChan +} + +// ReconnectToNATSStreaming .... +func (s *SubscriptionsSupervisor) ReconnectToNATSStreaming() { + s.actionChan <- func() { + log.Println("ReconnectToNATSStreaming() :: try to reconnet to NATS Streaming") + stanutil.Close(s.stanConn) + stanutil.Connect(s.opts.NatsStreamingClusterID, s.opts.ClientID, s.opts.NatsURL) + } +} + +// StartSubscriptionReq message models the request to start handling EventBus subscription +func (s *SubscriptionsSupervisor) StartSubscriptionReq(sub *v1alpha1.Subscription) { + s.actionChan <- func() { + log.Print("handling EventBus Subscription start request") + if val, present := s.subscriptions[sub.UID]; !present || val == nil { + s.subscriptions[sub.UID] = &subscriptionCRWithNATSStreamingSubscription{subscriptionCR: sub} + s.retryNATSStreamingSubscriptions() + } else { // present && val != nil + if val.natsStreamingSubscription != nil { + log.Print("subscription is already being handled") + } else { + log.Print("already aware of the subscription, handling will be retried later") + } + } + } +} + +// StopSubscriptionReq message models the request to stop handling EventBus subscription +func (s *SubscriptionsSupervisor) StopSubscriptionReq(sub *v1alpha1.Subscription) { + s.actionChan <- func() { + log.Printf("handling EventBus Subscription %s stop request", sub.Name) + if val, present := s.subscriptions[sub.UID]; present { + if val.natsStreamingSubscription != nil { + s.stopSubscription(sub) + } + + // cleanup references + delete(s.subscriptions, sub.UID) + } else { + log.Printf("there was no subscription %s", sub.Name) + } + } +} + +func (s *SubscriptionsSupervisor) retryNATSStreamingSubscriptions() { + if s.stanConn != nil { + for _, subscriptionCRWithNATSStreamingSubscription := range s.subscriptions { + if subscriptionCRWithNATSStreamingSubscription.natsStreamingSubscription == nil { + s.handleSubscription(subscriptionCRWithNATSStreamingSubscription.subscriptionCR) + } + } + } +} + +func (s *SubscriptionsSupervisor) stopSubscription(sub *v1alpha1.Subscription) { + log.Printf("Stopping handling EventBus subscription %s", sub.Name) + // Close NATS Streaming subscription for the EventBus subscription being stopped + subscriptionCRWithNATSStreamingSubscription := s.subscriptions[sub.UID] + if err := (*subscriptionCRWithNATSStreamingSubscription.natsStreamingSubscription).Unsubscribe(); err != nil { + log.Printf("error unsubscribing NATS Streaming subscription: %v", err) + } +} + +func (s *SubscriptionsSupervisor) handleSubscription(sub *v1alpha1.Subscription) { + log.Printf("attempting establishing NATS Streaming subscription for %s", sub.Name) + + msgHandler := s.mhf.NewMsgHandler(sub, s.opts) + topic := common.FromSubscriptionSpec(sub.SubscriptionSpec).Encode() + + natsStreamingSub, err := (*s.stanConn).QueueSubscribe( + topic, s.opts.QueueGroup, msgHandler, + stan.DurableName(sub.Name), + stan.SetManualAckMode(), + stan.AckWait(s.opts.AckWait), + stan.MaxInflight(sub.SubscriptionSpec.MaxInflight)) + + if err != nil { + log.Printf("failed to subscribe: %v", err) + } else { + s.subscriptions[sub.UID].natsStreamingSubscription = &natsStreamingSub + log.Print("established NATS Streaming subscription") + } +} diff --git a/components/event-bus/internal/push/controllers/eventactivation.go b/components/event-bus/internal/push/controllers/eventactivation.go new file mode 100644 index 000000000000..4f23b238781a --- /dev/null +++ b/components/event-bus/internal/push/controllers/eventactivation.go @@ -0,0 +1,47 @@ +package controllers + +import "log" +import ( + subApis "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + "github.com/kyma-project/kyma/components/event-bus/internal/push/actors" +) + +func getUpdateFnWithEventActivationCheck(supervisor actors.SubscriptionsSupervisorInterface) func(oldObj, newObj interface{}) { + return func(oldObj, newObj interface{}) { + if oldObj == newObj { + return + } + + _, oldSubOk := oldObj.(*subApis.Subscription) + newSub, newSubOK := newObj.(*subApis.Subscription) + + if !oldSubOk || !newSubOK { + log.Printf("unknown object type either updated %+v or original +%v", newObj, oldObj) + return + } + + if newSub.HasCondition(subApis.SubscriptionCondition{Type: subApis.EventsActivated, Status: subApis.ConditionFalse}) { + log.Printf("Stop NATS Subscription %+v", newSub) + supervisor.StopSubscriptionReq(newSub) + } + + if newSub.HasCondition(subApis.SubscriptionCondition{Type: subApis.EventsActivated, Status: subApis.ConditionTrue}) { + log.Printf("Start NATS Subscription %+v", newSub) + supervisor.StartSubscriptionReq(newSub) + } + } +} + +func getAddFnWithEventActivationCheck(supervisor actors.SubscriptionsSupervisorInterface) func(obj interface{}) { + return func(obj interface{}) { + subscription, ok := obj.(*subApis.Subscription) + if !ok { + log.Printf("unknown object type added %+v", obj) + return + } + if subscription.HasCondition(subApis.SubscriptionCondition{Type: subApis.EventsActivated, Status: subApis.ConditionTrue}) { + log.Printf("Subscription custom resource created %v", obj) + supervisor.StartSubscriptionReq(subscription) + } + } +} diff --git a/components/event-bus/internal/push/controllers/eventactivation_test.go b/components/event-bus/internal/push/controllers/eventactivation_test.go new file mode 100644 index 000000000000..52683bae8ce5 --- /dev/null +++ b/components/event-bus/internal/push/controllers/eventactivation_test.go @@ -0,0 +1,78 @@ +package controllers + +import ( + "testing" + + "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + "github.com/stretchr/testify/mock" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newFakeSubWithStatus(name string, status v1alpha1.SubscriptionStatus) *v1alpha1.Subscription { + return &v1alpha1.Subscription{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Status: status, + } +} + +type MockSupervisior struct { + mock.Mock +} + +func (m *MockSupervisior) PoisonPill() {} + +func (m *MockSupervisior) IsRunning() bool { return true } + +func (m *MockSupervisior) IsNATSConnected() bool { return true } + +func (m *MockSupervisior) StartSubscriptionReq(sub *v1alpha1.Subscription) { + m.Called(sub) +} + +func (m *MockSupervisior) StopSubscriptionReq(sub *v1alpha1.Subscription) { + m.Called(sub) +} + +func Test_UpdateFunction(t *testing.T) { + mockSupervisor := &MockSupervisior{} + updateFunction := getUpdateFnWithEventActivationCheck(mockSupervisor) + + orgSub := newFakeSubWithStatus("test-update", v1alpha1.SubscriptionStatus{}) + + subWithEventActivation := verifyStartSubCalledOnEventActivation(orgSub, mockSupervisor, updateFunction, t) + + verifyStopSubCalledOnEventDeactivation(subWithEventActivation, mockSupervisor, updateFunction, t) + +} +func verifyStartSubCalledOnEventActivation(orgSub *v1alpha1.Subscription, mockSupervisor *MockSupervisior, updateFunction func(oldObj, newObj interface{}), t *testing.T) *v1alpha1.Subscription { + activeStatus := v1alpha1.SubscriptionStatus{ + Conditions: []v1alpha1.SubscriptionCondition{ + { + Type: v1alpha1.EventsActivated, + Status: v1alpha1.ConditionTrue, + }, + }, + } + subWithEventsActivation := orgSub.DeepCopy() + subWithEventsActivation.Status = activeStatus + mockSupervisor.On("StartSubscriptionReq", subWithEventsActivation).Return() + updateFunction(orgSub, subWithEventsActivation) + mockSupervisor.AssertCalled(t, "StartSubscriptionReq", subWithEventsActivation) + return subWithEventsActivation +} + +func verifyStopSubCalledOnEventDeactivation(orgSub *v1alpha1.Subscription, mockSupervisor *MockSupervisior, updateFunction func(oldObj, newObj interface{}), t *testing.T) { + deactiveStatus := v1alpha1.SubscriptionStatus{ + Conditions: []v1alpha1.SubscriptionCondition{ + { + Type: v1alpha1.EventsActivated, + Status: v1alpha1.ConditionFalse, + }, + }, + } + SubWithEventsDeactivation := orgSub.DeepCopy() + SubWithEventsDeactivation.Status = deactiveStatus + mockSupervisor.On("StopSubscriptionReq", SubWithEventsDeactivation).Return() + updateFunction(orgSub, SubWithEventsDeactivation) + mockSupervisor.AssertCalled(t, "StopSubscriptionReq", SubWithEventsDeactivation) +} diff --git a/components/event-bus/internal/push/controllers/noeventactivation.go b/components/event-bus/internal/push/controllers/noeventactivation.go new file mode 100644 index 000000000000..a72f8a533d72 --- /dev/null +++ b/components/event-bus/internal/push/controllers/noeventactivation.go @@ -0,0 +1,20 @@ +package controllers + +import ( + "log" + + subscriptionApis "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + "github.com/kyma-project/kyma/components/event-bus/internal/push/actors" +) + +func getAddFnWithoutEventActivationCheck(supervisor actors.SubscriptionsSupervisorInterface) func(obj interface{}) { + return func(obj interface{}) { + subscription, ok := obj.(*subscriptionApis.Subscription) + if ok { + log.Printf("Added Subscription %+v", subscription) + supervisor.StartSubscriptionReq(subscription) + } else { + log.Printf("unknown object type added %+v", obj) + } + } +} diff --git a/components/event-bus/internal/push/controllers/subscriptions_controller.go b/components/event-bus/internal/push/controllers/subscriptions_controller.go new file mode 100644 index 000000000000..2bc4b35647be --- /dev/null +++ b/components/event-bus/internal/push/controllers/subscriptions_controller.go @@ -0,0 +1,100 @@ +package controllers + +import ( + "log" + + "time" + + subscriptionApis "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + subscriptionClientSet "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned" + "github.com/kyma-project/kyma/components/event-bus/generated/push/informers/externalversions/eventing.kyma.cx/v1alpha1" + "github.com/kyma-project/kyma/components/event-bus/internal/push/actors" + "github.com/kyma-project/kyma/components/event-bus/internal/push/opts" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/cache" +) + +// SubscriptionsController observes Subscription CRs and ensures that for each there is matching NATS Streaming subscription +type SubscriptionsController struct { + informer cache.SharedIndexInformer + supervisor actors.SubscriptionsSupervisorInterface + stopChannel chan struct{} +} + +// StartSubscriptionsController is a factory method for the SubscriptionsController +func StartSubscriptionsController(supervisor *actors.SubscriptionsSupervisor, pushOptions *opts.Options) *SubscriptionsController { + informer := createSubscriptionsInformer() + return StartSubscriptionsControllerWithInformer(supervisor, informer, pushOptions) +} + +// StartSubscriptionsControllerWithInformer is a factory for the SubscriptionsController which method uses the specified informer +func StartSubscriptionsControllerWithInformer(supervisor *actors.SubscriptionsSupervisor, informer cache.SharedIndexInformer, pushOptions *opts.Options) *SubscriptionsController { + stopChan := make(chan struct{}) + + controller := &SubscriptionsController{ + informer: informer, + supervisor: supervisor, + stopChannel: stopChan, + } + + if pushOptions.CheckEventsActivation { + controller.startInformerWithEventActivationCheck() + } else { + controller.startInformerWithoutEventActivationCheck() + } + + return controller +} + +func createSubscriptionsInformer() cache.SharedIndexInformer { + config, err := rest.InClusterConfig() + if err != nil { + log.Panicf("Error in getting cluster config - %+v", err) + } + subscriptionClient, err := subscriptionClientSet.NewForConfig(config) + if err != nil { + log.Panicf("Error in creating client - %+v", err) + } + informer := v1alpha1.NewSubscriptionInformer(subscriptionClient, metav1.NamespaceAll, 1*time.Minute, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}) + return informer +} + +// Stop halts the SubscriptionsController +func (controller *SubscriptionsController) Stop() { + controller.stopChannel <- struct{}{} +} + +func (controller *SubscriptionsController) startInformerWithoutEventActivationCheck() { + controller.informer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: getAddFnWithoutEventActivationCheck(controller.supervisor), + UpdateFunc: func(oldObj, newObj interface{}) { + //log.Print("Updated") + }, + DeleteFunc: controller.getDeleteFn(), + }) + + go controller.informer.Run(controller.stopChannel) +} + +func (controller *SubscriptionsController) startInformerWithEventActivationCheck() { + controller.informer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: getAddFnWithEventActivationCheck(controller.supervisor), + UpdateFunc: getUpdateFnWithEventActivationCheck(controller.supervisor), + DeleteFunc: controller.getDeleteFn(), + }) + + go controller.informer.Run(controller.stopChannel) +} + +func (controller *SubscriptionsController) getDeleteFn() func(obj interface{}) { + return func(obj interface{}) { + subscription, ok := obj.(*subscriptionApis.Subscription) + if ok { + log.Printf("Deleted Subscription %+v", subscription) + controller.supervisor.StopSubscriptionReq(subscription) + } else { + log.Printf("unknown object type deleted %+v", obj) + } + } +} diff --git a/components/event-bus/internal/push/handlers/message_handler.go b/components/event-bus/internal/push/handlers/message_handler.go new file mode 100644 index 000000000000..18cdf613385e --- /dev/null +++ b/components/event-bus/internal/push/handlers/message_handler.go @@ -0,0 +1,211 @@ +package handlers + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "log" + "net/http" + "time" + + api "github.com/kyma-project/kyma/components/event-bus/api/publish" + "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + "github.com/kyma-project/kyma/components/event-bus/internal/common" + "github.com/kyma-project/kyma/components/event-bus/internal/push/opts" + trc "github.com/kyma-project/kyma/components/event-bus/internal/trace" + "github.com/nats-io/go-nats-streaming" + "github.com/opentracing/opentracing-go" + "github.com/opentracing/opentracing-go/ext" +) + +const ( + // push request headers to endpoint + headerSourceNamespace = "KYMA-Source-Namespace" + headerSourceType = "KYMA-Source-Type" + headerSourceEnvironment = "KYMA-Source-Environment" + headerEventType = "KYMA-Event-Type" + headerEventTypeVersion = "KYMA-Event-Type-Version" + headerEventID = "KYMA-Event-ID" + headerEventTime = "KYMA-Event-Time" +) + +type MessageHandlerFactory struct { + tr *http.Transport + tracer *trc.Tracer +} + +func NewMessageHandlerFactory(opts *opts.Options, tracer *trc.Tracer) *MessageHandlerFactory { + tr := initHTTPTransport(opts) + return &MessageHandlerFactory{ + tr: tr, + tracer: tracer, + } +} + +func initHTTPTransport(opts *opts.Options) *http.Transport { + return &http.Transport{ + MaxIdleConns: opts.MaxIdleConns, + IdleConnTimeout: opts.IdleConnTimeout, + DisableCompression: true, + TLSClientConfig: &tls.Config{InsecureSkipVerify: opts.TLSSkipVerify}, + } +} + +func (mhf *MessageHandlerFactory) NewMsgHandler(sub *v1alpha1.Subscription, opts *opts.Options) func(msg *stan.Msg) { + + client := &http.Client{ + Transport: mhf.tr, + Timeout: time.Duration(sub.PushRequestTimeoutMS) * time.Millisecond, + } + + msgHandler := func(msg *stan.Msg) { + var payload []byte + cloudEvent, err := convertToCloudEvent(&msg.Data) + if err == nil { // message is cloud event compliant, send the data field as the payload if possible + var marshallError error + payload, marshallError = json.Marshal(cloudEvent.Data) + if marshallError != nil { // data is not valid JSON type value, send the message as it is + payload = msg.Data + } + } else { // message is not cloud event compliant, send the message as it is + payload = msg.Data + } + + req, err := http.NewRequest(http.MethodPost, sub.Endpoint, bytes.NewBuffer(payload)) + if err != nil { + panic(fmt.Sprintf("push HTTP request creation failed: %v", err)) + } + req.Header.Add("Content-Type", "application/json") + + subNameSupplier := func() string { return sub.Name } + addOptionalHeader(req, sub.IncludeSubscriptionNameHeader, opts.SubscriptionNameHeader, subNameSupplier) + + topicNameSupplier := func() string { return common.FromSubscriptionSpec(sub.SubscriptionSpec).Encode() } + addOptionalHeader(req, sub.IncludeTopicHeader, opts.TopicHeader, topicNameSupplier) + + var pushSpan *opentracing.Span + if cloudEvent != nil { + if traceContext := getTraceContext(cloudEvent); traceContext != nil && (*mhf.tracer).Started() { + pushSpan = mhf.createPushSpan(traceContext, cloudEvent, sub) + defer trc.FinishSpan(pushSpan) + + addTraceContext(pushSpan, req) + } + annotateCloudEventHeaders(req, cloudEvent) + } + + resp, err := client.Do(req) + + if resp != nil { + trc.TagSpanWithHttpStatusCode(pushSpan, uint16(resp.StatusCode)) + } + + if err != nil { // failed to send push request + // TODO introduce a metric, counter of message send failure, and update it here + log.Printf("MsgHandler :: Error send push request failed: %v\n", err) + + trc.TagSpanAsError(pushSpan, "failed to send push request", err.Error()) + + // just return from this delivery attempt, message delivery will be retried by NATS Streaming + return + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { // subscriber failed to receive or process the message, returned non-2xx status code + // TODO introduce a metric, counter of delivery failure per response code, and update it here + + trc.TagSpanAsError(pushSpan, "subscriber failed to receive or process the message, returned non-2xx status code", "") + + // just return from this delivery attempt, message delivery will be retried by NATS Streaming + return + } + + if err := msg.Ack(); err != nil { // failed to ACK message to NATS Streaming + // TODO introduce a metric, counter of ACK to NATS Streaming failures, and update it here + log.Printf("MsgHandler :: Error ACK failed: %v\n", err) + + trc.TagSpanAsError(pushSpan, "failed to ACK message to NATS Streaming", err.Error()) + + // just return from this delivery attempt, message delivery will be retried by NATS Streaming + return + } + + // TODO introduce a metric, counter of successful deliveries, and update it here + } + + return msgHandler +} +func annotateCloudEventHeaders(req *http.Request, cloudEvent *api.CloudEvent) { + // add cloud event properties as request headers except the data field + req.Header.Add(headerSourceNamespace, cloudEvent.Source.SourceNamespace) + req.Header.Add(headerSourceType, cloudEvent.Source.SourceType) + req.Header.Add(headerSourceEnvironment, cloudEvent.Source.SourceEnvironment) + req.Header.Add(headerEventType, cloudEvent.EventType) + req.Header.Add(headerEventTypeVersion, cloudEvent.EventTypeVersion) + req.Header.Add(headerEventID, cloudEvent.EventID) + req.Header.Add(headerEventTime, cloudEvent.EventTime) +} + +func (mhf *MessageHandlerFactory) createPushSpan(traceContext *api.TraceContext, cloudEvent *api.CloudEvent, sub *v1alpha1.Subscription) *opentracing.Span { + spanContext := trc.ReadTraceContext(traceContext) + pushSpan := trc.StartSpan(spanContext, &(*mhf.tracer).Options().OperationName, ext.SpanKindConsumer) + addSpanTagsForCloudEventAndSubscription(pushSpan, cloudEvent, sub) + return pushSpan +} + +func addSpanTagsForCloudEventAndSubscription(pushSpan *opentracing.Span, cloudEvent *api.CloudEvent, sub *v1alpha1.Subscription) { + tags := trc.CreateTraceTagsFromCloudEvent(cloudEvent) + tags[trc.SubscriptionName] = sub.Name + tags[trc.SubscriptionEnvironment] = sub.Namespace + trc.SetSpanTags(pushSpan, &tags) +} + +func addTraceContext(span *opentracing.Span, req *http.Request) error { + carrier := opentracing.HTTPHeadersCarrier(req.Header) + return opentracing.GlobalTracer().Inject((*span).Context(), opentracing.HTTPHeaders, carrier) +} + +func addOptionalHeader(request *http.Request, includeHeader bool, key string, valueSupplier func() string) { + if includeHeader { + value := valueSupplier() + request.Header.Add(key, value) + } +} + +func convertToCloudEvent(payload *[]byte) (*api.CloudEvent, error) { + if payload == nil { + return nil, fmt.Errorf("payload is null") + } + + var cloudEvent *api.CloudEvent + err := json.Unmarshal(*payload, &cloudEvent) + + if err != nil { + return nil, err + } + + if err := api.ValidatePublish(&cloudEvent.PublishRequest); err != nil { + return nil, fmt.Errorf("payload is not valid: %v", string(*payload)) + } + + return cloudEvent, nil +} + +func getTraceContext(cloudEvent *api.CloudEvent) *api.TraceContext { + if cloudEvent == nil { + return nil + } + + if traceContextWrapper, present := cloudEvent.Extensions[api.FieldTraceContext]; present { + if traceContextMap, ok := traceContextWrapper.(map[string]interface{}); ok { + traceContext := api.TraceContext{} + for key, value := range traceContextMap { + traceContext[key] = fmt.Sprint(value) + } + return &traceContext + } + } + + return nil +} diff --git a/components/event-bus/internal/push/handlers/message_handler_test.go b/components/event-bus/internal/push/handlers/message_handler_test.go new file mode 100644 index 000000000000..c1ce07b844de --- /dev/null +++ b/components/event-bus/internal/push/handlers/message_handler_test.go @@ -0,0 +1,49 @@ +package handlers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_convertToCloudEvent_Missing_Payload(t *testing.T) { + _, err := convertToCloudEvent(nil) + assert.NotNil(t, err) +} + +func Test_convertToCloudEvent_Invalid_Payload(t *testing.T) { + payload := []byte("{\"key\":\"value\"}") + _, err := convertToCloudEvent(&payload) + assert.NotNil(t, err) +} + +func Test_convertToCloudEvent_Success(t *testing.T) { + payload := []byte("{\"source\":{\"source-namespace\":\"test-source-namespace\",\"source-type\":\"test-source-type\",\"source-environment\":\"test-source-environment\"},\"event-type\":\"test-event-type\",\"event-type-version\":\"v1\",\"event-id\":\"4ea567cf-812b-49d9-a4b2-cb5ddf464094\",\"event-time\":\"2012-11-01T22:08:41+00:00\",\"data\":\"{'key':'value'}\",\"extensions\":{\"trace-context\":{\"x-b3-flags\":\"0\",\"x-b3-parentspanid\":\"2c94f88423efcd96\",\"x-b3-sampled\":\"true\",\"x-b3-spanid\":\"61c8973b3ccb7417\",\"x-b3-traceid\":\"4ef497da28c4bc6a1c0202f1b0e342db\"}}}") + _, err := convertToCloudEvent(&payload) + assert.Nil(t, err) +} + +func Test_getTraceContext_Missing_CloudEvent(t *testing.T) { + traceContext := getTraceContext(nil) + assert.Nil(t, traceContext) +} + +func Test_getTraceContext_Without_TraceContext(t *testing.T) { + payload := []byte("{\"source\":{\"source-namespace\":\"test-source-namespace\",\"source-type\":\"test-source-type\",\"source-environment\":\"test-source-environment\"},\"event-type\":\"test-event-type\",\"event-type-version\":\"v1\",\"event-id\":\"4ea567cf-812b-49d9-a4b2-cb5ddf464094\",\"event-time\":\"2012-11-01T22:08:41+00:00\",\"data\":\"{'key':'value'}\"}") + cloudEvent, err := convertToCloudEvent(&payload) + traceContext := getTraceContext(cloudEvent) + + assert.Nil(t, err) + assert.NotNil(t, cloudEvent) + assert.Nil(t, traceContext) +} + +func Test_getTraceContext_With_TraceContext(t *testing.T) { + payload := []byte("{\"source\":{\"source-namespace\":\"test-source-namespace\",\"source-type\":\"test-source-type\",\"source-environment\":\"test-source-environment\"},\"event-type\":\"test-event-type\",\"event-type-version\":\"v1\",\"event-id\":\"4ea567cf-812b-49d9-a4b2-cb5ddf464094\",\"event-time\":\"2012-11-01T22:08:41+00:00\",\"data\":\"{'key':'value'}\",\"extensions\":{\"trace-context\":{\"x-b3-flags\":\"0\",\"x-b3-parentspanid\":\"2c94f88423efcd96\",\"x-b3-sampled\":\"true\",\"x-b3-spanid\":\"61c8973b3ccb7417\",\"x-b3-traceid\":\"4ef497da28c4bc6a1c0202f1b0e342db\"}}}") + cloudEvent, err := convertToCloudEvent(&payload) + traceContext := getTraceContext(cloudEvent) + + assert.Nil(t, err) + assert.NotNil(t, cloudEvent) + assert.NotNil(t, traceContext) +} diff --git a/components/event-bus/internal/push/opts/opts.go b/components/event-bus/internal/push/opts/opts.go new file mode 100644 index 000000000000..528c331f465a --- /dev/null +++ b/components/event-bus/internal/push/opts/opts.go @@ -0,0 +1,192 @@ +package opts + +import ( + "flag" + "fmt" + "log" + "os" + "strings" + "time" + + "github.com/kyma-project/kyma/components/event-bus/internal/trace" + "github.com/nats-io/go-nats-streaming" +) + +const ( + defaultPort = 8080 + defaultClientID = "event-bus-push" + defaultNatsURL = stan.DefaultNatsURL + defaultConnectWait = stan.DefaultConnectWait + defaultNatsStreamingClusterID = "test-cluster" + defaultAckWait = stan.DefaultAckWait + defaultMaxIdleConns = 2 + defaultIdleConnTimeout = 30 * time.Second + defaultQueueGroup = "event-bus-push" +) + +// version is the current version for the event-bus-push. +var version = os.Getenv("APP_VERSION") + +var usageStr = ` +Usage: event-bus-push [options] +HTTP Server Options: + --port HTTP server port (default: 8080) + +NATS Client Options: + --client_id Client ID to identify the client on the server (default: event-bus-push) (valid characters: alphanumeric, '-', '_') + --nats_url URL NATS client should use to connect to the NATS server (default: nats://localhost:4222) + --connect_wait Timeout used for the connect operation (default: 2s) + +NATS Streaming Client Options: + --cluster_id NATS Streaming cluster ID to connect to (default: test-cluster) + --ack_wait How long the server should wait for an ACK before resending a message (default: 30s) + --max_inflight Maximum number of inflight messages with outstanding ACKs the server can send (default: 1024) + +HTTP Client Options: + --max_idle_conns Maximum number of idle HTTP connections (default: 2) + --idle_conn_timeout Idle HTTP connection timeout (default: 30s) + --tls_skip_verify Skip verifying certificate chain for TLS/HTTPS connections, e.g. to accept self-signed certificates (default: false) + +Common Options: + -h, --help Show this message + -v, --version Show version. +` + +// print out usage instructions. +func usage() { + fmt.Printf("%s\n", usageStr) + os.Exit(0) +} + +// Options represents command line +type Options struct { + Port int + ClientID string + NatsURL string + NatsStreamingClusterID string + ConnectWait time.Duration + QueueGroup string + SubscriptionNameHeader string + TopicHeader string + AckWait time.Duration + MaxIdleConns int + IdleConnTimeout time.Duration + TLSSkipVerify bool + trace.Options + CheckEventsActivation bool + // TODO TLS +} + +var DefaultOptions = Options{ + ClientID: defaultClientID, + NatsURL: defaultNatsURL, + NatsStreamingClusterID: defaultNatsStreamingClusterID, + ConnectWait: defaultConnectWait, + AckWait: defaultAckWait, + MaxIdleConns: defaultMaxIdleConns, + IdleConnTimeout: defaultIdleConnTimeout, + TLSSkipVerify: false, + QueueGroup: defaultQueueGroup, + CheckEventsActivation: false, +} + +// ParseFlags parses command line flags +func ParseFlags() *Options { + fs := flag.NewFlagSet("push", flag.ExitOnError) + fs.Usage = usage + + opts, err := configureOptions(fs, os.Args[1:], + func() { + fmt.Printf("push version %s, ", version) + os.Exit(0) + }, + fs.Usage) + if err != nil { + log.Fatalf("failed to parse command line flags: %v", err.Error()+"\n"+usageStr) + } + + return opts +} + +func configureOptions(fs *flag.FlagSet, args []string, printVersion, printHelp func()) (*Options, error) { + opts := &DefaultOptions + + var ( + showVersion bool + showHelp bool + err error + ) + + fs.BoolVar(&showHelp, "h", false, "show this message") + fs.BoolVar(&showHelp, "help", false, "show this message") + fs.BoolVar(&showVersion, "version", false, "print version information") + fs.BoolVar(&showVersion, "v", false, "print version information") + fs.IntVar(&opts.Port, "port", defaultPort, "HTTP server port") + fs.StringVar(&opts.ClientID, "client_id", defaultClientID, "client ID to use") + fs.StringVar(&opts.NatsURL, "nats_url", defaultNatsURL, "NATS Server URL to connect to") + fs.StringVar(&opts.NatsStreamingClusterID, "cluster_id", defaultNatsStreamingClusterID, "NATS Streaming Cluster ID to connect to") + fs.DurationVar(&opts.ConnectWait, "connect_wait", defaultConnectWait, "NATS Streaming client connection timeout") + fs.DurationVar(&opts.AckWait, "ack_wait", defaultAckWait, "NATS Streaming ack timeout before resending") + fs.StringVar(&opts.QueueGroup, "queue_group", defaultQueueGroup, "queue group name") + fs.BoolVar(&opts.TLSSkipVerify, "tls_skip_verify", false, "Skip TLS certificate verification, allow insecure connection") + fs.StringVar(&opts.SubscriptionNameHeader, "subscription_name_header", "", "Push Subscription name header") + fs.StringVar(&opts.TopicHeader, "topic_header", "", "Topic header") + fs.StringVar(&opts.APIURL, "trace_api_url", trace.DefaultTraceAPIURL, "Trace API URL") + fs.StringVar(&opts.HostPort, "trace_host_port", trace.DefaultTraceHostPort, "Trace host port") + fs.StringVar(&opts.ServiceName, "trace_service_name", trace.DefaultTraceServiceName, "Push service name") + fs.StringVar(&opts.OperationName, "trace_operation_name", trace.DefaultTraceOperationName, "Push operation name") + fs.BoolVar(&opts.Debug, "trace_debug", false, "Trace debug") + fs.BoolVar(&opts.CheckEventsActivation, "check_events_activation", false, "Check Events Activation before starting subscription") + + if err := fs.Parse(args); err != nil { + return nil, err + } + + if showVersion { + printVersion() + return nil, nil + } + + if showHelp { + printHelp() + return nil, nil + } + + showVersion, showHelp, err = processCommandLineArgs(fs) + if err != nil { + return nil, err + } else if showVersion { + printVersion() + return nil, nil + } else if showHelp { + printHelp() + return nil, nil + } + + flagSet := make(map[string]bool) + fs.Visit(func(f *flag.Flag) { + flagSet[f.Name] = true + }) + + // TODO validate that mandatory flags with defaults are not empty, they haven't been cleared through CLI + + // TODO signal processing + + return opts, nil +} + +func processCommandLineArgs(cmd *flag.FlagSet) (showVersion bool, showHelp bool, err error) { + if len(cmd.Args()) > 0 { + arg := cmd.Args()[0] + switch strings.ToLower(arg) { + case "version": + return true, false, nil + case "help": + return false, true, nil + default: + return false, false, fmt.Errorf("unrecognized command: %q", arg) + } + } + + return false, false, nil +} diff --git a/components/event-bus/internal/stanutil/stanutil.go b/components/event-bus/internal/stanutil/stanutil.go new file mode 100644 index 000000000000..212bb8710e16 --- /dev/null +++ b/components/event-bus/internal/stanutil/stanutil.go @@ -0,0 +1,75 @@ +package stanutil + +import ( + "errors" + "fmt" + "log" + + stan "github.com/nats-io/go-nats-streaming" +) + +// Connect creates a new NATS-Streaming connection +func Connect(clusterID string, clientID string, natsURL string) (*stan.Conn, error) { + sc, err := stan.Connect(clusterID, clientID, stan.NatsURL(natsURL)) + if err != nil { + log.Printf("Can't connect to: %s ; error: %v; NATS URL: %s", clusterID, err, natsURL) + } + return &sc, err +} + +// Close must be the last call to close the connection +func Close(sc *stan.Conn) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("recovered from: %v", r) + log.Printf("Close(): %v\n", err.Error()) + } + }() + + if sc == nil { + err = errors.New("can't close empty stan connection") + return + } + err = (*sc).Close() + if err != nil { + log.Printf("Can't close connection: %+v\n", err) + } + return +} + +// Publish a message to a subject +func Publish(sc *stan.Conn, subj string, msg *[]byte) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("recovered from: %v", r) + log.Printf("Publish(): %v\n", err.Error()) + } + }() + + if sc == nil { + err = errors.New("cant'publish on empty stan connection") + return + } + err = (*sc).Publish(subj, *msg) + if err != nil { + log.Printf("Error during publish: %v\n", err) + } + return +} + +// IsConnected .... +func IsConnected(sc *stan.Conn) (ok bool) { + defer func() { + if r := recover(); r != nil { + ok = false + log.Printf("IsConnected() recovered: %v\n", r) + } + }() + + if sc != nil && sc != (*stan.Conn)(nil) && (*sc).NatsConn() != nil { + ok = (*sc).NatsConn().IsConnected() + } else { + ok = false + } + return +} diff --git a/components/event-bus/internal/sv/eventactivations_controller.go b/components/event-bus/internal/sv/eventactivations_controller.go new file mode 100644 index 000000000000..e413a6e13443 --- /dev/null +++ b/components/event-bus/internal/sv/eventactivations_controller.go @@ -0,0 +1,91 @@ +package ea + +import ( + "log" + + eaclientset "github.com/kyma-project/kyma/components/event-bus/generated/ea/clientset/versioned" + eav1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/ea/informers/externalversions/remoteenvironment.kyma.cx/v1alpha1" + subscriptionClientSet "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned" + eaApis "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1" + "github.com/kyma-project/kyma/components/event-bus/internal/sv/opts" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/cache" +) + +// EventActivationsController observes EventActivations CRs and updates the related Subscriptions-CRs status +type EventActivationsController struct { + informer cache.SharedIndexInformer + stopChannel chan struct{} + running bool +} + +// StartEventActivationsController creates and starts an EventActivationsController +func StartEventActivationsController() *EventActivationsController { + return StartEventActivationsControllerWithInformer(createEventActivationsInformer()) +} + +// StartEventActivationsControllerWithInformer is a factory for the Controller using the specified informer +func StartEventActivationsControllerWithInformer(informer cache.SharedIndexInformer) *EventActivationsController { + controller := &EventActivationsController{ + informer: informer, + stopChannel: make(chan struct{}), + } + go controller.informer.Run(controller.stopChannel) + controller.running = true + return controller +} + +// IsRunning... +func (controller *EventActivationsController) IsRunning() bool { + return controller.running +} + +// Stop the Controller +func (controller *EventActivationsController) Stop() { + controller.stopChannel <- struct{}{} + controller.running = false +} + +func createEventActivationsInformer() cache.SharedIndexInformer { + config, err := rest.InClusterConfig() + if err != nil { + log.Panicf("Error in getting cluster config - %+v", err) + } + eaClient, err := eaclientset.NewForConfig(config) + if err != nil { + log.Panicf("Error in creating event activation client - %+v", err) + } + subClient, err := subscriptionClientSet.NewForConfig(config) + if err != nil { + log.Panicf("Error in creating subscription client - %+v", err) + } + + informer := eav1alpha1.NewEventActivationInformer(eaClient, metav1.NamespaceAll, opts.GetOptions().ResyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}) + informer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + log.Printf("Event Activation custom resource created:\n %v\n", obj) + eaObj, ok := obj.(*eaApis.EventActivation) + if !ok { + log.Printf("Error: Not an Event Activation object: %v\n", obj) + return + } + if subs, err := getSubscriptionsForEventActivation(subClient, eaObj); err == nil { + activateSubscriptions(subClient, eaObj.GetNamespace(), subs) + } + }, + UpdateFunc: func(oldObj, newObj interface{}) { + if oldObj != newObj { + log.Printf("Event Activation custom resource updated, old:\n %v\n new: %v\n", oldObj, newObj) + } + }, + DeleteFunc: func(obj interface{}) { + log.Printf("Event Activation custom resource deleted:\n %v\n", obj) + eaObj := obj.(*eaApis.EventActivation) + if subs, err := getSubscriptionsForEventActivation(subClient, eaObj); err == nil { + deactivateSubscriptions(subClient, eaObj.GetNamespace(), subs) + } + }, + }) + return informer +} diff --git a/components/event-bus/internal/sv/eventactivations_utils.go b/components/event-bus/internal/sv/eventactivations_utils.go new file mode 100644 index 000000000000..81c66ac701dd --- /dev/null +++ b/components/event-bus/internal/sv/eventactivations_utils.go @@ -0,0 +1,29 @@ +package ea + +import ( + "log" + + subApis "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + eaclientset "github.com/kyma-project/kyma/components/event-bus/generated/ea/clientset/versioned" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// check if an event activation custom resource exists having the same "namespace" as the "sub" namespace and the same "Source" +func checkEventActivationForSubscription(eaClient *eaclientset.Clientset, subObj *subApis.Subscription) bool { + subNamespace := subObj.GetNamespace() + subSource := subObj.SubscriptionSpec.Source + + eaList, err := eaClient.RemoteenvironmentV1alpha1().EventActivations(subNamespace).List(metav1.ListOptions{}) + if err != nil { + log.Printf("Error: List Event Activation call failed for the subscription:\n %v;\n Error:%v\n", subObj, err) + return false + } + for _, e := range eaList.Items { + if subSource.SourceEnvironment == e.Source.Environment && + subSource.SourceNamespace == e.Source.Namespace && + subSource.SourceType == e.Source.Type { + return true + } + } + return false +} diff --git a/components/event-bus/internal/sv/opts/opts.go b/components/event-bus/internal/sv/opts/opts.go new file mode 100644 index 000000000000..1b3781981aee --- /dev/null +++ b/components/event-bus/internal/sv/opts/opts.go @@ -0,0 +1,69 @@ +package opts + +import ( + "flag" + "log" + "os" + "sync" + "time" +) + +var version = os.Getenv("APP_VERSION") + +const ( + defaultPort = 8080 + defaultResyncPeriod = 1 * time.Minute +) + +type Options struct { + Port int + ResyncPeriod time.Duration +} + +var ( + config *Options + once sync.Once +) + +func GetOptions() *Options { + once.Do(func() { + config = ParseFlags() + }) + return config +} + +func ParseFlags() *Options { + fs := flag.NewFlagSet("sv", flag.ExitOnError) + opts, err := configureOptions(fs, os.Args[1:]) + + if err != nil { + log.Fatalf("failed to parse command line flags: %v", err.Error()) + } + + config = opts + return opts +} + +func configureOptions(fs *flag.FlagSet, args []string) (*Options, error) { + opts := DefaultOptions() + var showHelp bool + + fs.IntVar(&opts.Port, "port", defaultPort, "The publish listen port") + fs.DurationVar(&opts.ResyncPeriod, "resyncPeriod", defaultResyncPeriod, "The resync period for the used informers") + if err := fs.Parse(args); err != nil { + return nil, err + } + + if showHelp { + flag.PrintDefaults() + os.Exit(0) + } + return opts, nil +} + +func DefaultOptions() *Options { + return &Options{ + Port: defaultPort, + ResyncPeriod: defaultResyncPeriod, + } +} diff --git a/components/event-bus/internal/sv/subscriptions_controller.go b/components/event-bus/internal/sv/subscriptions_controller.go new file mode 100644 index 000000000000..71aae0d005f1 --- /dev/null +++ b/components/event-bus/internal/sv/subscriptions_controller.go @@ -0,0 +1,89 @@ +package ea + +import ( + "log" + + subApis "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + eaclientset "github.com/kyma-project/kyma/components/event-bus/generated/ea/clientset/versioned" + subscriptionClientSet "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned" + subv1alpha1 "github.com/kyma-project/kyma/components/event-bus/generated/push/informers/externalversions/eventing.kyma.cx/v1alpha1" + "github.com/kyma-project/kyma/components/event-bus/internal/sv/opts" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/cache" +) + +// Controller observes EventActivations CRs and updates the related Subscriptions-CRs status +type SubscriptionsController struct { + informer cache.SharedIndexInformer + stopChannel chan struct{} + running bool +} + +// StartController creates and starts an EventActivationsController +func StartSubscriptionsController() *SubscriptionsController { + return StartSubscriptionsControllerWithInformer(createSubscriptionsInformer()) +} + +// StartControllerWithInformer is a factory for the Controller using the specified informer +func StartSubscriptionsControllerWithInformer(informer cache.SharedIndexInformer) *SubscriptionsController { + controller := &SubscriptionsController{ + informer: informer, + stopChannel: make(chan struct{}), + } + go controller.informer.Run(controller.stopChannel) + controller.running = true + return controller +} + +// IsRunning... +func (controller *SubscriptionsController) IsRunning() bool { + return controller.running +} + +// Stop the Controller +func (controller *SubscriptionsController) Stop() { + controller.stopChannel <- struct{}{} + controller.running = false +} + +func createSubscriptionsInformer() cache.SharedIndexInformer { + config, err := rest.InClusterConfig() + if err != nil { + log.Panicf("Error in getting cluster config - %+v", err) + } + eaClient, err := eaclientset.NewForConfig(config) + if err != nil { + log.Panicf("Error in creating event activation client - %+v", err) + } + subClient, err := subscriptionClientSet.NewForConfig(config) + if err != nil { + log.Panicf("Error in creating subscription client - %+v", err) + } + + informer := subv1alpha1.NewSubscriptionInformer(subClient, metav1.NamespaceAll, opts.GetOptions().ResyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}) + informer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + log.Printf("Subscription custom resource created:\n %v\n", obj) + subObj, ok := obj.(*subApis.Subscription) + if !ok { + log.Printf("Error: Not a Subscription object:\n %v\n", obj) + return + } + if checkEventActivationForSubscription(eaClient, subObj) { + activateSubscriptions(subClient, subObj.GetNamespace(), []*subApis.Subscription{subObj}) + } else { + deactivateSubscriptions(subClient, subObj.GetNamespace(), []*subApis.Subscription{subObj}) + } + }, + UpdateFunc: func(oldObj, newObj interface{}) { + if oldObj != newObj { + log.Printf("Subscription custom resource updated, old:\n %v\n new: %v\n", oldObj, newObj) + } + }, + DeleteFunc: func(obj interface{}) { + log.Printf("Subscription custom resource deleted:\n %v\n", obj) + }, + }) + return informer +} diff --git a/components/event-bus/internal/sv/subscriptions_utils.go b/components/event-bus/internal/sv/subscriptions_utils.go new file mode 100644 index 000000000000..ee036364130c --- /dev/null +++ b/components/event-bus/internal/sv/subscriptions_utils.go @@ -0,0 +1,80 @@ +package ea + +import ( + "log" + "time" + + subApis "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + subscriptionClientSet "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned" + eaApis "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// find all the subscriptions having the same "namespace" as the "ea" namespace and the same "Source" +func getSubscriptionsForEventActivation(subClient *subscriptionClientSet.Clientset, eaObj *eaApis.EventActivation) ([]*subApis.Subscription, error) { + eaNamespace := eaObj.GetNamespace() + eaSource := eaObj.EventActivationSpec.Source + + subscrList, err := subClient.EventingV1alpha1().Subscriptions(eaNamespace).List(metav1.ListOptions{}) // TODO query on eaSource?? + if err != nil { + log.Printf("Error: List Subscriptions call failed for event activatsion:\n %v;\n Error:%v\n", eaObj, err) + return nil, err + } + var subs []*subApis.Subscription + for _, s := range subscrList.Items { + if eaSource.Environment == s.Source.SourceEnvironment && + eaSource.Namespace == s.Source.SourceNamespace && + eaSource.Type == s.Source.SourceType { + subs = append(subs, &s) + } + } + return subs, err +} + +func writeSubscriptions(subClient *subscriptionClientSet.Clientset, namespace string, subs []*subApis.Subscription) { + for _, u := range subs { + _, err := subClient.EventingV1alpha1().Subscriptions(namespace).Update(u) + if err != nil { + log.Printf("Error: Update Subscription call failed for subscription:\n %v,\n %v\n", u, err) + } + } +} + +func activateSubscriptions(subClient *subscriptionClientSet.Clientset, namespace string, subs []*subApis.Subscription) { + updatedSubs := updateSubscriptionsStatus(subs, subApis.ConditionTrue) + writeSubscriptions(subClient, namespace, updatedSubs) +} + +func deactivateSubscriptions(subClient *subscriptionClientSet.Clientset, namespace string, subs []*subApis.Subscription) { + updatedSubs := updateSubscriptionsStatus(subs, subApis.ConditionFalse) + writeSubscriptions(subClient, namespace, updatedSubs) +} + +func updateSubscriptionsStatus(subs []*subApis.Subscription, conditionStatus subApis.ConditionStatus) []*subApis.Subscription { + t := metav1.NewTime(time.Now()) + var newCondition subApis.SubscriptionCondition + if conditionStatus == subApis.ConditionTrue { + newCondition = subApis.SubscriptionCondition{Type: subApis.EventsActivated, Status: subApis.ConditionTrue, LastTransitionTime: t} + } else { + newCondition = subApis.SubscriptionCondition{Type: subApis.EventsActivated, Status: subApis.ConditionFalse, LastTransitionTime: t} + } + + var updatedSubs []*subApis.Subscription + for _, s := range subs { + if !s.HasCondition(newCondition) { + if len(s.Status.Conditions) == 0 { + s.Status.Conditions = []subApis.SubscriptionCondition{newCondition} + updatedSubs = append(updatedSubs, s) + } else { + for i, cond := range s.Status.Conditions { + if cond.Type == subApis.EventsActivated && cond.Status != conditionStatus { + s.Status.Conditions[i] = newCondition + updatedSubs = append(updatedSubs, s) + break + } + } + } + } + } + return updatedSubs +} diff --git a/components/event-bus/internal/trace/tags.go b/components/event-bus/internal/trace/tags.go new file mode 100644 index 000000000000..0be0210375aa --- /dev/null +++ b/components/event-bus/internal/trace/tags.go @@ -0,0 +1,25 @@ +package trace + +import api "github.com/kyma-project/kyma/components/event-bus/api/publish" + +const ( + eventID = "event-id" + sourceNamespace = "source-ns" + sourceType = "source-type" + sourceEnvironment = "source-env" + eventType = "event-type" + eventTypeVersion = "event-type-ver" + SubscriptionName = "sub-name" + SubscriptionEnvironment = "sub-env" +) + +func CreateTraceTagsFromCloudEvent(cloudEvent *api.CloudEvent) map[string]string { + return map[string]string{ + eventID: cloudEvent.EventID, + sourceNamespace: cloudEvent.Source.SourceNamespace, + sourceType: cloudEvent.Source.SourceType, + sourceEnvironment: cloudEvent.Source.SourceEnvironment, + eventType: cloudEvent.EventType, + eventTypeVersion: cloudEvent.EventTypeVersion, + } +} diff --git a/components/event-bus/internal/trace/tracer.go b/components/event-bus/internal/trace/tracer.go new file mode 100644 index 000000000000..ad104e3f00f9 --- /dev/null +++ b/components/event-bus/internal/trace/tracer.go @@ -0,0 +1,153 @@ +package trace + +import ( + "log" + "net/http" + + api "github.com/kyma-project/kyma/components/event-bus/api/publish" + "github.com/opentracing/opentracing-go" + "github.com/opentracing/opentracing-go/ext" + otlog "github.com/opentracing/opentracing-go/log" + zipkin "github.com/openzipkin/zipkin-go-opentracing" +) + +const ( + DefaultTraceAPIURL = "" + DefaultTraceHostPort = "0.0.0.0:0" + DefaultTraceServiceName = "trace-service" + DefaultTraceOperationName = "trace-operation" +) + +type Options struct { + APIURL string + HostPort string + ServiceName string + OperationName string + Debug bool +} + +type Tracer interface { + Started() bool + Options() *Options + Stop() +} + +type OpenTracer struct { + opts Options + collector zipkin.Collector +} + +func StartNewTracer(opts *Options) Tracer { + tracer := new(OpenTracer) + tracer.opts = *opts + tracer.Start() + return tracer +} + +func (zk *OpenTracer) Start() { + if collector, err := zipkin.NewHTTPCollector(zk.opts.APIURL); err != nil { + log.Printf("Tracer :: Start :: Error creating Zipkin collector :: Error: %v", err) + } else { + recorder := zipkin.NewRecorder(collector, zk.opts.Debug, zk.opts.HostPort, zk.opts.ServiceName) + if tracer, err := zipkin.NewTracer(recorder, zipkin.TraceID128Bit(false)); err != nil { + log.Printf("Tracer :: Start :: Error creating Zipkin tracer :: Error: %v", err) + } else { + zk.collector = collector + opentracing.SetGlobalTracer(tracer) + } + } +} + +func (zk *OpenTracer) Started() bool { + return zk.collector != nil +} + +func (zk *OpenTracer) Options() *Options { + return &zk.opts +} + +func (zk *OpenTracer) Stop() { + if zk.collector != nil { + zk.collector.Close() + } +} + +func ReadTraceHeaders(header *http.Header) *opentracing.SpanContext { + if header == nil { + return nil + } + if spanContext, err := opentracing.GlobalTracer().Extract(opentracing.HTTPHeaders, opentracing.HTTPHeadersCarrier(*header)); err != nil { + return nil + } else { + return &spanContext + } +} + +func ReadTraceContext(traceContext *api.TraceContext) *opentracing.SpanContext { + if spanContext, err := opentracing.GlobalTracer().Extract(opentracing.TextMap, (*opentracing.TextMapCarrier)(traceContext)); err != nil { + return nil + } else { + return &spanContext + } +} + +func StartSpan(spanContext *opentracing.SpanContext, operationName *string, opts ...opentracing.StartSpanOption) *opentracing.Span { + if spanContext != nil { + if opts == nil || len(opts) == 0 { + opts = make([]opentracing.StartSpanOption, 0) + } + opts = append(opts, opentracing.ChildOf(*spanContext)) + } + span := opentracing.StartSpan(*operationName, opts...) + return &span +} + +func WriteSpan(span *opentracing.Span) *api.TraceContext { + if span == nil { + log.Printf("Tracer :: WriteSpan :: Error writing trace span nil") + return nil + } + traceContext := make(api.TraceContext) + carrier := opentracing.TextMapCarrier(traceContext) + opentracing.GlobalTracer().Inject((*span).Context(), opentracing.TextMap, carrier) + return &traceContext +} + +func TagSpanAsError(span *opentracing.Span, errorMessage, errorStack string) { + if span != nil { + ext.Error.Set(*span, true) + + // log more details about the error + var fields []otlog.Field + + if len(errorMessage) != 0 { + fields = append(fields, otlog.String("message", errorMessage)) // human readable error message + } + + if len(errorStack) != 0 { + fields = append(fields, otlog.String("stack", errorStack)) // error stacktrace + } + + (*span).LogFields(fields...) + } +} + +func TagSpanWithHttpStatusCode(span *opentracing.Span, statusCode uint16) { + if span != nil { + ext.HTTPStatusCode.Set(*span, statusCode) + } +} + +func FinishSpan(span *opentracing.Span) { + if span != nil { + (*span).Finish() + } +} + +func SetSpanTags(span *opentracing.Span, tags *map[string]string) { + if span != nil && tags != nil { + for key, value := range *tags { + (*span).SetTag(key, value) + } + } +} diff --git a/components/event-bus/test/acceptance/push/acceptance_test.go b/components/event-bus/test/acceptance/push/acceptance_test.go new file mode 100644 index 000000000000..4d7778847c67 --- /dev/null +++ b/components/event-bus/test/acceptance/push/acceptance_test.go @@ -0,0 +1,247 @@ +package application_test + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + api "github.com/kyma-project/kyma/components/event-bus/api/publish" + publishapp "github.com/kyma-project/kyma/components/event-bus/cmd/event-bus-publish/application" + pushapp "github.com/kyma-project/kyma/components/event-bus/cmd/event-bus-push/application" + "github.com/kyma-project/kyma/components/event-bus/generated/push/clientset/versioned/fake" + "github.com/kyma-project/kyma/components/event-bus/generated/push/informers/externalversions/eventing.kyma.cx/v1alpha1" + "github.com/kyma-project/kyma/components/event-bus/internal/publish" + "github.com/kyma-project/kyma/components/event-bus/internal/push/opts" + "github.com/kyma-project/kyma/components/event-bus/test/util" + "github.com/nats-io/nats-streaming-server/server" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/cache" +) + +const ( + clusterID = "kyma-nats-streaming" + eventType = "test-publish-push-success" + eventTypeVersion = "v1" + sourceType = "ec" + + sourceEnvironmentV1 = "test" + sourceNamespaceV1 = "local.kyma.commerce" + eventDataV1 = "test-event-1" + + sourceEnvironmentV2 = "test.com" + sourceNamespaceV2 = "sap.hybris" + eventDataV2 = "test-event-2" + + publishServerStatusPath = "/v1/status/ready" +) + +var ( + publishServer *httptest.Server + pushServer *httptest.Server + subscriberServerV1 *httptest.Server + subscriberServerV2 *httptest.Server +) + +func startNats() (*server.StanServer, error) { + return server.RunServer(clusterID) +} + +func stopNats(stanServer *server.StanServer) { + stanServer.Shutdown() +} + +func TestMain(m *testing.M) { + + stanServer, err := startNats() + + publishOpts := publish.DefaultOptions() + println(publishOpts) + publishApplication := publishapp.NewPublishApplication(publishOpts) + publishServer = httptest.NewServer(util.Logger(publishApplication.ServerMux)) + + subscriberServerV1 = util.NewSubscriberServerV1() + subscriberServerV2 = util.NewSubscriberServerV2() + + pushOpts := opts.DefaultOptions + pushOpts.NatsStreamingClusterID = clusterID + pushApplication := pushapp.NewPushApplication(&pushOpts, newFakeInformer()) + subscriptionsSupervisor1 := pushApplication.SubscriptionsSupervisor + subscriptionsSupervisor1.StartSubscriptionReq( + util.NewSubscription( + metav1.NamespaceDefault, + subscriberServerV1.URL+util.SubServer1EventsPath, + eventType, + eventTypeVersion, + sourceEnvironmentV1, + sourceNamespaceV1, + sourceType)) + + subscriptionsSupervisor2 := pushApplication.SubscriptionsSupervisor + subscriptionsSupervisor2.StartSubscriptionReq( + util.NewSubscription( + metav1.NamespaceDefault, + subscriberServerV2.URL+util.SubServer2EventsPath, + eventType, + eventTypeVersion, + sourceEnvironmentV2, + sourceNamespaceV2, + sourceType)) + + pushServer = httptest.NewServer(util.Logger(pushApplication.ServerMux)) + + if err != nil { + panic(err) + } else { + retCode := m.Run() + + publishServer.Close() + publishApplication.Stop() + + pushServer.Close() + pushApplication.Stop() + + stopNats(stanServer) + + subscriberServerV1.Close() + subscriberServerV2.Close() + + os.Exit(retCode) + } +} + +func Test_Publish_Status(t *testing.T) { + res, err := http.Get(publishServer.URL + publishServerStatusPath) + checkIfError(err, t) + verifyStatusCode(res, http.StatusOK, t) +} + +func Test_Push_Status(t *testing.T) { + res, err := http.Get(pushServer.URL + publishServerStatusPath) + checkIfError(err, t) + verifyStatusCode(res, http.StatusOK, t) +} + +func Test_Subscriber_Status(t *testing.T) { + res1, err1 := http.Get(subscriberServerV1.URL + util.SubServer1StatusPath) + checkIfError(err1, t) + verifyStatusCode(res1, http.StatusOK, t) + res2, err2 := http.Get(subscriberServerV2.URL + util.SubServer2StatusPath) + checkIfError(err2, t) + verifyStatusCode(res2, http.StatusOK, t) +} + +func makePayload( + sourceNamespace string, + sourceType string, + sourceEnvironment string, + eventType string, + eventTypeVersion string, + eventData string) string { + return fmt.Sprintf( + `{"source": {"source-namespace": "%s","source-type": "%s","source-environment": "%s"}, + "event-type": "%s","event-type-version": "%s","event-time": "2018-11-02T22:08:41+00:00","data": "%s"}`, + sourceNamespace, sourceType, sourceEnvironment, eventType, eventTypeVersion, eventData) +} + +func Test_Publish_Push_Request(t *testing.T) { + { + payloadV1 := makePayload(sourceNamespaceV1, sourceType, sourceEnvironmentV1, eventType, eventTypeVersion, eventDataV1) + res, err := http.Post(publishServer.URL+"/v1/events", "application/json", strings.NewReader(payloadV1)) + checkIfError(err, t) + verifyStatusCode(res, 200, t) + log.Print(res) + respObj := &api.PublishResponse{} + body, err := ioutil.ReadAll(res.Body) + defer res.Body.Close() + err = json.Unmarshal(body, &respObj) + assert.NotNil(t, respObj.EventID) + assert.NotEmpty(t, respObj.EventID) + log.Printf("%v", respObj) + + var ok bool + for i := 0; i < 10; i++ { + time.Sleep(1 * time.Second) + res, err := http.Get(subscriberServerV1.URL + util.SubServer1ResultsPath) + assert.Nil(t, err) + body, err := ioutil.ReadAll(res.Body) + var resp string + json.Unmarshal(body, &resp) + res.Body.Close() + if len(resp) == 0 { + continue + } + assert.Equal(t, eventDataV1, resp) + ok = true + break + } + assert.True(t, ok) + } + + { + payloadV2 := makePayload(sourceNamespaceV2, sourceType, sourceEnvironmentV2, eventType, eventTypeVersion, eventDataV2) + res, err := http.Post(publishServer.URL+"/v1/events", "application/json", strings.NewReader(payloadV2)) + checkIfError(err, t) + verifyStatusCode(res, 200, t) + log.Print(res) + respObj := &api.PublishResponse{} + body, err := ioutil.ReadAll(res.Body) + defer res.Body.Close() + err = json.Unmarshal(body, &respObj) + assert.NotNil(t, respObj.EventID) + assert.NotEmpty(t, respObj.EventID) + log.Printf("%v", respObj) + + var ok bool + for i := 0; i < 10; i++ { + time.Sleep(1 * time.Second) + res, err := http.Get(subscriberServerV2.URL + util.SubServer2ResultsPath) + assert.Nil(t, err) + body, err := ioutil.ReadAll(res.Body) + var resp string + json.Unmarshal(body, &resp) + res.Body.Close() + if len(resp) == 0 { + continue + } + assert.Equal(t, eventDataV2, resp) + ok = true + break + } + assert.True(t, ok) + } +} + +func newFakeInformer() cache.SharedIndexInformer { + sub := util.NewSubscription( + metav1.NamespaceDefault, + subscriberServerV1.URL+util.SubServer1EventsPath, + eventType, + eventTypeVersion, + sourceEnvironmentV1, + sourceNamespaceV1, + sourceType) + clientSet := fake.NewSimpleClientset(sub) + informer := v1alpha1.NewSubscriptionInformer(clientSet, metav1.NamespaceAll, 0, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}) + informer.GetIndexer().Add(sub) + return informer +} + +func verifyStatusCode(res *http.Response, expectedStatusCode int, t *testing.T) { + if res.StatusCode != expectedStatusCode { + t.Errorf("Status code is wrong, have: %d, want: %d", res.StatusCode, expectedStatusCode) + } +} + +func checkIfError(err error, t *testing.T) { + if err != nil { + t.Fatal(err) + } +} diff --git a/components/event-bus/test/util/crd.go b/components/event-bus/test/util/crd.go new file mode 100644 index 000000000000..26c07e63fbde --- /dev/null +++ b/components/event-bus/test/util/crd.go @@ -0,0 +1,54 @@ +package util + +import ( + apiv1 "github.com/kyma-project/kyma/components/event-bus/api/push/eventing.kyma.cx/v1alpha1" + eaApis "github.com/kyma-project/kyma/components/event-bus/internal/ea/apis/remoteenvironment.kyma.cx/v1alpha1" + "github.com/satori/go.uuid" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// NewSubscription creates a new subscription +func NewSubscription(namespace string, + subscriberEventEndpointURL string, + eventType string, + eventTypeVersion string, + sourceEnvironment string, + sourceNamespace string, + sourceType string) *apiv1.Subscription { + uid := uuid.NewV4().String() + return &apiv1.Subscription{ + TypeMeta: metav1.TypeMeta{APIVersion: apiv1.SchemeGroupVersion.String()}, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sub", + Namespace: namespace, + UID: types.UID(uid), + }, + + SubscriptionSpec: apiv1.SubscriptionSpec{ + Endpoint: subscriberEventEndpointURL, + IncludeSubscriptionNameHeader: false, + IncludeTopicHeader: false, + MaxInflight: 100, + PushRequestTimeoutMS: 10, + Source: apiv1.Source{SourceEnvironment: sourceEnvironment, SourceNamespace: sourceNamespace, SourceType: sourceType}, + EventType: eventType, + EventTypeVersion: eventTypeVersion, + }, + } +} + +// NewEventActivation creates a new event activation +func NewEventActivation(namespace string) *eaApis.EventActivation { + return &eaApis.EventActivation{ + TypeMeta: metav1.TypeMeta{APIVersion: apiv1.SchemeGroupVersion.String()}, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ea", + Namespace: namespace, + }, + EventActivationSpec: eaApis.EventActivationSpec{ + DisplayName: "e2e-test-event-activation", + Source: eaApis.Source{Environment: "test", Namespace: "local.kyma.commerce", Type: "commerce"}, + }, + } +} diff --git a/components/event-bus/test/util/logger.go b/components/event-bus/test/util/logger.go new file mode 100644 index 000000000000..1a2db154e54e --- /dev/null +++ b/components/event-bus/test/util/logger.go @@ -0,0 +1,22 @@ +package util + +import ( + "fmt" + "log" + "net/http" + "net/http/httputil" +) + +// Logger ... +func Logger(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("%s requested %s", r.RemoteAddr, r.URL) + dump, err := httputil.DumpRequest(r, true) + log.Printf("%q", dump) + if err != nil { + http.Error(w, fmt.Sprint(err), http.StatusInternalServerError) + return + } + h.ServeHTTP(w, r) + }) +} diff --git a/components/event-bus/test/util/subscriber.go b/components/event-bus/test/util/subscriber.go new file mode 100644 index 000000000000..d837fe272e88 --- /dev/null +++ b/components/event-bus/test/util/subscriber.go @@ -0,0 +1,97 @@ +package util + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "net/http/httptest" + "sync" +) + +// Subscribers Servers API paths +const ( + SubServer1EventsPath = "/v1/events" + SubServer1StatusPath = "/v1/status" + SubServer1ResultsPath = "/v1/results" + SubServer2EventsPath = "/v2/events" + SubServer2StatusPath = "/v2/status" + SubServer2ResultsPath = "/v2/results" +) + +// NewSubscriberServerV1 ... +func NewSubscriberServerV1() *httptest.Server { + subscriberMux := http.NewServeMux() + subscriberMux.HandleFunc(SubServer1EventsPath, eventsHandlerV1) + subscriberMux.HandleFunc(SubServer1StatusPath, statusHandler) + subscriberMux.HandleFunc(SubServer1ResultsPath, resultsHandlerV1) + return httptest.NewServer(Logger(subscriberMux)) +} + +// NewSubscriberServerV2 ... +func NewSubscriberServerV2() *httptest.Server { + subscriberMux := http.NewServeMux() + subscriberMux.HandleFunc(SubServer2EventsPath, eventsHandlerV2) + subscriberMux.HandleFunc(SubServer2StatusPath, statusHandler) + subscriberMux.HandleFunc(SubServer2ResultsPath, resultsHandlerV2) + return httptest.NewServer(Logger(subscriberMux)) +} + +// NewSubscriberServerWithPort ... +func NewSubscriberServerWithPort(port int, stop chan bool) *http.Server { + subscriberMux := http.NewServeMux() + subscriberMux.HandleFunc("/v1/events", eventsHandlerV1) + subscriberMux.HandleFunc("/v1/status", statusHandler) + subscriberMux.HandleFunc("/v1/results", resultsHandlerV1) + subscriberMux.Handle("/v1/shutdown", shutdownHandler(stop)) + + srv := &http.Server{Addr: fmt.Sprintf(":%d", port), Handler: Logger(subscriberMux)} + + // start listener and serve requests + go func() { + log.Printf("Subscriber HTTP server starting on port %d", port) + log.Fatal(srv.ListenAndServe()) + }() + return srv +} + +var ( + subscriberV1Result string + subscriberV2Result string + mu sync.Mutex +) + +func resultsHandlerV1(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + json.NewEncoder(w).Encode(subscriberV1Result) +} + +func eventsHandlerV1(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + json.NewDecoder(r.Body).Decode(&subscriberV1Result) +} + +func resultsHandlerV2(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + json.NewEncoder(w).Encode(subscriberV2Result) +} + +func eventsHandlerV2(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + json.NewDecoder(r.Body).Decode(&subscriberV2Result) +} + +func statusHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) +} + +func shutdownHandler(stop chan bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + stop <- true + }) +} diff --git a/components/event-bus/test/util/subscriber_yaml.go b/components/event-bus/test/util/subscriber_yaml.go new file mode 100644 index 000000000000..40d230de3844 --- /dev/null +++ b/components/event-bus/test/util/subscriber_yaml.go @@ -0,0 +1,74 @@ +package util + +import ( + appsv1 "k8s.io/api/apps/v1" + apiv1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + SubscriberName = "test-core-event-bus-subscriber" +) + +func NewSubscriberDeployment(sbscrImg string) *appsv1.Deployment { + var replicas int32 = 1 + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: SubscriberName, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": SubscriberName, + }, + }, + Template: apiv1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": SubscriberName, + }, + Annotations: map[string]string{ + "sidecar.istio.io/inject": "true", + }, + }, + Spec: apiv1.PodSpec{ + Containers: []apiv1.Container{ + { + Name: SubscriberName, + Image: sbscrImg, + ImagePullPolicy: "IfNotPresent", + Ports: []apiv1.ContainerPort{ + { + ContainerPort: 9000, + }, + }, + }, + }, + }, + }, + }, + } +} + +func NewSubscriberService() *apiv1.Service { + return &apiv1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: SubscriberName, + Labels: map[string]string{ + "app": SubscriberName, + }, + }, + Spec: apiv1.ServiceSpec{ + Selector: map[string]string{ + "app": SubscriberName, + }, + Ports: []apiv1.ServicePort{ + { + Name: "http", + Port: 9000, + }, + }, + }, + } +} diff --git a/components/format.sh b/components/format.sh new file mode 100755 index 000000000000..d6bdcee3a738 --- /dev/null +++ b/components/format.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +DIRS=$(find . -type f -name '*.go' -not -path "./vendor/*") +for d in $DIRS; do goimports -w $d; done \ No newline at end of file diff --git a/components/gateway/Dockerfile b/components/gateway/Dockerfile new file mode 100644 index 000000000000..13af8b0e18f9 --- /dev/null +++ b/components/gateway/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.9-alpine as builder + +ARG DOCK_PKG_DIR=/go/src/github.com/kyma-project/kyma/components/gateway + +RUN mkdir -p $DOCK_PKG_DIR + +COPY ./ $DOCK_PKG_DIR +WORKDIR $DOCK_PKG_DIR + +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gateway ./cmd/gateway + +FROM alpine +RUN apk update && apk add curl ngrep + +LABEL source=git@github.com:kyma-project/kyma.git + +COPY --from=builder /go/src/github.com/kyma-project/kyma/components/gateway . + +CMD ["/gateway"] diff --git a/components/gateway/Gopkg.lock b/components/gateway/Gopkg.lock new file mode 100644 index 000000000000..653f0e7b2ef2 --- /dev/null +++ b/components/gateway/Gopkg.lock @@ -0,0 +1,391 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/davecgh/go-spew" + packages = ["spew"] + revision = "346938d642f2ec3594ed81d874461961cd0faa76" + version = "v1.1.0" + +[[projects]] + name = "github.com/ghodss/yaml" + packages = ["."] + revision = "0ca9ea5df5451ffdf184b4428c902747c2c11cd7" + version = "v1.0.0" + +[[projects]] + name = "github.com/gogo/protobuf" + packages = [ + "proto", + "sortkeys" + ] + revision = "1adfc126b41513cc696b209667c8656ea7aac67c" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "github.com/golang/glog" + packages = ["."] + revision = "23def4e6c14b4da8ac2ed8007337bc5eb5007998" + +[[projects]] + name = "github.com/golang/protobuf" + packages = [ + "proto", + "ptypes", + "ptypes/any", + "ptypes/duration", + "ptypes/timestamp" + ] + revision = "925541529c1fa6821df4e44ce2723319eb2be768" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "github.com/google/gofuzz" + packages = ["."] + revision = "24818f796faf91cd76ec7bddd72458fbced7a6c1" + +[[projects]] + name = "github.com/googleapis/gnostic" + packages = [ + "OpenAPIv2", + "compiler", + "extensions" + ] + revision = "ee43cbb60db7bd22502942cccbc39059117352ab" + version = "v0.1.0" + +[[projects]] + name = "github.com/gorilla/context" + packages = ["."] + revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a" + version = "v1.1" + +[[projects]] + name = "github.com/gorilla/mux" + packages = ["."] + revision = "53c1911da2b537f792e7cafcb446b05ffe33b996" + version = "v1.6.1" + +[[projects]] + name = "github.com/json-iterator/go" + packages = ["."] + revision = "ca39e5af3ece67bbcda3d0f4f56a8e24d9f2dad4" + version = "1.1.3" + +[[projects]] + branch = "master" + name = "github.com/kyma-project/kyma" + packages = [ + "components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1", + "components/remote-environment-broker/pkg/client/clientset/versioned", + "components/remote-environment-broker/pkg/client/clientset/versioned/scheme", + "components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1" + ] + revision = "98ffd39a35793983f4d291b743e192daa0701d3b" + +[[projects]] + name = "github.com/modern-go/concurrent" + packages = ["."] + revision = "bacd9c7ef1dd9b15be4a9909b8ac7a4e313eec94" + version = "1.0.3" + +[[projects]] + name = "github.com/modern-go/reflect2" + packages = ["."] + revision = "1df9eeb2bb81f327b96228865c5687bc2194af3f" + version = "1.0.0" + +[[projects]] + name = "github.com/patrickmn/go-cache" + packages = ["."] + revision = "a3647f8e31d79543b2d0f0ae2fe5c379d72cedc0" + version = "v2.1.0" + +[[projects]] + name = "github.com/pmezard/go-difflib" + packages = ["difflib"] + revision = "792786c7400a136282c1664665ae0a8db921c6c2" + version = "v1.0.0" + +[[projects]] + name = "github.com/sirupsen/logrus" + packages = ["."] + revision = "c155da19408a8799da419ed3eeb0cb5db0ad5dbc" + version = "v1.0.5" + +[[projects]] + name = "github.com/spf13/pflag" + packages = ["."] + revision = "e57e3eeb33f795204c1ca35f56c44f83227c6e66" + version = "v1.0.0" + +[[projects]] + name = "github.com/stretchr/objx" + packages = ["."] + revision = "facf9a85c22f48d2f52f2380e4efce1768749a89" + version = "v0.1" + +[[projects]] + name = "github.com/stretchr/testify" + packages = [ + "assert", + "mock", + "require" + ] + revision = "12b6f73e6084dad08a7c6e575284b177ecafbc71" + version = "v1.2.1" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = ["ssh/terminal"] + revision = "88942b9c40a4c9d203b82b3731787b672d6e809b" + +[[projects]] + branch = "master" + name = "golang.org/x/net" + packages = [ + "context", + "http2", + "http2/hpack", + "idna", + "lex/httplex" + ] + revision = "6078986fec03a1dcc236c34816c71b0e05018fda" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = [ + "unix", + "windows" + ] + revision = "13d03a9a82fba647c21a0ef8fba44a795d0f0835" + +[[projects]] + name = "golang.org/x/text" + packages = [ + "collate", + "collate/build", + "internal/colltab", + "internal/gen", + "internal/tag", + "internal/triegen", + "internal/ucd", + "language", + "secure/bidirule", + "transform", + "unicode/bidi", + "unicode/cldr", + "unicode/norm", + "unicode/rangetable" + ] + revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" + version = "v0.3.0" + +[[projects]] + branch = "master" + name = "golang.org/x/time" + packages = ["rate"] + revision = "fbb02b2291d28baffd63558aa44b4b56f178d650" + +[[projects]] + branch = "master" + name = "golang.org/x/tools" + packages = [ + "go/ast/astutil", + "imports", + "internal/fastwalk" + ] + revision = "48418e5732e1b1e2a10207c8007a5f959e422f20" + +[[projects]] + name = "gopkg.in/inf.v0" + packages = ["."] + revision = "d2d2541c53f18d2a059457998ce2876cc8e67cbf" + version = "v0.9.1" + +[[projects]] + name = "gopkg.in/yaml.v2" + packages = ["."] + revision = "86f5ed62f8a0ee96bd888d2efdfd6d4fb100a4eb" + version = "v2.2.0" + +[[projects]] + name = "k8s.io/api" + packages = [ + "admissionregistration/v1alpha1", + "admissionregistration/v1beta1", + "apps/v1", + "apps/v1beta1", + "apps/v1beta2", + "authentication/v1", + "authentication/v1beta1", + "authorization/v1", + "authorization/v1beta1", + "autoscaling/v1", + "autoscaling/v2beta1", + "batch/v1", + "batch/v1beta1", + "batch/v2alpha1", + "certificates/v1beta1", + "core/v1", + "events/v1beta1", + "extensions/v1beta1", + "networking/v1", + "policy/v1beta1", + "rbac/v1", + "rbac/v1alpha1", + "rbac/v1beta1", + "scheduling/v1alpha1", + "settings/v1alpha1", + "storage/v1", + "storage/v1alpha1", + "storage/v1beta1" + ] + revision = "73d903622b7391f3312dcbac6483fed484e185f8" + version = "kubernetes-1.10.0" + +[[projects]] + name = "k8s.io/apimachinery" + packages = [ + "pkg/api/errors", + "pkg/api/meta", + "pkg/api/resource", + "pkg/apimachinery", + "pkg/apimachinery/registered", + "pkg/apis/meta/v1", + "pkg/apis/meta/v1/unstructured", + "pkg/apis/meta/v1beta1", + "pkg/conversion", + "pkg/conversion/queryparams", + "pkg/fields", + "pkg/labels", + "pkg/runtime", + "pkg/runtime/schema", + "pkg/runtime/serializer", + "pkg/runtime/serializer/json", + "pkg/runtime/serializer/protobuf", + "pkg/runtime/serializer/recognizer", + "pkg/runtime/serializer/streaming", + "pkg/runtime/serializer/versioning", + "pkg/selection", + "pkg/types", + "pkg/util/clock", + "pkg/util/errors", + "pkg/util/framer", + "pkg/util/intstr", + "pkg/util/json", + "pkg/util/net", + "pkg/util/runtime", + "pkg/util/sets", + "pkg/util/validation", + "pkg/util/validation/field", + "pkg/util/wait", + "pkg/util/yaml", + "pkg/version", + "pkg/watch", + "third_party/forked/golang/reflect" + ] + revision = "302974c03f7e50f16561ba237db776ab93594ef6" + version = "kubernetes-1.10.0" + +[[projects]] + name = "k8s.io/client-go" + packages = [ + "discovery", + "kubernetes", + "kubernetes/scheme", + "kubernetes/typed/admissionregistration/v1alpha1", + "kubernetes/typed/admissionregistration/v1beta1", + "kubernetes/typed/apps/v1", + "kubernetes/typed/apps/v1beta1", + "kubernetes/typed/apps/v1beta2", + "kubernetes/typed/authentication/v1", + "kubernetes/typed/authentication/v1beta1", + "kubernetes/typed/authorization/v1", + "kubernetes/typed/authorization/v1beta1", + "kubernetes/typed/autoscaling/v1", + "kubernetes/typed/autoscaling/v2beta1", + "kubernetes/typed/batch/v1", + "kubernetes/typed/batch/v1beta1", + "kubernetes/typed/batch/v2alpha1", + "kubernetes/typed/certificates/v1beta1", + "kubernetes/typed/core/v1", + "kubernetes/typed/events/v1beta1", + "kubernetes/typed/extensions/v1beta1", + "kubernetes/typed/networking/v1", + "kubernetes/typed/policy/v1beta1", + "kubernetes/typed/rbac/v1", + "kubernetes/typed/rbac/v1alpha1", + "kubernetes/typed/rbac/v1beta1", + "kubernetes/typed/scheduling/v1alpha1", + "kubernetes/typed/settings/v1alpha1", + "kubernetes/typed/storage/v1", + "kubernetes/typed/storage/v1alpha1", + "kubernetes/typed/storage/v1beta1", + "pkg/apis/clientauthentication", + "pkg/apis/clientauthentication/v1alpha1", + "pkg/version", + "plugin/pkg/client/auth/exec", + "rest", + "rest/watch", + "tools/clientcmd/api", + "tools/metrics", + "tools/reference", + "transport", + "util/cert", + "util/flowcontrol", + "util/integer" + ] + revision = "989be4278f353e42f26c416c53757d16fcff77db" + version = "kubernetes-1.10.1" + +[[projects]] + branch = "release-1.10" + name = "k8s.io/code-generator" + packages = [ + "cmd/client-gen", + "cmd/client-gen/args", + "cmd/client-gen/generators", + "cmd/client-gen/generators/fake", + "cmd/client-gen/generators/scheme", + "cmd/client-gen/generators/util", + "cmd/client-gen/path", + "cmd/client-gen/types", + "cmd/deepcopy-gen", + "cmd/deepcopy-gen/args", + "cmd/defaulter-gen", + "cmd/defaulter-gen/args", + "cmd/informer-gen", + "cmd/informer-gen/args", + "cmd/informer-gen/generators", + "cmd/lister-gen", + "cmd/lister-gen/args", + "cmd/lister-gen/generators", + "pkg/util" + ] + revision = "69d88fb997c52c32288a40f5efb62add0a7c8e04" + +[[projects]] + name = "k8s.io/gengo" + packages = [ + "args", + "examples/deepcopy-gen/generators", + "examples/defaulter-gen/generators", + "examples/set-gen/sets", + "generator", + "namer", + "parser", + "types" + ] + revision = "01a732e01d00cb9a81bb0ca050d3e6d2b947927b" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "0d863aa0746ccef8c4e40a813e6b9760235b58387bf4865192587cc117aed2cf" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/components/gateway/Gopkg.toml b/components/gateway/Gopkg.toml new file mode 100644 index 000000000000..8ecbeec48745 --- /dev/null +++ b/components/gateway/Gopkg.toml @@ -0,0 +1,60 @@ +required = [ + "k8s.io/code-generator/cmd/defaulter-gen", + "k8s.io/code-generator/cmd/deepcopy-gen", + "k8s.io/code-generator/cmd/client-gen", + "k8s.io/code-generator/cmd/lister-gen", + "k8s.io/code-generator/cmd/informer-gen", + "k8s.io/apimachinery/pkg/apimachinery/registered" +] + +[prune] + non-go = true + go-tests = true + unused-packages = true + +[[constraint]] + name = "github.com/sirupsen/logrus" + version = "1.0.5" + +[[constraint]] + name = "github.com/gorilla/mux" + version = "1.6.1" + +[[constraint]] + name = "k8s.io/client-go" + version = "kubernetes-1.10.1" + +[[constraint]] + name = "k8s.io/code-generator" + branch = "release-1.10" + +[[override]] + name = "k8s.io/api" + version = "kubernetes-1.10.1" + +[[override]] + name = "k8s.io/apimachinery" + version = "kubernetes-1.10.1" + +[[override]] + name = "k8s.io/gengo" + revision = "01a732e01d00cb9a81bb0ca050d3e6d2b947927b" + +[[override]] + name = "k8s.io/kubernetes" + branch = "release-1.10" + + +[[override]] + name = "github.com/docker/distribution" + revision = "edc3ab29cdff8694dd6feb85cfeb4b5f1b38ed9c" + + +[[constraint]] + name = "github.com/patrickmn/go-cache" + version = "2.1.0" + + +[[constraint]] + name = "github.com/kyma-project/kyma" + branch = "master" \ No newline at end of file diff --git a/components/gateway/Jenkinsfile b/components/gateway/Jenkinsfile new file mode 100755 index 000000000000..70a8458130a8 --- /dev/null +++ b/components/gateway/Jenkinsfile @@ -0,0 +1,98 @@ +#!/usr/bin/env groovy +def label = "kyma-${UUID.randomUUID().toString()}" +def application = 'gateway' +def isMaster = params.GIT_BRANCH == 'master' + +def dockerPushRoot = isMaster + ? "${env.DOCKER_REGISTRY}" + : "${env.DOCKER_REGISTRY}snapshot/" + +def dockerImageTag = isMaster + ? params.APP_VERSION + : params.GIT_BRANCH + +echo """ +******************************** +Job started with the following parameters: +DOCKER_REGISTRY=${env.DOCKER_REGISTRY} +DOCKER_CREDENTIALS=${env.DOCKER_CREDENTIALS} +GIT_REVISION=${params.GIT_REVISION} +GIT_BRANCH=${params.GIT_BRANCH} +APP_VERSION=${params.APP_VERSION} +APP_FOLDER=${env.APP_FOLDER} +******************************** +""" + +podTemplate(label: label) { + node(label) { + try { + timestamps { + timeout(time:20, unit:"MINUTES") { + ansiColor('xterm') { + stage("setup") { + checkout scm + + if(dockerImageTag == ""){ + error("No version for docker tag defined, please set APP_VERSION parameter for master branch or GIT_BRANCH parameter for any branch") + } + + withCredentials([usernamePassword(credentialsId: env.DOCKER_CREDENTIALS, passwordVariable: 'pwd', usernameVariable: 'uname')]) { + sh "docker login -u $uname -p '$pwd' $env.DOCKER_REGISTRY" + } + } + + stage("install dependencies $application") { + execute("dep ensure -v") + } + + stage("code quality $application") { + execute("gometalinter --vendor --deadline=2m --disable-all --enable=vet --exclude=vendor ./...") + } + + stage("build $application") { + execute("CGO_ENABLED=0 go build ./cmd/gateway") + } + + stage("test $application") { + execute("go test `go list ./internal/... ./cmd/...`") + } + + if (isMaster) { + stage("IP scan $application (Sourceclear)"){ + withCredentials([string(credentialsId: 'SRCCLR_API_TOKEN', variable: 'SRCCLR_API_TOKEN')]) { + execute("make scan","SRCCLR_API_TOKEN=$SRCCLR_API_TOKEN") + } + } + } + + stage("build image $application") { + dir(env.APP_FOLDER){ + sh "docker build -t $application:latest ." + } + } + + + stage("push image $application") { + sh "docker tag ${application}:latest ${dockerPushRoot}${application}:${dockerImageTag}" + sh "docker push ${dockerPushRoot}${application}:${dockerImageTag}" + } + } + } + } + } catch (ex) { + echo "Got exception: ${ex}" + currentBuild.result = "FAILURE" + def body = "${currentBuild.currentResult} ${env.JOB_NAME}${env.BUILD_DISPLAY_NAME}: on branch: ${params.GIT_BRANCH}. See details: ${env.BUILD_URL}" + emailext body: body, recipientProviders: [[$class: 'DevelopersRecipientProvider'], [$class: 'CulpritsRecipientProvider'], [$class: 'RequesterRecipientProvider']], subject: "${currentBuild.currentResult}: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'" + } + } +} + +def execute(command, envs = '') { + def buildpack = 'golang-buildpack:0.0.8' + def repositoryName = 'kyma' + def envText = envs=='' ? '' : "--env $envs" + workDir = pwd() + sh "docker run --rm -v $workDir:/go/src/github.com/kyma-project/$repositoryName/ -w /go/src/github.com/kyma-project/$repositoryName/$env.APP_FOLDER $envText ${env.DOCKER_REGISTRY}$buildpack /bin/bash -c '$command'" +} + diff --git a/components/gateway/README.md b/components/gateway/README.md new file mode 100644 index 000000000000..7a95efdc5d5e --- /dev/null +++ b/components/gateway/README.md @@ -0,0 +1,182 @@ +# Gateway + +## Overview + +This is the repository for the Kyma Gateway. + +## Prerequisites + +The Gateway requires Go 1.8 or higher. + +## Installation + +To install the Gateway, follow these steps: + +1. `git clone git@github.com:kyma-project/kyma.git` +1. `cd kyma/components/gateway` +1. `make build` + +## Usage + +This section explains how to use the Gateway. + +### Start the Gateway +To start the Gateway, run this command: + +``` +./gateway +``` + +The Gateway has the following parameters: +- **proxyPort** - This port acts as a proxy for the calls from services and lambdas to an external solution. The default port is `8080`. +- **externalAPIPort** - This port exposes the Gateway API to an external solution. The default port is `8081`. +- **eventsTargetURL** - A URL to which you proxy the incoming events. The default URL is http://localhost:9000. +- **remoteEnvironment** - Remote Environment name used to write and read information about services. The default remote environment is `default-ec`. +- **appName** - The name of the gateway instance. The default appName is `gateway`. +- **namespace** - Namespace where Gateway is deployed. The default namespace is `kyma-system`. +- **requestTimeout** - A time-out for requests sent through the Gateway. It is provided in seconds. The default time-out is `1`. +- **skipVerify** - A flag for skipping the verification of certificates for the proxy targets. The default value is `false`. +- **requestLogging** - A flag for logging incoming requests. The default value is `false`. +- **proxyTimeout** - A time-out for request send through Gateway proxy in seconds. The default is `10`. +- **proxyCacheTTL** - Time to live of Remote API information stored in proxy cache. The value is provided in seconds and the default is `120`. + +The parameters for the Event API correspond to the fields in the [Remote Environment](https://github.com/kyma-project/kyma/tree/master/docs/remote-environment.md): + +- **sourceEnvironment** - The name of the Event source environment. +- **sourceType** - The type of the Event source. +- **sourceNamespace** - The organization that publishes the Event. + +## Development + +This section explains the development process. + +### Rapid development with Telepresence + +Gateway stores its state in the Kubernetes Custom Resource, therefore Gateway is dependent on Kubernetes. You cannot mock the dependency. You cannot develop locally. Manual deployment on every change is a mundane task. + +You can, however, leverage [Telepresence](https://www.telepresence.io/). This works by replacing a container in a specified pod, opening up a new local shell or a pre-configured bash, and proxying the network traffic from the local shell through the pod. + +Although you are on your local machine, you can make calls such as `curl http://....svc.cluster.local:8081/v1/health`. When you run a server in this shell, other Kubernetes services can access it. + +1. [Install telepresence](https://www.telepresence.io/reference/install). +2. Run Kyma or use the cluster. In the case of Kyma, point your local kubectl to Kyma in Docker. +3. Check the deployment name to swap and run: `telepresence --namespace --swap-deployment : --run-shell` +```bash +telepresence --namespace kyma-system --swap-deployment kyma-core-gateway:gateway --run-shell +``` +4. Every Kubernetes pod has `/var/run/secrets` mounted. The Kubernetes client uses it in the Gateway. It is hardcoded. By default, telepresence copies this directory. It stores the directory path in `$TELEPRESENCE_ROOT`, under telepresence shell. It unwinds to `/tmp/tmp...`. You need to move it to `/var/run/secrets`, where the Gateway expects it. Create a symlink: + ```bash +sudo ln -s $TELEPRESENCE_ROOT/var/run/secrets /var/run/secrets +``` +5. Use the `make build` and then the `./gateway` commands, and now all the Kubernetes services that call gateway access this process. The process runs locally on your machine. + +You can also run another shell to make calls to this gateway. To run this shell, swap the Remote Environment Broker deployment, because istio sidecar is already injected into this deployment: +```bash +telepresence --namespace kyma-system --swap-deployment kyma-core-remote-environment-broker:reb --run-shell +``` + +### Generate mocks + +To generate a mock, follow these steps: + +1. Go to the directory where the interface is located. +2. Run this command: +```sh +mockery -name=Sender +``` + +### Tests + +This section outlines the testing details. + +#### Unit tests + +To run the unit tests, use the following command: + +``` +make test-unit +``` + +#### Acceptance tests + +To run the acceptance tests, start the Gateway and the Echo service. + +##### Start echo-service + +To start the Echo service, use this command: +``` +make start-echo-service +``` + +The `start-echo-service` command has one parameter, **echo-port**. This parameter is the server's HTTP port, and the default port is `9000`. + +``` +make start-echo-service echo-port=9001 +``` + +##### Run the acceptance tests + +To run the acceptance tests, use the following command: +``` +make test-acc +``` + +The `test-acc` command has the following parameters: +- **gateExternalAddr** - The Gateway's external API address. The default external API address is `127.0.0.1:8081`. +- **gateProxyAddr** - The Gateway's proxy API address. The default proxy API address is `127.0.0.1:8080`. + +``` +make test-acc -gateExternalAddr 127.0.0.1:7080 -gateProxyAddr 127.0.0.1:7081 +``` + +When the script runs, it informs you of a success or any errors. + +In this test configuration, the Events API implementation enhances the events with the Gateway data and sends them through the tunnel to the Echo service endpoint. +To test it, use this command: +``` +curl -v -X POST "localhost:8081/v1/events" -H "Accept: application/json" -H "Content-Type: application/json" -d "{\"event-type\":\"order.created\",\"event-type-version\":\"v1\",\"event-id\":\"31109198-4d69-4ae0-972d-76117f3748c8\",\"event-time\":\"2012-11-01T22:08:41+00:00\", \"data\":\"my order created\" }" +``` + +### Generate Kubernetes clients for custom resources + +1. Create a directory structure for each client, similar to the one in `pkg/apis`. For example, when generating a client for EgressRule in Istio, the directory structure looks like this: `pkg/apis/istio/v1alpha2`. +2. After creating the directories, define the following files: + - `doc.go` + - `register.go` + - `types.go` - define the custom structs that reflect the fields of the custom resource. + +See an example in `pkg/apis/istio/v1alpha2`. + +3. Go to the project root directory and run `./hack/update-codegen.sh`. The script generates a new client in `pkg/apis/client/clientset`. + + +### Contract between the Gateway and the UI API Facade + +The UI API Facade must check the status of the Gateway instance that represents the Remote Environment. +In the current solution, the UI API Facade iterates through services to find those which match the criteria, and then uses the health endpoint to determine the status. +The UI API Facade has the following obligatory requirements: +- The Kubernetes Gateway service uses the `remoteEnvironment` key, with the value as the name of the remote environment. +- The Kubernetes Gateway service contains one port with the `ext-api-port` name. The system uses this port for the status check. +- Find the Kubernetes Gateway service in the `kyma-integration` Namespace. You can change its location in the `ui-api-layer` chart configuration. +- The `/v1/health` endpoint returns a status of `HTTP 200`. Any other status code indicates the service is not healthy. + +### Access the Gateway on Minikube + +To access the Gateway locally, provide the NodePort of the `core-nginx-ingress-controller` when you send the request. + +To get the NodePort, run this command: + +``` +kubectl -n kyma-system get svc core-nginx-ingress-controller -o 'jsonpath={.spec.ports[?(@.port==443)].nodePort}' +``` + +To send the request, run: + +``` +curl https://gateway.kyma.local:{NODE_PORT}/ec-default/v1/metadata/services --cert ec-default.crt --key ec-default.key -k +``` + + +### Contribution + +To learn how you can contribute to this project, see the [Contributing](/CONTRIBUTING.md) document. diff --git a/components/gateway/cmd/gateway/gateway.go b/components/gateway/cmd/gateway/gateway.go new file mode 100644 index 000000000000..42fd60802657 --- /dev/null +++ b/components/gateway/cmd/gateway/gateway.go @@ -0,0 +1,136 @@ +package main + +import ( + "net/http" + "strconv" + "sync" + "time" + + "github.com/kyma-project/kyma/components/gateway/internal/apperrors" + "github.com/kyma-project/kyma/components/gateway/internal/events/bus" + "github.com/kyma-project/kyma/components/gateway/internal/externalapi" + "github.com/kyma-project/kyma/components/gateway/internal/httptools" + "github.com/kyma-project/kyma/components/gateway/internal/k8sconsts" + "github.com/kyma-project/kyma/components/gateway/internal/metadata" + "github.com/kyma-project/kyma/components/gateway/internal/metadata/remoteenv" + "github.com/kyma-project/kyma/components/gateway/internal/metadata/secrets" + "github.com/kyma-project/kyma/components/gateway/internal/metadata/serviceapi" + "github.com/kyma-project/kyma/components/gateway/internal/proxy" + "github.com/kyma-project/kyma/components/gateway/internal/proxy/proxycache" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned" + log "github.com/sirupsen/logrus" + "k8s.io/client-go/kubernetes" + restclient "k8s.io/client-go/rest" +) + +func main() { + formatter := &log.TextFormatter{ + FullTimestamp: true, + } + log.SetFormatter(formatter) + + log.Info("Starting gateway.") + + options := parseArgs() + log.Infof("Options: %s", options) + + bus.Init(&options.sourceNamespace, &options.sourceType, &options.sourceEnvironment, &options.eventsTargetURL) + + proxyCache := proxycache.NewProxyCache(options.skipVerify, options.proxyCacheTTL) + + nameResolver := k8sconsts.NewNameResolver(options.remoteEnvironment, options.namespace) + + serviceDefinitionService, err := newServiceDefinitionService( + options.namespace, + options.remoteEnvironment, + ) + + if err != nil { + log.Errorf("Unable to create ServiceDefinitionService: '%s'", err.Error()) + } + + internalHandler := newInternalHandler(serviceDefinitionService, nameResolver, proxyCache, options.skipVerify, options.proxyTimeout) + externalHandler := externalapi.NewHandler() + + if options.requestLogging { + internalHandler = httptools.RequestLogger("Internal handler: ", internalHandler) + externalHandler = httptools.RequestLogger("External handler: ", externalHandler) + } + + externalSrv := &http.Server{ + Addr: ":" + strconv.Itoa(options.externalAPIPort), + Handler: externalHandler, + ReadTimeout: time.Duration(options.requestTimeout) * time.Second, + WriteTimeout: time.Duration(options.requestTimeout) * time.Second, + } + + internalSrv := &http.Server{ + Addr: ":" + strconv.Itoa(options.proxyPort), + Handler: internalHandler, + ReadTimeout: time.Duration(options.requestTimeout) * time.Second, + WriteTimeout: time.Duration(options.requestTimeout) * time.Second, + } + + wg := &sync.WaitGroup{} + + wg.Add(1) + go func() { + log.Info(externalSrv.ListenAndServe()) + }() + + go func() { + log.Info(internalSrv.ListenAndServe()) + }() + + wg.Wait() +} + +func newInternalHandler(serviceDefinitionService metadata.ServiceDefinitionService, nameResolver k8sconsts.NameResolver, + httpProxyCache proxycache.HTTPProxyCache, skipVerify bool, proxyTimeout int) http.Handler { + if serviceDefinitionService != nil { + oauthClient := proxy.NewOauthClient(proxyTimeout) + + return proxy.New(nameResolver, serviceDefinitionService, oauthClient, httpProxyCache, skipVerify, proxyTimeout) + } + return proxy.NewInvalidStateHandler("Gateway is not initialized properly") +} + +func newServiceDefinitionService(namespace string, remoteEnvironment string) (metadata.ServiceDefinitionService, apperrors.AppError) { + k8sConfig, err := restclient.InClusterConfig() + if err != nil { + return nil, apperrors.Internal("failed to read k8s in-cluster configuration, %s", err) + } + + coreClientset, err := kubernetes.NewForConfig(k8sConfig) + if err != nil { + return nil, apperrors.Internal("failed to create k8s core client, %s", err) + } + + remoteEnvironmentServiceRepository, apperror := newRemoteEnvironmentRepository(k8sConfig, namespace, remoteEnvironment) + if apperror != nil { + return nil, apperror + } + + secretsRepository := newSecretsRepository(coreClientset, namespace, remoteEnvironment) + + serviceAPIService := serviceapi.NewService(secretsRepository) + + return metadata.NewServiceDefinitionService(serviceAPIService, remoteEnvironmentServiceRepository), nil +} + +func newRemoteEnvironmentRepository(config *restclient.Config, namespace string, name string) (remoteenv.ServiceRepository, apperrors.AppError) { + remoteEnvironmentClientset, err := versioned.NewForConfig(config) + if err != nil { + return nil, apperrors.Internal("failed to create k8s remote environment client, %s", err) + } + + rei := remoteEnvironmentClientset.RemoteenvironmentV1alpha1().RemoteEnvironments(namespace) + + return remoteenv.NewServiceRepository(name, rei), nil +} + +func newSecretsRepository(coreClientset *kubernetes.Clientset, namespace, remoteEnvironment string) secrets.Repository { + sei := coreClientset.CoreV1().Secrets(namespace) + + return secrets.NewRepository(sei, remoteEnvironment) +} diff --git a/components/gateway/cmd/gateway/options.go b/components/gateway/cmd/gateway/options.go new file mode 100644 index 000000000000..b88034a166a2 --- /dev/null +++ b/components/gateway/cmd/gateway/options.go @@ -0,0 +1,66 @@ +package main + +import ( + "flag" + "fmt" +) + +type options struct { + externalAPIPort int + proxyPort int + eventsTargetURL string + remoteEnvironment string + namespace string + requestTimeout int + skipVerify bool + proxyTimeout int + sourceNamespace string + sourceType string + sourceEnvironment string + requestLogging bool + proxyCacheTTL int +} + +func parseArgs() *options { + externalAPIPort := flag.Int("externalAPIPort", 8081, "External API port.") + proxyPort := flag.Int("proxyPort", 8080, "Proxy port.") + eventsTargetURL := flag.String("eventsTargetURL", "http://localhost:9001/events", "Target URL for events to be sent.") + remoteEnvironment := flag.String("remoteEnvironment", "default-ec", "Remote environment name for reading and updating services.") + namespace := flag.String("namespace", "kyma-system", "Namespace used by Gateway") + requestTimeout := flag.Int("requestTimeout", 1, "Timeout for services.") + skipVerify := flag.Bool("skipVerify", false, "Flag for skipping certificate verification for proxy target.") + proxyTimeout := flag.Int("proxyTimeout", 10, "Timeout for proxy call.") + requestLogging := flag.Bool("requestLogging", false, "Flag for logging incoming requests.") + proxyCacheTTL := flag.Int("proxyCacheTTL", 120, "TTL, in seconds, for proxy cache of Remote API information") + + sourceNamespace := flag.String("sourceNamespace", "local.kyma.commerce", "The organization publishing the event.") + sourceType := flag.String("sourceType", "commerce", "The type of the event source.") + sourceEnvironment := flag.String("sourceEnvironment", "stage", "The name of the event source environment.") + + flag.Parse() + + return &options{ + externalAPIPort: *externalAPIPort, + proxyPort: *proxyPort, + eventsTargetURL: *eventsTargetURL, + remoteEnvironment: *remoteEnvironment, + namespace: *namespace, + requestTimeout: *requestTimeout, + skipVerify: *skipVerify, + proxyTimeout: *proxyTimeout, + requestLogging: *requestLogging, + proxyCacheTTL: *proxyCacheTTL, + sourceNamespace: *sourceNamespace, + sourceType: *sourceType, + sourceEnvironment: *sourceEnvironment, + } +} + +func (o *options) String() string { + return fmt.Sprintf("--externalAPIPort=%d --proxyPort=%d --eventsTargetURL=%s"+ + " --remoteEnvironment=%s --namespace=%s --requestTimeout=%d --skipVerify=%v --proxyTimeout=%d"+ + " --sourceNamespace=%s --sourceType=%s --sourceEnvironment=%s --requestLogging=%t --proxyCacheTTL=%d", + o.externalAPIPort, o.proxyPort, o.eventsTargetURL, + o.remoteEnvironment, o.namespace, o.requestTimeout, o.skipVerify, o.proxyTimeout, + o.sourceNamespace, o.sourceType, o.sourceEnvironment, o.requestLogging, o.proxyCacheTTL) +} diff --git a/components/gateway/docs/api/externalapi.yaml b/components/gateway/docs/api/externalapi.yaml new file mode 100644 index 000000000000..bca2a466481c --- /dev/null +++ b/components/gateway/docs/api/externalapi.yaml @@ -0,0 +1,434 @@ +openapi: '3.0.0' +info: + version: '1.0.0' + title: 'Kyma Gateway Metadata API' +tags: +- name: 'service metadata' + description: 'Service registering API and events catalog.' +paths: + /v1/metadata/services: + post: + tags: + - 'service metadata' + summary: 'Registers a new service' + operationId: 'registerService' + requestBody: + description: 'Service object to be registered' + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceDetails' + responses: + '200': + description: 'Successful operation' + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceId' + '400': + description: 'Bad request' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + '500': + description: 'Internal server error' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + get: + tags: + - 'service metadata' + summary: 'Gets all registered services' + operationId: 'getServices' + responses: + '200': + description: 'Successful operation' + content: + application/json: + schema: + type: 'array' + items: + $ref: '#/components/schemas/Service' + '500': + description: 'Internal server error' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + /v1/metadata/services/{serviceId}: + get: + tags: + - 'service metadata' + summary: 'Gets a service by service ID' + operationId: 'getServiceByServiceId' + parameters: + - in: 'path' + name: 'serviceId' + description: 'ID of a service' + required: true + schema: + type: 'string' + format: 'uuid' + responses: + '200': + description: 'Successful operation' + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceDetails' + '404': + description: 'Service not found' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + '500': + description: 'Internal server error' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + put: + tags: + - 'service metadata' + summary: 'Updates a service by service ID' + operationId: 'updateService' + requestBody: + description: 'Service object to be stored' + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceDetails' + parameters: + - in: 'path' + name: 'serviceId' + description: 'ID of a service' + required: true + schema: + type: 'string' + format: 'uuid' + responses: + '200': + description: 'Successful operation' + '404': + description: 'Service not found' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + '500': + description: 'Internal server error' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + delete: + tags: + - 'service metadata' + summary: 'Deletes a service by service ID' + operationId: 'deleteServiceByServiceId' + parameters: + - in: 'path' + name: 'serviceId' + description: 'ID of a service' + required: true + schema: + type: 'string' + format: 'uuid' + responses: + '204': + description: 'Successful operation' + '404': + description: 'Service not found' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + '500': + description: 'Internal server error' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + /v1/health: + get: + summary: 'Returns health of a service' + operationId: 'getHealth' + tags: + - 'health' + responses: + '200': + description: 'The service is in a good health' + /v1/events: + post: + summary: 'Publish an event' + operationId: 'publishEvent' + tags: + - 'publish' + requestBody: + description: 'The event to be published' + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PublishRequest' + responses: + '200': + description: 'The event was successfully published' + content: + application/json: + schema: + $ref: '#/components/schemas/PublishResponse' + '400': + description: 'Bad Request' + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + '401': + description: 'Authentication failure' + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + '403': + description: 'Not authorized' + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + '500': + description: 'Server error' + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' +components: + schemas: + ServiceId: + type: 'object' + properties: + id: + type: 'string' + format: 'uuid' + ServiceDetails: + type: 'object' + properties: + provider: + type: 'string' + name: + type: 'string' + description: + type: 'string' + api: + $ref: '#/components/schemas/Api' + events: + $ref: '#/components/schemas/Events' + documentation: + $ref: '#/components/schemas/Documentation' + required: + - provider + - name + - description + Service: + type: 'object' + properties: + id: + type: 'string' + format: 'uuid' + provider: + type: 'string' + name: + type: 'string' + description: + type: 'string' + Api: + type: 'object' + properties: + targetUrl: + type: 'string' + format: 'uri' + credentials: + $ref: '#/components/schemas/ApiCredentials' + spec: + type: 'object' + description: 'OpenApi v2 swagger file: https://github.com/OAI/OpenAPI-Specification/blob/master/schemas/v2.0/schema.json' + required: + - targetUrl + Events: + type: 'object' + properties: + spec: + description: 'AsynApi file v1: https://github.com/asyncapi/asyncapi/blob/develop/schema/asyncapi.json' + type: 'object' + Documentation: + type: 'object' + properties: + displayName: + type: 'string' + description: + type: 'string' + type: + type: 'string' + tags: + type: 'array' + items: + type: 'string' + docs: + type: 'array' + items: + $ref: '#/components/schemas/Document' + required: + - displayName + - description + - type + Document: + type: 'object' + properties: + title: + type: 'string' + type: + type: 'string' + source: + type: 'string' + required: + - title + - type + - source + ApiCredentials: + type: 'object' + properties: + oauth: + $ref: '#/components/schemas/OAuth' + required: + - oauth + OAuth: + type: 'object' + properties: + url: + type: 'string' + format: 'uri' + clientId: + type: 'string' + clientSecret: + type: 'string' + required: + - url + - clientId + - clientSecret + MetadataErrorResponse: + type: 'object' + properties: + code: + type: 'integer' + error: + type: 'string' + PublishRequest: + type: object + description: A Publish request + properties: + event-type: + description: Type of the event. + type: string + format: hostname + pattern: '^[a-zA-Z]+([_\-\.]?[a-zA-Z0-9]+)*$' + example: 'order.created' + event-type-version: + description: The version of the event-type. This is applicable to the data payload alone. + type: string + pattern: '^[a-zA-Z0-9]+$' + example: 'v1' + event-id: + description: Optional publisher provided ID (UUID v4) of the to-be-published event. When omitted, one will be automatically generated. + type: string + pattern: '^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$' + example: '31109198-4d69-4ae0-972d-76117f3748c8' + event-time: + description: RFC 3339 timestamp of when the event happened. + type: string + format: date-time + example: '2012-11-01T22:08:41+00:00' + data: + $ref: '#/components/schemas/AnyValue' + required: + - event-type + - event-type-version + - event-time + - data + PublishResponse: + type: object + description: A Publish response + properties: + event-id: + type: string + description: ID of the published event + pattern: '^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$' + example: '31109198-4d69-4ae0-972d-76117f3748c8' + required: + - event-id + AnyValue: + nullable: false + description: Can be any value but null. + APIError: + type: object + description: API Error response body + properties: + status: + type: integer + description: >- + original HTTP error code, should be consistent with the response HTTP code + minimum: 100 + maximum: 599 + type: + type: string + description: >- + classification of the error type, lower case with underscore eg + validation_failure + pattern: '[a-z]+[a-z_]*[a-z]+' + message: + type: string + description: descriptive error message for debugging + moreInfo: + type: string + format: uri + description: link to documentation to investigate further and finding support + details: + type: array + description: list of error causes + items: + $ref: '#/components/schemas/APIErrorDetail' + required: + - status + - type + APIErrorDetail: + description: schema for specific error detail + type: object + properties: + field: + type: string + description: >- + a bean notation expression specifying the element in request + data causing the error, eg product.variants[3].name, this can + be empty if violation was not field specific + type: + type: string + description: >- + classification of the error detail type, lower case with + underscore eg missing_value, this value must be always + interpreted in context of the general error type. + pattern: '[a-z]+[a-z_]*[a-z]+' + message: + type: string + description: descriptive error detail message for debugging + moreInfo: + type: string + format: uri + description: >- + link to documentation to investigate further and finding + support for error detail + required: + - type diff --git a/components/gateway/docs/events/README.md b/components/gateway/docs/events/README.md new file mode 100644 index 000000000000..191dfd609919 --- /dev/null +++ b/components/gateway/docs/events/README.md @@ -0,0 +1,47 @@ +# Event processing application + +## Overview +The Event process as part of the Gateway exposes the [Events API](https://github.com/kyma-project/kyma/components/gateway/blob/master/docs/events/api.yaml) to an external application that publishes Events to Kyma. + +It uses a mock implementation of the `Receiver End Point`. +This mock dumps an incoming HTTP request and returns it in the body of the response that the `event-id` finds in the request. + +## Build +A Makefile is located in the `cmd/events` folder. +- To build the event processing application, use this command: +``` +$make compile +``` +This command compiles and runs the unit tests. +The event process is available in the `cmd/events/bin` folder. +- To build a `docker image`, use this command: +``` +make docker-build` +``` + +## Command line arguments +These are the available command line arguments: + +``` +events -help + -dump_requests + Dump the incoming request + -help + Print the command line options + -max_requests int + The max number of accepted concurrent requests, 0 means no limitation + -port int + The events/publish listen port (default 8080) + -source_environment string + The ID of the event source (default "stage") + -source_namespace string + The organization publishing the event + -source_type string + The type of the event source +``` + +## Test the Event API +Use the SwaggerUI plugin of your IDE to load and test the [Events API](https://github.com/kyma-project/kyma/blob/master/components/gateway/docs/api/externalapi.yaml#L166), or do it directly with this command: +``` +curl -v -X POST "localhost:8080/v1/events" -H "accept: application/json" -H "Content-Type: application/json" -d "{\"event-type\":\"order.created\",\"event-type-version\":\"v1\",\"event-id\":\"31109198-4d69-4ae0-972d-76117f3748c8\",\"event-time\":\"2012-11-01T22:08:41+00:00\", \"data\":\"my order created\" }" +``` diff --git a/components/gateway/docs/instalation/Installation.md b/components/gateway/docs/instalation/Installation.md new file mode 100644 index 000000000000..562229835689 --- /dev/null +++ b/components/gateway/docs/instalation/Installation.md @@ -0,0 +1,168 @@ +# Adding a new Application Connector to Kyma + +## Overview + +The Application Connector connects an external solution to Kyma. + +## Introduction + +The Application Connector consists of: +- the [Remote Environment](https://github.com/kyma-project/kyma/blob/master/docs/remote-environment.md) +- the Gateway - A service responsible for registering available services (APIs, Events) and proxying calls to the registered solution. +- Ingress-Nginx - A controller that exposes multiple Application Connectors to the external world. + +By default, Kyma comes with two default Application Connectors preconfigured. A user can add more Application Connectors using Helm package manager. + +To add an Application Connector, download the `remote-environments.zip` package. Unpack it and place it in the project directory. + + +## Installation + +Use this command to install the Remote Environment: +``` bash +helm install --name remote-environment-name --set deployment.args.sourceType=commerce --set global.domainName=domain.cluster.com --set global.isLocalEnv=false --namespace kyma-integration ./remote-environments +``` + +To install locally on Minikube, provide the NodePort as shown in this example: +``` bash +helm install --name remote-environment-name --set deployment.args.sourceType=commerce --set global.domainName=domain.cluster.com --set global.isLocalEnv=true --set service.externalapi.nodePort=32001 --namespace kyma-integration ./remote-environments +``` + +The user can override the following parameters: + +- **sourceEnvironment** - The Event source environment name. +- **sourceType** - The Event source type. +- **sourceNamespace** - The organization that publishes the Event. + +## Working with helm + +Helm provides you with several useful commands: +- `helm list` - list existing helm releases +- `helm test [release-name]` - test a release +- `helm get [release-name]` - see the contents of `.yaml` files that make up the release +- `helm status [release-name]` - show the status of the named release +- `helm delete [release-name]` - delete the release from Kubernetes + +To review a complete list of Helm commands, see [Helm documentation](https://docs.helm.sh/helm/) or use `helm --help` + + ## Check with kubectl + +Make sure everything runs with kubectl: +`kubectl get pods -n kyma-integration` +`kubectl get services -n kyma-integration` + +## Access the Application Connector + +The Ingress-Nginx controller exposes Kyma Gateways to the outside world using a public IP address/DNS name. The DNS name of Ingress is `gateway.[cluster-dns]`. For example, `gateway.servicemanager.cluster.kyma.cx`. + +Expose a particular Gateway service as the path of the Remote Environment. For example, if you want to reach the Gateway of the Remote Environment named `ec-dafault`, you need to use following URL: `gateway.servicemanager.cluster.kyma.cx/ec-default`. The communication requires a valid client certificate. Check the security documentation for further details. + +The following example shows how to get all ServiceClasses: + +``` console +http GET https://gateway.servicemanager.cluster.kyma.cx/ec-default/v1/metadata/services --cert=ec-default.pem +``` + +## Example + +This example shows how to add a new Application Connector to the Minikube. + +To integrate a new instance of `Marketing` marked as a `Production` environment, the example uses the following values: + +- **sourceEnvironment** = production +- **sourceType** = marketing +- **sourceNamespace** = organization.com + +Start with: + +``` bash +helm install --name hmc-prod --set deployment.args.sourceType=marketing --set deployment.args.sourceEnvironment=production --set global.isLocalEnv=true --set service.externalapi.nodePort=32002 --namespace kyma-integration ./remote-environments +``` + +The following output displays: +``` bash +NAME: hmc-prod +LAST DEPLOYED: Fri Apr 20 11:25:44 2018 +NAMESPACE: kyma-integration +STATUS: DEPLOYED + +RESOURCES: +==> v1/Role +NAME AGE +hmc-prod-gateway-role 0s + +==> v1/RoleBinding +NAME AGE +hmc-prod-gateway-rolebinding 0s + +==> v1/Service +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +hmc-prod-gateway-external-api NodePort 10.108.126.243 8081:32002/TCP 0s +hmc-prod-gateway-echo ClusterIP 10.100.94.12 8080/TCP 0s + +==> v1beta1/Deployment +NAME DESIRED CURRENT UP-TO-DATE AVAILABLE AGE +hmc-prod-gateway 1 1 1 0 0s + +==> v1alpha1/RemoteEnvironment +NAME AGE +hmc-prod 0s + +==> v1/Pod(related) +NAME READY STATUS RESTARTS AGE +hmc-prod-gateway-67469769c8-6lgjl 0/1 ContainerCreating 0 0s + + +NOTES: +------------------------------------------------------------------------------------------------------------------------ + +Thank you for installing Gateway helm chart for Kubernetes version 0.0.1. + +To learn more about the release, see: + + $ helm status hmc-prod + $ helm get hmc-prod + +------------------------------------------------------------------------------------------------------------------------ + +``` +Running `helm status hmc-prod` shows similar output with the most recent status of the release. + +Run the `helm list` command. See your release among the others: +``` bash +cluster-essentials 1 Wed Apr 18 07:50:01 2018 DEPLOYED kyma-cluster-essentials-0.0.1 kyma-system +ec-default 1 Wed Apr 18 07:57:50 2018 DEPLOYED gateway-0.0.1 kyma-integration +hmc-default 1 Wed Apr 18 07:57:36 2018 DEPLOYED gateway-0.0.1 kyma-integration +istio 1 Wed Apr 18 07:50:04 2018 DEPLOYED istio-0.5.0 istio-system +prometheus-operator 1 Wed Apr 18 07:51:50 2018 DEPLOYED prometheus-operator-0.17.0 kyma-system +hmc-prod 1 Fri Apr 20 11:25:44 2018 DEPLOYED gateway-0.0.1 kyma-integration +sf-core 2 Wed Apr 18 07:56:56 2018 DEPLOYED kyma-core-0.0.1 kyma-system +``` + +Use `kubectl` commands to see Kubernetes resources associated with the release. + +`kubectl get pods -n kyma-integration` +``` bash +NAME READY STATUS RESTARTS AGE +ec-default-gateway-5b77fdf7b5-rx64m 2/2 Running 3 2d +hmc-default-gateway-f88b58978-75dkb 2/2 Running 3 2d +hmc-prod-gateway-67469769c8-6lgjl 1/1 Running 0 1m +``` + +`kubectl get services -n kyma-integration` +``` bash +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +ec-default-gateway-echo ClusterIP 10.96.212.205 8080/TCP 2d +ec-default-gateway-external-api NodePort 10.101.245.196 8081:32000/TCP 2d +hmc-default-gateway-echo ClusterIP 10.101.68.223 8080/TCP 2d +hmc-default-gateway-external-api NodePort 10.96.215.1 8081:32001/TCP 2d +hmc-prod-gateway-echo ClusterIP 10.100.94.12 8080/TCP 1m +hmc-prod-gateway-external-api NodePort 10.108.126.243 8081:32002/TCP 1m +``` + +When you are done, delete the release with the following command: +`helm delete hmc-prod --purge` + +```bash +release "hmc-prod" deleted +``` diff --git a/components/gateway/docs/instalation/remote-environments.zip b/components/gateway/docs/instalation/remote-environments.zip new file mode 100644 index 0000000000000000000000000000000000000000..d915774d81cbd88ac52399a428ba321b0a252c81 GIT binary patch literal 6141 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 MlK=oLhq2%O2eE-XApigX literal 0 HcmV?d00001 diff --git a/components/gateway/hack/custom-boilerplate.go.txt b/components/gateway/hack/custom-boilerplate.go.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/components/gateway/hack/generate-groups.sh b/components/gateway/hack/generate-groups.sh new file mode 100755 index 000000000000..bf4890248c5d --- /dev/null +++ b/components/gateway/hack/generate-groups.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +# This file was copied from k8s.io/code-generator project +# The only one modification was to specify path in `go install` execution (line 52). + +# Copyright 2017 The Kubernetes Authors. +# +# 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. + +set -o errexit +set -o nounset +set -o pipefail + +# generate-groups generates everything for a project with external types only, e.g. a project based +# on CustomResourceDefinitions. + +if [ "$#" -lt 4 ] || [ "${1}" == "--help" ]; then + cat < ... + + the generators comma separated to run (deepcopy,defaulter,client,lister,informer) or "all". + the output package name (e.g. github.com/example/project/pkg/generated). + the external types dir (e.g. github.com/example/api or github.com/example/project/pkg/apis). + the groups and their versions in the format "groupA:v1,v2 groupB:v1 groupC:v2", relative + to . + ... arbitrary flags passed to all generator binaries. + + +Examples: + $(basename $0) all github.com/example/project/pkg/client github.com/example/project/pkg/apis "foo:v1 bar:v1alpha1,v1beta1" + $(basename $0) deepcopy,client github.com/example/project/pkg/client github.com/example/project/pkg/apis "foo:v1 bar:v1alpha1,v1beta1" +EOF + exit 0 +fi + +GENS="$1" +OUTPUT_PKG="$2" +APIS_PKG="$3" +GROUPS_WITH_VERSIONS="$4" +shift 4 + +go install ./vendor/k8s.io/code-generator/cmd/{defaulter-gen,client-gen,lister-gen,informer-gen,deepcopy-gen} +function codegen::join() { local IFS="$1"; shift; echo "$*"; } + +# enumerate group versions +FQ_APIS=() # e.g. k8s.io/api/apps/v1 +for GVs in ${GROUPS_WITH_VERSIONS}; do + IFS=: read G Vs <<<"${GVs}" + + # enumerate versions + for V in ${Vs//,/ }; do + FQ_APIS+=(${APIS_PKG}/${G}/${V}) + done +done + +if [ "${GENS}" = "all" ] || grep -qw "deepcopy" <<<"${GENS}"; then + echo "Generating deepcopy funcs" + ${GOPATH}/bin/deepcopy-gen --input-dirs $(codegen::join , "${FQ_APIS[@]}") -O zz_generated.deepcopy --bounding-dirs ${APIS_PKG} "$@" +fi + +if [ "${GENS}" = "all" ] || grep -qw "client" <<<"${GENS}"; then + echo "Generating clientset for ${GROUPS_WITH_VERSIONS} at ${OUTPUT_PKG}/clientset" + ${GOPATH}/bin/client-gen --clientset-name versioned --input-base "" --input $(codegen::join , "${FQ_APIS[@]}") --clientset-path ${OUTPUT_PKG}/clientset "$@" +fi + +if [ "${GENS}" = "all" ] || grep -qw "lister" <<<"${GENS}"; then + echo "Generating listers for ${GROUPS_WITH_VERSIONS} at ${OUTPUT_PKG}/listers" + ${GOPATH}/bin/lister-gen --input-dirs $(codegen::join , "${FQ_APIS[@]}") --output-package ${OUTPUT_PKG}/listers "$@" +fi + +if [ "${GENS}" = "all" ] || grep -qw "informer" <<<"${GENS}"; then + echo "Generating informers for ${GROUPS_WITH_VERSIONS} at ${OUTPUT_PKG}/informers" + ${GOPATH}/bin/informer-gen \ + --input-dirs $(codegen::join , "${FQ_APIS[@]}") \ + --versioned-clientset-package ${OUTPUT_PKG}/clientset/versioned \ + --listers-package ${OUTPUT_PKG}/listers \ + --output-package ${OUTPUT_PKG}/informers \ + "$@" +fi diff --git a/components/gateway/hack/update-codegen.sh b/components/gateway/hack/update-codegen.sh new file mode 100755 index 000000000000..ce5de13ea8f4 --- /dev/null +++ b/components/gateway/hack/update-codegen.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -o errexit +set -o nounset +set -o pipefail + +SCRIPT_ROOT=$(dirname ${BASH_SOURCE})/.. +CODEGEN_PKG=${CODEGEN_PKG:-$(cd ${SCRIPT_ROOT}; ls -d -1 ./vendor/k8s.io/code-generator 2>/dev/null || echo ${GOPATH}/src/k8s.io/code-generator)} +GATEWAY_ROOT_PKG="github.com/kyma-project/kyma/components/gateway/pkg" + +./hack/generate-groups.sh all \ + ${GATEWAY_ROOT_PKG}/client ${GATEWAY_ROOT_PKG}/apis \ + remoteenvironment:v1alpha1 \ + --go-header-file ${SCRIPT_ROOT}/hack/custom-boilerplate.go.txt diff --git a/components/gateway/internal/apperrors/apperrors.go b/components/gateway/internal/apperrors/apperrors.go new file mode 100644 index 000000000000..45d545d6f34c --- /dev/null +++ b/components/gateway/internal/apperrors/apperrors.go @@ -0,0 +1,53 @@ +package apperrors + +import "fmt" + +const ( + CodeInternal = 1 + CodeNotFound = 2 + CodeAlreadyExists = 3 + CodeWrongInput = 4 + CodeUpstreamServerCallFailed = 5 +) + +type AppError interface { + Code() int + Error() string +} + +type appError struct { + code int + message string +} + +func errorf(code int, format string, a ...interface{}) AppError { + return appError{code: code, message: fmt.Sprintf(format, a...)} +} + +func Internal(format string, a ...interface{}) AppError { + return errorf(CodeInternal, format, a...) +} + +func NotFound(format string, a ...interface{}) AppError { + return errorf(CodeNotFound, format, a...) +} + +func AlreadyExists(format string, a ...interface{}) AppError { + return errorf(CodeAlreadyExists, format, a...) +} + +func WrongInput(format string, a ...interface{}) AppError { + return errorf(CodeWrongInput, format, a...) +} + +func UpstreamServerCallFailed(format string, a ...interface{}) AppError { + return errorf(CodeUpstreamServerCallFailed, format, a...) +} + +func (ae appError) Code() int { + return ae.code +} + +func (ae appError) Error() string { + return ae.message +} diff --git a/components/gateway/internal/apperrors/apperrors_test.go b/components/gateway/internal/apperrors/apperrors_test.go new file mode 100644 index 000000000000..0367732200f6 --- /dev/null +++ b/components/gateway/internal/apperrors/apperrors_test.go @@ -0,0 +1,34 @@ +package apperrors + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAppError(t *testing.T) { + + t.Run("should create error with proper code", func(t *testing.T) { + assert.Equal(t, CodeInternal, Internal("error").Code()) + assert.Equal(t, CodeNotFound, NotFound("error").Code()) + assert.Equal(t, CodeAlreadyExists, AlreadyExists("error").Code()) + assert.Equal(t, CodeWrongInput, WrongInput("error").Code()) + assert.Equal(t, CodeUpstreamServerCallFailed, UpstreamServerCallFailed("error").Code()) + }) + + t.Run("should create error with simple message", func(t *testing.T) { + assert.Equal(t, "error", Internal("error").Error()) + assert.Equal(t, "error", NotFound("error").Error()) + assert.Equal(t, "error", AlreadyExists("error").Error()) + assert.Equal(t, "error", WrongInput("error").Error()) + assert.Equal(t, "error", UpstreamServerCallFailed("error").Error()) + }) + + t.Run("should create error with formatted message", func(t *testing.T) { + assert.Equal(t, "code: 1, error: bug", Internal("code: %d, error: %s", 1, "bug").Error()) + assert.Equal(t, "code: 1, error: bug", NotFound("code: %d, error: %s", 1, "bug").Error()) + assert.Equal(t, "code: 1, error: bug", AlreadyExists("code: %d, error: %s", 1, "bug").Error()) + assert.Equal(t, "code: 1, error: bug", WrongInput("code: %d, error: %s", 1, "bug").Error()) + assert.Equal(t, "code: 1, error: bug", UpstreamServerCallFailed("code: %d, error: %s", 1, "bug").Error()) + }) +} diff --git a/components/gateway/internal/events/api/types.go b/components/gateway/internal/events/api/types.go new file mode 100644 index 000000000000..cb381af4ec10 --- /dev/null +++ b/components/gateway/internal/events/api/types.go @@ -0,0 +1,69 @@ +// GENERATED FILE: DO NOT EDIT! + +package api + +// PublishRequest implements the service definition of PublishRequest +type PublishRequest struct { + EventType string `json:"event-type,omitempty"` + EventTypeVersion string `json:"event-type-version,omitempty"` + EventId string `json:"event-id,omitempty"` + EventTime string `json:"event-time,omitempty"` + Data AnyValue `json:"data,omitempty"` +} + +// PublishResponse implements the service definition of PublishResponse +type PublishResponse struct { + EventId string `json:"event-id,omitempty"` +} + +// AnyValue implements the service definition of AnyValue +type AnyValue interface { +} + +// Error implements the service definition of APIError +type Error struct { + Status int `json:"status,omitempty"` + Type string `json:"type,omitempty"` + Message string `json:"message,omitempty"` + MoreInfo string `json:"moreInfo,omitempty"` + Details []ErrorDetail `json:"details,omitempty"` +} + +// ErrorDetail implements the service definition of APIErrorDetail +type ErrorDetail struct { + Field string `json:"field,omitempty"` + Type string `json:"type,omitempty"` + Message string `json:"message,omitempty"` + MoreInfo string `json:"moreInfo,omitempty"` +} + +// PublishEventParameters holds parameters to PublishEvent +type PublishEventParameters struct { + Publishrequest PublishRequest `json:"publishrequest,omitempty"` +} + +// PublishEventResponses holds responses of PublishEvent +type PublishEventResponses struct { + Ok *PublishResponse + Error *Error +} + +// EventSource implements the Source definition of the outbound messaging API +type EventSource struct { + SourceNamespace string `json:"source-namespace,omitempty"` + SourceType string `json:"source-type,omitempty"` + SourceEnvironment string `json:"source-environment,omitempty"` +} + +// SendEventParameters implements the request to the outbound messaging API +type SendEventParameters struct { + Eventsource EventSource `json:"source,omitempty"` + EventType string `json:"event-type,omitempty"` + EventTypeVersion string `json:"event-type-version,omitempty"` + EventId string `json:"event-id,omitempty"` + EventTime string `json:"event-time,omitempty"` + Data AnyValue `json:"data,omitempty"` +} + +// SendEventResponse holds the response from outbound messaging API +type SendEventResponse PublishEventResponses diff --git a/components/gateway/internal/events/bus/process.go b/components/gateway/internal/events/bus/process.go new file mode 100644 index 000000000000..a68f6dc60906 --- /dev/null +++ b/components/gateway/internal/events/bus/process.go @@ -0,0 +1,54 @@ +package bus + +import ( + "fmt" + + "github.com/kyma-project/kyma/components/gateway/internal/events/api" +) + +type configurationData struct { + sourceNamespace *string + sourceType *string + sourceEnvironment *string +} + +var conf *configurationData +var eventsTargetURL string + +// Init should be used to initialize the "source" related configuration data +func Init(sourceNamespace *string, sourceType *string, sourceEnvironment *string, targetUrl *string) { + conf = &configurationData{ + sourceNamespace: sourceNamespace, + sourceType: sourceType, + sourceEnvironment: sourceEnvironment, + } + + eventsTargetURL = *targetUrl +} + +func checkConf() (err error) { + if conf == nil { + return fmt.Errorf("configuration data not initialized") + } + return nil +} + +// AddSource adds the "source" related data to the incoming request +func AddSource(parameters *api.PublishEventParameters) (resp *api.SendEventParameters, err error) { + if err := checkConf(); err != nil { + return nil, err + } + + es := api.EventSource{SourceNamespace: *conf.sourceNamespace, SourceType: *conf.sourceType, SourceEnvironment: *conf.sourceEnvironment} + + sendRequest := api.SendEventParameters{ + Eventsource: es, + EventType: parameters.Publishrequest.EventType, + EventTypeVersion: parameters.Publishrequest.EventTypeVersion, + EventId: parameters.Publishrequest.EventId, + EventTime: parameters.Publishrequest.EventTime, + Data: parameters.Publishrequest.Data, + } + + return &sendRequest, nil +} diff --git a/components/gateway/internal/events/bus/send.go b/components/gateway/internal/events/bus/send.go new file mode 100644 index 000000000000..cd8a65d71935 --- /dev/null +++ b/components/gateway/internal/events/bus/send.go @@ -0,0 +1,98 @@ +package bus + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + + "github.com/kyma-project/kyma/components/gateway/internal/events/api" + "github.com/kyma-project/kyma/components/gateway/internal/httpconsts" + "github.com/kyma-project/kyma/components/gateway/internal/httptools" +) + +var ( + httpClientProvider = httptools.DefaultHttpClientProvider + httpRequestProvider = httptools.DefaultHttpRequestProvider +) + +func InitEventSender(clientProvider httptools.HttpClientProvider, requestProvider httptools.HttpRequestProvider) { + httpClientProvider = clientProvider + httpRequestProvider = requestProvider +} + +// SendEvent sends the incoming request to the Sender +func SendEvent(req *api.SendEventParameters, traceHeaders *map[string]string) (*api.SendEventResponse, error) { + body := new(bytes.Buffer) + json.NewEncoder(body).Encode(req) + httpReq, err := httpRequestProvider(http.MethodPost, "", body) + if err != nil { + return nil, err + } + + headers := make(http.Header) + headers.Set(httpconsts.HeaderContentType, httpconsts.ContentTypeApplicationJson) + httpReq.Header = headers + + reqURL, err := url.ParseRequestURI(eventsTargetURL) + if err != nil { + fmt.Println(err) + } + + httpReq.URL = reqURL + httpReq.Header.Add(httpconsts.HeaderXForwardedFor, httpReq.Host) + httpReq.Header.Del(httpconsts.HeaderConnection) + + addTraceHeaders(httpReq, traceHeaders) + + resp, err := httpClientProvider().Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + response := api.SendEventResponse{} + + if resp.StatusCode == 200 { + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + result := &api.PublishResponse{} + + err = json.Unmarshal(body, result) + if err != nil { + return nil, err + } + + response.Ok = result + + } else { + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + result := &api.Error{} + + err = json.Unmarshal(body, result) + if err != nil { + return nil, err + } + + response.Error = result + } + + return &response, nil +} + +func addTraceHeaders(httpReq *http.Request, traceHeaders *map[string]string) { + if traceHeaders != nil { + for key, value := range *traceHeaders { + httpReq.Header.Add(key, value) + } + } +} diff --git a/components/gateway/internal/events/shared/constants.go b/components/gateway/internal/events/shared/constants.go new file mode 100644 index 000000000000..d95e4e3ea379 --- /dev/null +++ b/components/gateway/internal/events/shared/constants.go @@ -0,0 +1,35 @@ +package shared + +const ( + AllowedEventTypeVersionChars = `[a-zA-Z0-9]+` + AllowedEventIdChars = `^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$` +) + +// Handlers paths +const ( + EventsPath = "/v1/events" +) + +// Error messages +const ( + ErrorMessageBadPayload = "Bad Payload Syntax" + ErrorMessageMissingField = "Missing Field" + ErrorMessageInvalidField = "Invalid Field" +) + +//Error type definition +const ( + ErrorTypeBadPayload = "bad_payload_syntax" + ErrorTypeMissingField = "missing_field" + ErrorTypeValidationViolation = "validation_violation" + ErrorTypeInvalidField = "invalid_field" +) + +// Field definition +const ( + FieldEventId = "event-id" + FieldEventTime = "event-time" + FieldEventType = "event-type" + FieldEventTypeVersion = "event-type-version" + FieldData = "data" +) diff --git a/components/gateway/internal/events/shared/utils.go b/components/gateway/internal/events/shared/utils.go new file mode 100644 index 000000000000..a35ee4ff207c --- /dev/null +++ b/components/gateway/internal/events/shared/utils.go @@ -0,0 +1,55 @@ +package shared + +import ( + "net/http" + + "github.com/kyma-project/kyma/components/gateway/internal/events/api" +) + +func ErrorResponseBadRequest(moreInfo string) (response *api.PublishEventResponses) { + details := []api.ErrorDetail{} + apiError := api.Error{Status: http.StatusBadRequest, Type: ErrorTypeBadPayload, Message: ErrorMessageBadPayload, MoreInfo: moreInfo, Details: details} + return &api.PublishEventResponses{Ok: nil, Error: &apiError} +} + +func ErrorResponseMissingFieldEventType() (response *api.PublishEventResponses) { + return createMissingFieldError(FieldEventType) +} + +func ErrorResponseMissingFieldEventTypeVersion() (response *api.PublishEventResponses) { + return createMissingFieldError(FieldEventTypeVersion) +} + +func ErrorResponseWrongEventTypeVersion() (response *api.PublishEventResponses) { + return createInvalidFieldError(FieldEventTypeVersion) +} + +func ErrorResponseMissingFieldEventTime() (response *api.PublishEventResponses) { + return createMissingFieldError(FieldEventTime) +} + +func ErrorResponseWrongEventTime(err error) (response *api.PublishEventResponses) { + return createInvalidFieldError(FieldEventTime) +} + +func ErrorResponseWrongEventId() (response *api.PublishEventResponses) { + return createInvalidFieldError(FieldEventId) +} + +func ErrorResponseMissingFieldData() (response *api.PublishEventResponses) { + return createMissingFieldError(FieldData) +} + +func createMissingFieldError(field interface{}) (response *api.PublishEventResponses) { + apiErrorDetail := api.ErrorDetail{Field: field.(string), Type: ErrorTypeMissingField, Message: ErrorMessageMissingField, MoreInfo: ""} + details := []api.ErrorDetail{apiErrorDetail} + apiError := api.Error{Status: http.StatusBadRequest, Type: ErrorTypeValidationViolation, Message: ErrorMessageMissingField, MoreInfo: "", Details: details} + return &api.PublishEventResponses{Ok: nil, Error: &apiError} +} + +func createInvalidFieldError(field interface{}) (response *api.PublishEventResponses) { + apiErrorDetail := api.ErrorDetail{Field: field.(string), Type: ErrorTypeInvalidField, Message: ErrorMessageInvalidField, MoreInfo: ""} + details := []api.ErrorDetail{apiErrorDetail} + apiError := api.Error{Status: http.StatusBadRequest, Type: ErrorTypeValidationViolation, Message: ErrorMessageInvalidField, MoreInfo: "", Details: details} + return &api.PublishEventResponses{Ok: nil, Error: &apiError} +} diff --git a/components/gateway/internal/externalapi/blackb_data_test.go b/components/gateway/internal/externalapi/blackb_data_test.go new file mode 100644 index 000000000000..96ceb9175138 --- /dev/null +++ b/components/gateway/internal/externalapi/blackb_data_test.go @@ -0,0 +1,57 @@ +package externalapi + +import ( + "net/http" + "testing" + + "github.com/kyma-project/kyma/components/gateway/internal/events/api" + "github.com/kyma-project/kyma/components/gateway/internal/events/shared" +) + +func TestErrorEmptyData(t *testing.T) { + s := "{\"event-type\":\"order.created\",\"event-type-version\":\"v1\",\"event-id\":\"31109198-4d69-4ae0-972d-76117f3748c8\",\"event-time\":\"2012-11-01T22:08:41+00:00\"}" + wantErrorDetail := api.ErrorDetail{Field: shared.FieldData, Type: shared.ErrorTypeMissingField, Message: shared.ErrorMessageMissingField, MoreInfo: ""} + result, err := sendAndReceiveError(t, &s) + if err != nil { + t.Errorf("%s", err) + } else { + checkEmptyParameter(t, result, &wantErrorDetail) + } +} + +func TestErrorWrongDataNullValue(t *testing.T) { + s := "{\"event-type\":\"order.created\",\"event-type-version\":\"v1\",\"event-id\":\"31109198-4d69-4ae0-972d-76117f3748c8\",\"event-time\":\"2012-11-01T22:08:41+00:00\"" + + ",\"data\":null }" + wantErrorDetail := api.ErrorDetail{Field: shared.FieldData, Type: shared.ErrorTypeMissingField, Message: shared.ErrorMessageMissingField, MoreInfo: ""} + result, err := sendAndReceiveError(t, &s) + if err != nil { + t.Errorf("%s", err) + } else { + checkEmptyParameter(t, result, &wantErrorDetail) + } +} + +func TestErrorWrongDataEmptyStringValue(t *testing.T) { + s := "{\"event-type\":\"order.created\",\"event-type-version\":\"v1\",\"event-id\":\"31109198-4d69-4ae0-972d-76117f3748c8\",\"event-time\":\"2012-11-01T22:08:41+00:00\"" + + ",\"data\":\"\" }" + wantErrorDetail := api.ErrorDetail{Field: shared.FieldData, Type: shared.ErrorTypeMissingField, Message: shared.ErrorMessageMissingField, MoreInfo: ""} + result, err := sendAndReceiveError(t, &s) + if err != nil { + t.Errorf("%s", err) + } else { + checkEmptyParameter(t, result, &wantErrorDetail) + } +} + +func TestErrorWrongDataJsonValue(t *testing.T) { + s := "{\"event-type\":\"order.created\",\"event-type-version\":\"v1\",\"event-id\":\"31109198-4d69-4ae0-972d-76117f3748c8\",\"event-time\":\"2012-11-01T22:08:41+00:00\"" + + ",\"data\":\"{\"number\":\"123\"}\" }" + wantError := api.Error{Status: http.StatusBadRequest, Type: shared.ErrorTypeBadPayload, Message: shared.ErrorMessageBadPayload, + MoreInfo: "", Details: []api.ErrorDetail{}} + result, err := sendAndReceiveError(t, &s) + if err != nil { + t.Errorf("%s", err) + } else { + checkBadRequest(t, result, &wantError) + } +} diff --git a/components/gateway/internal/externalapi/blackb_test.go b/components/gateway/internal/externalapi/blackb_test.go new file mode 100644 index 000000000000..9262b1a56932 --- /dev/null +++ b/components/gateway/internal/externalapi/blackb_test.go @@ -0,0 +1,208 @@ +package externalapi + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/kyma-project/kyma/components/gateway/internal/events/api" + "github.com/kyma-project/kyma/components/gateway/internal/events/shared" +) + +func TestErrorNoContent(t *testing.T) { + req, err := http.NewRequest("POST", shared.EventsPath, nil) + if err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + handler := NewEventsHandler() + handler.ServeHTTP(recorder, req) + if status := recorder.Code; status != http.StatusBadRequest { + t.Errorf("Wrong status code: got %v want %v", status, http.StatusBadRequest) + } + body, err := ioutil.ReadAll(recorder.Result().Body) + if err != nil { + t.Fatal(err) + } + result := &api.Error{} + err = json.Unmarshal(body, result) + if err != nil { + t.Fatal(err) + } + wantError := api.Error{Status: http.StatusBadRequest, Type: shared.ErrorTypeBadPayload, Message: shared.ErrorMessageBadPayload, + MoreInfo: "Empty request body", Details: []api.ErrorDetail{}} + checkEmptyRequest(t, result, &wantError) +} + +func TestErrorNoParameters(t *testing.T) { + s := "" + wantError := api.Error{Status: http.StatusBadRequest, Type: shared.ErrorTypeBadPayload, Message: shared.ErrorMessageBadPayload, + MoreInfo: "", Details: []api.ErrorDetail{}} + result, err := sendAndReceiveError(t, &s) + if err != nil { + t.Errorf("%s", err) + } else { + checkEmptyRequest(t, result, &wantError) + } +} + +func TestErrorNoEventType(t *testing.T) { + s := "{\"event-type-version\":\"v1\",\"event-id\":\"31109198-4d69-4ae0-972d-76117f3748c8\",\"event-time\":\"2012-11-01T22:08:41+00:00\"}" + wantErrorDetail := api.ErrorDetail{Field: shared.FieldEventType, Type: shared.ErrorTypeMissingField, Message: shared.ErrorMessageMissingField, MoreInfo: ""} + result, err := sendAndReceiveError(t, &s) + if err != nil { + t.Errorf("%s", err) + } else { + checkEmptyParameter(t, result, &wantErrorDetail) + } +} + +func TestErrorEmptyEventType(t *testing.T) { + s := "{\"event-type\":\"\",\"event-type-version\":\"v1\",\"event-id\":\"31109198-4d69-4ae0-972d-76117f3748c8\",\"event-time\":\"2012-11-01T22:08:41+00:00\"}" + wantErrorDetail := api.ErrorDetail{Field: shared.FieldEventType, Type: shared.ErrorTypeMissingField, Message: shared.ErrorMessageMissingField, MoreInfo: ""} + result, err := sendAndReceiveError(t, &s) + if err != nil { + t.Errorf("%s", err) + } else { + checkEmptyParameter(t, result, &wantErrorDetail) + } +} + +func TestErrorEmptyEventTime(t *testing.T) { + s := "{\"event-type\":\"order.created\",\"event-type-version\":\"v1\",\"event-id\":\"31109198-4d69-4ae0-972d-76117f3748c8\",\"event-time\":\"\"}" + wantErrorDetail := api.ErrorDetail{Field: shared.FieldEventTime, Type: shared.ErrorTypeMissingField, Message: shared.ErrorMessageMissingField, MoreInfo: ""} + result, err := sendAndReceiveError(t, &s) + if err != nil { + t.Errorf("%s", err) + } else { + checkEmptyParameter(t, result, &wantErrorDetail) + } +} + +func TestErrorWrongEventTime(t *testing.T) { + s := "{\"event-type\":\"order.created\",\"event-type-version\":\"v1\",\"event-id\":\"31109198-4d69-4ae0-972d-76117f3748c8\",\"event-time\":\"2012-11-01T22\"}" + result, err := sendAndReceiveError(t, &s) + if err != nil { + t.Errorf("%s", err) + } else { + checkWrongEventTime(t, result) + } +} + +func TestErrorWrongEventId(t *testing.T) { + s := "{\"event-type\":\"order.created\",\"event-type-version\":\"v1\",\"event-id\":\"31109198\",\"event-time\":\"2012-11-01T22:08:41+00:00\"}" + wantErrorDetail := api.ErrorDetail{Field: shared.FieldEventId, Type: shared.ErrorTypeInvalidField, Message: shared.ErrorMessageInvalidField, MoreInfo: ""} + result, err := sendAndReceiveError(t, &s) + if err != nil { + t.Errorf("%s", err) + } else { + checkWrongParameter(t, result, &wantErrorDetail) + } +} + +func sendAndReceiveError(t *testing.T, s *string) (result *api.Error, err error) { + req, err := http.NewRequest("POST", shared.EventsPath, strings.NewReader(*s)) + if err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + handler := NewEventsHandler() + handler.ServeHTTP(recorder, req) + if status := recorder.Code; status != http.StatusBadRequest { + t.Errorf("Wrong status code: got %v want %v", status, http.StatusBadRequest) + return nil, fmt.Errorf("Unexpected http response code") + } + body, err := ioutil.ReadAll(recorder.Result().Body) + if err != nil { + t.Fatal(err) + } + result = &api.Error{} + err = json.Unmarshal(body, result) + if err != nil { + t.Fatal(err) + } + return result, err +} + +func checkEmptyParameter(t *testing.T, result *api.Error, wantErrorDetail *api.ErrorDetail) { + if result.Status != http.StatusBadRequest { + t.Errorf("Wrong result.Status: got %v want %v", result.Status, http.StatusBadRequest) + } + if result.Type != shared.ErrorTypeValidationViolation { + t.Errorf("Wrong result.Type: got %v want %v", result.Type, shared.ErrorTypeValidationViolation) + } + if result.Message != shared.ErrorMessageMissingField { + t.Errorf("Wrong result.Message: got %v want %v", result.Message, shared.ErrorMessageMissingField) + } + if result.Details[0] != *wantErrorDetail { + t.Errorf("Wrong ErrorDetails: got %v want %v", result.Details[0], *wantErrorDetail) + } +} + +func checkWrongParameter(t *testing.T, result *api.Error, wantErrorDetail *api.ErrorDetail) { + if result.Status != http.StatusBadRequest { + t.Errorf("Wrong result.Status: got %v want %v", result.Status, http.StatusBadRequest) + } + if result.Type != shared.ErrorTypeValidationViolation { + t.Errorf("Wrong result.Type: got %v want %v", result.Type, shared.ErrorTypeValidationViolation) + } + if result.Message != shared.ErrorMessageInvalidField { + t.Errorf("Wrong result.Message: got %v want %v", result.Message, shared.ErrorMessageInvalidField) + } + if result.Details[0] != *wantErrorDetail { + t.Errorf("Wrong ErrorDetails: got %v want %v", result.Details[0], *wantErrorDetail) + } +} + +func checkWrongEventTime(t *testing.T, result *api.Error) { + apiErrorDetail := api.ErrorDetail{Field: shared.FieldEventTime, Type: shared.ErrorTypeInvalidField, Message: shared.ErrorMessageInvalidField, MoreInfo: ""} + if result.Status != http.StatusBadRequest { + t.Errorf("Wrong result.Status: got %v want %v", result.Status, http.StatusBadRequest) + } + if result.Type != shared.ErrorTypeValidationViolation { + t.Errorf("Wrong result.Type: got %v want %v", result.Type, shared.ErrorTypeValidationViolation) + } + if result.Message != shared.ErrorMessageInvalidField { + t.Errorf("Wrong result.Message: got %v want %v", result.Message, shared.ErrorMessageInvalidField) + } + if result.Details[0].Field != apiErrorDetail.Field { + t.Errorf("Wrong ErrorDetails: got %v want %v", result.Details[0].Field, apiErrorDetail.Field) + } + if result.Details[0].Type != apiErrorDetail.Type { + t.Errorf("Wrong ErrorDetails: got %v want %v", result.Details[0].Type, apiErrorDetail.Type) + } +} + +func checkEmptyRequest(t *testing.T, result *api.Error, wantError *api.Error) { + if result.Status != http.StatusBadRequest { + t.Errorf("Wrong result.Status: got %v want %v", result.Status, http.StatusBadRequest) + } + if result.Type != shared.ErrorTypeBadPayload { + t.Errorf("Wrong result.Type: got %v want %v", result.Type, shared.ErrorTypeBadPayload) + } + if result.Message != shared.ErrorMessageBadPayload { + t.Errorf("Wrong result.Message: got %v want %v", result.Message, shared.ErrorMessageBadPayload) + } + if len(result.Details) > 0 { + t.Errorf("Wrong ErrorDetails: got %v want %v", result.Details, nil) + } +} + +func checkBadRequest(t *testing.T, result *api.Error, wantError *api.Error) { + if result.Status != http.StatusBadRequest { + t.Errorf("Wrong result.Status: got %v want %v", result.Status, http.StatusBadRequest) + } + if result.Type != shared.ErrorTypeBadPayload { + t.Errorf("Wrong result.Type: got %v want %v", result.Type, shared.ErrorTypeBadPayload) + } + if result.Message != shared.ErrorMessageBadPayload { + t.Errorf("Wrong result.Message: got %v want %v", result.Message, shared.ErrorMessageBadPayload) + } + if len(result.Details) > 0 { + t.Errorf("Wrong ErrorDetails: got %v want %v", result.Details, nil) + } +} diff --git a/components/gateway/internal/externalapi/errorhandler.go b/components/gateway/internal/externalapi/errorhandler.go new file mode 100644 index 000000000000..f56a2e1bc484 --- /dev/null +++ b/components/gateway/internal/externalapi/errorhandler.go @@ -0,0 +1,26 @@ +package externalapi + +import ( + "encoding/json" + "net/http" + + "github.com/kyma-project/kyma/components/gateway/internal/httpconsts" + "github.com/kyma-project/kyma/components/gateway/internal/httperrors" +) + +type ErrorHandler struct { + Message string + Code int +} + +func NewErrorHandler(code int, message string) *ErrorHandler { + return &ErrorHandler{Message: message, Code: code} +} + +func (eh *ErrorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + responseBody := httperrors.ErrorResponse{Code: eh.Code, Error: eh.Message} + + w.Header().Set(httpconsts.HeaderContentType, httpconsts.ContentTypeApplicationJson) + w.WriteHeader(eh.Code) + json.NewEncoder(w).Encode(responseBody) +} diff --git a/components/gateway/internal/externalapi/errorhandler_test.go b/components/gateway/internal/externalapi/errorhandler_test.go new file mode 100644 index 000000000000..723d7ad9f5e2 --- /dev/null +++ b/components/gateway/internal/externalapi/errorhandler_test.go @@ -0,0 +1,43 @@ +package externalapi + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/kyma-project/kyma/components/gateway/internal/httperrors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestErrorHandler_ServeHTTP(t *testing.T) { + t.Run("Should always respond with given error and status code", func(t *testing.T) { + + r := mux.NewRouter() + + r.NotFoundHandler = NewErrorHandler(404, "Requested resource could not be found.") + ts := httptest.NewServer(r) + defer ts.Close() + + // when + res, err := http.Get(ts.URL + "/wrong/path") + + responseBody, err := ioutil.ReadAll(res.Body) + if err != nil { + assert.Fail(t, "Failure while reading response body.") + } + defer res.Body.Close() + + var errResponse httperrors.ErrorResponse + + json.Unmarshal(responseBody, &errResponse) + + // then + require.NoError(t, err) + assert.Equal(t, http.StatusNotFound, errResponse.Code) + assert.Equal(t, http.StatusNotFound, res.StatusCode) + }) +} diff --git a/components/gateway/internal/externalapi/eventshandler.go b/components/gateway/internal/externalapi/eventshandler.go new file mode 100644 index 000000000000..dbba1d9a8ab7 --- /dev/null +++ b/components/gateway/internal/externalapi/eventshandler.go @@ -0,0 +1,131 @@ +package externalapi + +import ( + "encoding/json" + "net/http" + "regexp" + "time" + + "github.com/kyma-project/kyma/components/gateway/internal/events/api" + "github.com/kyma-project/kyma/components/gateway/internal/events/bus" + "github.com/kyma-project/kyma/components/gateway/internal/events/shared" + log "github.com/sirupsen/logrus" +) + +var ( + isValidEventTypeVersion = regexp.MustCompile(shared.AllowedEventTypeVersionChars).MatchString + isValidEventId = regexp.MustCompile(shared.AllowedEventIdChars).MatchString + traceHeaderKeys = []string{"x-request-id", "x-b3-traceid", "x-b3-spanid", "x-b3-parentspanid", "x-b3-sampled", "x-b3-flags", "x-ot-span-context"} +) + +func NewEventsHandler() http.Handler { + return http.HandlerFunc(handleEvents) +} + +// EventsHandler handles "/v1/events" requests +func handleEvents(w http.ResponseWriter, req *http.Request) { + if req.Body == nil || req.ContentLength == 0 { + resp := shared.ErrorResponseBadRequest(shared.ErrorMessageBadPayload) + writeJsonResponse(w, resp) + return + } + var err error + parameters := &api.PublishEventParameters{} + decoder := json.NewDecoder(req.Body) + err = decoder.Decode(¶meters.Publishrequest) + if err != nil { + resp := shared.ErrorResponseBadRequest(err.Error()) + writeJsonResponse(w, resp) + return + } + resp := &api.PublishEventResponses{} + + traceHeaders := getTraceHeaders(req) + + err = handleEvent(parameters, resp, traceHeaders) + if err == nil { + if resp.Ok != nil || resp.Error != nil { + writeJsonResponse(w, resp) + return + } + log.Println("Cannot process event") + http.Error(w, "Cannot process event", http.StatusInternalServerError) + return + } + log.Printf("Internal Error: %s\n", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return +} + +var handleEvent = func(publishRequest *api.PublishEventParameters, publishResponse *api.PublishEventResponses, traceHeaders *map[string]string) (err error) { + checkResp := checkParameters(publishRequest) + if checkResp.Error != nil { + publishResponse.Error = checkResp.Error + return + } + // add source to the incoming request + sendRequest, err := bus.AddSource(publishRequest) + if err != nil { + return err + } + // send the event + sendEventResponse, err := bus.SendEvent(sendRequest, traceHeaders) + if err != nil { + return err + } + publishResponse.Ok = sendEventResponse.Ok + publishResponse.Error = sendEventResponse.Error + return err +} + +func checkParameters(parameters *api.PublishEventParameters) (response *api.PublishEventResponses) { + if parameters == nil { + return shared.ErrorResponseBadRequest(shared.ErrorMessageBadPayload) + } + if len(parameters.Publishrequest.EventType) == 0 { + return shared.ErrorResponseMissingFieldEventType() + } + if len(parameters.Publishrequest.EventTypeVersion) == 0 { + return shared.ErrorResponseMissingFieldEventTypeVersion() + } + if !isValidEventTypeVersion(parameters.Publishrequest.EventTypeVersion) { + return shared.ErrorResponseWrongEventTypeVersion() + } + if len(parameters.Publishrequest.EventTime) == 0 { + return shared.ErrorResponseMissingFieldEventTime() + } + if _, err := time.Parse(time.RFC3339, parameters.Publishrequest.EventTime); err != nil { + return shared.ErrorResponseWrongEventTime(err) + } + if len(parameters.Publishrequest.EventId) > 0 && !isValidEventId(parameters.Publishrequest.EventId) { + return shared.ErrorResponseWrongEventId() + } + if parameters.Publishrequest.Data == nil { + return shared.ErrorResponseMissingFieldData() + } else if d, ok := (parameters.Publishrequest.Data).(string); ok && d == "" { + return shared.ErrorResponseMissingFieldData() + } + // OK + return &api.PublishEventResponses{Ok: nil, Error: nil} +} + +func writeJsonResponse(w http.ResponseWriter, resp *api.PublishEventResponses) { + encoder := json.NewEncoder(w) + if resp.Error != nil { + w.WriteHeader(resp.Error.Status) + encoder.Encode(resp.Error) + } else { + encoder.Encode(resp.Ok) + } + return +} + +func getTraceHeaders(req *http.Request) *map[string]string { + traceHeaders := make(map[string]string) + for _, key := range traceHeaderKeys { + if value := req.Header.Get(key); len(value) > 0 { + traceHeaders[key] = value + } + } + return &traceHeaders +} diff --git a/components/gateway/internal/externalapi/eventshandler_test.go b/components/gateway/internal/externalapi/eventshandler_test.go new file mode 100644 index 000000000000..819350501ef4 --- /dev/null +++ b/components/gateway/internal/externalapi/eventshandler_test.go @@ -0,0 +1,107 @@ +package externalapi + +import ( + "bytes" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/kyma-project/kyma/components/gateway/internal/events/api" + "github.com/kyma-project/kyma/components/gateway/internal/events/bus" + "github.com/kyma-project/kyma/components/gateway/internal/events/shared" + "github.com/kyma-project/kyma/components/gateway/internal/httptools" +) + +func TestEventOk(t *testing.T) { + saved := handleEvent + defer func() { handleEvent = saved }() + + handleEvent = func(parameters *api.PublishEventParameters, response *api.PublishEventResponses, traceHeaders *map[string]string) (err error) { + ok := api.PublishResponse{EventId: "responseEventId"} + response.Ok = &ok + return + } + s := "{\"event-type\":\"order.created\",\"event-type-version\":\"v1\",\"event-id\":\"31109198-4d69-4ae0-972d-76117f3748c8\",\"event-time\":\"2012-11-01T22:08:41+00:00\"}" + req, err := http.NewRequest(http.MethodPost, shared.EventsPath, strings.NewReader(s)) + if err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + handler := NewEventsHandler() + handler.ServeHTTP(recorder, req) + if status := recorder.Code; status != http.StatusOK { + t.Errorf("Wrong status code: got %v want %v", status, http.StatusOK) + } +} + +// http client mock +type HttpClientMock struct{} + +func (c *HttpClientMock) Do(req *http.Request) (*http.Response, error) { + response := &http.Response{ + StatusCode: 200, + Body: ioutil.NopCloser(bytes.NewReader([]byte("{\"event-id\":\"cea54510-8631-47f0-934a-0571495c12d0\"}"))), + } + return response, nil +} + +func TestPropagateTraceHeaders(t *testing.T) { + // request to downstream services + var downstreamReq *http.Request + + // mock the http request provider + httpRequestProviderMock := func(method, url string, body io.Reader) (*http.Request, error) { + downstreamReq, _ = http.NewRequest(method, url, body) + return downstreamReq, nil + } + + // mock the http client provider + httpClientProviderMock := func() httptools.HttpClient { return new(HttpClientMock) } + + // init event sender with mocks + bus.InitEventSender(httpClientProviderMock, httpRequestProviderMock) + + // reset event sender default http client provider and http request provider + defer func() { bus.InitEventSender(httptools.DefaultHttpClientProvider, httptools.DefaultHttpRequestProvider) }() + + // init source config + sourceNamespace, sourceType, sourceEnvironment, targetUrl := "", "", "", "http://kyma-domain/v1/events" + bus.Init(&sourceNamespace, &sourceType, &sourceEnvironment, &targetUrl) + + // simulate request from outside of gateway + event := "{\"event-type\":\"order.created\",\"event-type-version\":\"v1\",\"event-id\":\"31109198-4d69-4ae0-972d-76117f3748c8\",\"event-time\":\"2012-11-01T22:08:41+00:00\",\"data\":\"{'key':'value'}\"}" + req, err := http.NewRequest(http.MethodPost, shared.EventsPath, strings.NewReader(event)) + + // simulate trace headers added by envoy sidecar + traceHeaderKey, traceHeaderVal := "x-b3-traceid", "0887296564d75cda" + req.Header.Add(traceHeaderKey, traceHeaderVal) + + // add none-trace headers + nonTraceHeaderKey, nonTraceHeaderVal := "key", "value" + req.Header.Add(nonTraceHeaderKey, nonTraceHeaderVal) + + if err != nil { + t.Fatal(err) + } + + if downstreamReq != nil { + t.Fatal("http request should have not be initialized at this point") + } + + recorder := httptest.NewRecorder() + handler := NewEventsHandler() + handler.ServeHTTP(recorder, req) + + // trace headers should be added to downstream request headers + if downstreamReq.Header.Get(traceHeaderKey) != traceHeaderVal { + t.Fatal("http request to events service is missing trace headers") + } + + // none-trace headers should not be added to downstream request headers + if downstreamReq.Header.Get(nonTraceHeaderKey) != "" { + t.Fatal("should not propagate non-trace headers") + } +} diff --git a/components/gateway/internal/externalapi/externalapi.go b/components/gateway/internal/externalapi/externalapi.go new file mode 100644 index 000000000000..ea3a2de3b6ad --- /dev/null +++ b/components/gateway/internal/externalapi/externalapi.go @@ -0,0 +1,20 @@ +package externalapi + +import ( + "net/http" + + "github.com/gorilla/mux" +) + +func NewHandler() http.Handler { + router := mux.NewRouter() + + router.PathPrefix("/{re}/v1/events").Handler(NewEventsHandler()).Methods(http.MethodPost) + + router.Path("/v1/health").Handler(NewHealthCheckHandler()).Methods(http.MethodGet) + + router.NotFoundHandler = NewErrorHandler(404, "Requested resource could not be found.") + router.MethodNotAllowedHandler = NewErrorHandler(405, "Method not allowed.") + + return router +} diff --git a/components/gateway/internal/externalapi/healthcheckhandler.go b/components/gateway/internal/externalapi/healthcheckhandler.go new file mode 100644 index 000000000000..3a7ea2cc324f --- /dev/null +++ b/components/gateway/internal/externalapi/healthcheckhandler.go @@ -0,0 +1,12 @@ +package externalapi + +import ( + "net/http" +) + +// NewHealthCheckHandler creates handler for performing health check +func NewHealthCheckHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) +} diff --git a/components/gateway/internal/externalapi/healthcheckhandler_test.go b/components/gateway/internal/externalapi/healthcheckhandler_test.go new file mode 100644 index 000000000000..80d250f72350 --- /dev/null +++ b/components/gateway/internal/externalapi/healthcheckhandler_test.go @@ -0,0 +1,27 @@ +package externalapi + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHealthCheckHandler_HandleRequest(t *testing.T) { + t.Run("should always respond with 200 status code", func(t *testing.T) { + // given + req, err := http.NewRequest(http.MethodGet, "/v1/health", nil) + require.NoError(t, err) + rr := httptest.NewRecorder() + + handler := NewHealthCheckHandler() + + // when + handler.ServeHTTP(rr, req) + + // then + assert.Equal(t, http.StatusOK, rr.Code) + }) +} diff --git a/components/gateway/internal/httpconsts/httpconsts.go b/components/gateway/internal/httpconsts/httpconsts.go new file mode 100644 index 000000000000..e1645efbec24 --- /dev/null +++ b/components/gateway/internal/httpconsts/httpconsts.go @@ -0,0 +1,14 @@ +package httpconsts + +const ( + HeaderXForwardedFor = "X-Forwarded-For" + HeaderConnection = "Connection" + HeaderContentType = "Content-Type" + HeaderAuthorization = "Authorization" + HeaderAccessToken = "Access-Token" +) + +const ( + ContentTypeApplicationJson = "application/json;charset=UTF-8" + ContentTypeApplicationURLEncoded = "application/x-www-form-urlencoded" +) diff --git a/components/gateway/internal/httperrors/httperrors.go b/components/gateway/internal/httperrors/httperrors.go new file mode 100644 index 000000000000..7ac82449169b --- /dev/null +++ b/components/gateway/internal/httperrors/httperrors.go @@ -0,0 +1,34 @@ +package httperrors + +import ( + "net/http" + + "github.com/kyma-project/kyma/components/gateway/internal/apperrors" +) + +type ErrorResponse struct { + Code int `json:"code"` + Error string `json:"error"` +} + +func errorCodeToHttpStatus(code int) int { + switch code { + case apperrors.CodeInternal: + return http.StatusInternalServerError + case apperrors.CodeNotFound: + return http.StatusNotFound + case apperrors.CodeAlreadyExists: + return http.StatusConflict + case apperrors.CodeWrongInput: + return http.StatusBadRequest + case apperrors.CodeUpstreamServerCallFailed: + return http.StatusBadGateway + default: + return http.StatusInternalServerError + } +} + +func AppErrorToResponse(appError apperrors.AppError) (status int, body ErrorResponse) { + httpCode := errorCodeToHttpStatus(appError.Code()) + return httpCode, ErrorResponse{httpCode, appError.Error()} +} diff --git a/components/gateway/internal/httptools/http.go b/components/gateway/internal/httptools/http.go new file mode 100644 index 000000000000..ec9407bd7c94 --- /dev/null +++ b/components/gateway/internal/httptools/http.go @@ -0,0 +1,21 @@ +package httptools + +import ( + "io" + "net/http" +) + +type HttpClientProvider func() HttpClient +type HttpRequestProvider func(method, url string, body io.Reader) (*http.Request, error) + +type HttpClient interface { + Do(req *http.Request) (*http.Response, error) +} + +func DefaultHttpClientProvider() HttpClient { + return http.DefaultClient +} + +func DefaultHttpRequestProvider(method, url string, body io.Reader) (*http.Request, error) { + return http.NewRequest(method, url, body) +} diff --git a/components/gateway/internal/httptools/logging.go b/components/gateway/internal/httptools/logging.go new file mode 100644 index 000000000000..f0297a423c45 --- /dev/null +++ b/components/gateway/internal/httptools/logging.go @@ -0,0 +1,42 @@ +package httptools + +import ( + "net/http" + "time" + + log "github.com/sirupsen/logrus" +) + +func RequestLogger(label string, h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + lw := newLoggingResponseWriter(w) + + h.ServeHTTP(lw, r) + + method := r.Method + fullPath := r.RequestURI + if fullPath == "" { + fullPath = r.URL.RequestURI() + } + proto := r.Proto + responseCode := lw.status + duration := time.Since(lw.start).Nanoseconds() / int64(time.Millisecond) + + log.Infof("%s: %s %s %s %d %d", label, method, fullPath, proto, responseCode, duration) + }) +} + +type loggingResponseWriter struct { + http.ResponseWriter + status int + start time.Time +} + +func newLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter { + return &loggingResponseWriter{ResponseWriter: w, start: time.Now()} +} + +func (w *loggingResponseWriter) WriteHeader(statusCode int) { + w.ResponseWriter.WriteHeader(statusCode) + w.status = statusCode +} diff --git a/components/gateway/internal/k8sconsts/k8sconsts.go b/components/gateway/internal/k8sconsts/k8sconsts.go new file mode 100644 index 000000000000..931cc291ab48 --- /dev/null +++ b/components/gateway/internal/k8sconsts/k8sconsts.go @@ -0,0 +1,7 @@ +package k8sconsts + +const ( + LabelRemoteEnvironment = "re" + LabelServiceId = "serviceId" + LabelApp = "app" +) diff --git a/components/gateway/internal/k8sconsts/mocks/NameResolver.go b/components/gateway/internal/k8sconsts/mocks/NameResolver.go new file mode 100644 index 000000000000..48161f2c3efa --- /dev/null +++ b/components/gateway/internal/k8sconsts/mocks/NameResolver.go @@ -0,0 +1,51 @@ +// Code generated by mockery v1.0.0 +package mocks + +import mock "github.com/stretchr/testify/mock" + +// NameResolver is an autogenerated mock type for the NameResolver type +type NameResolver struct { + mock.Mock +} + +// ExtractServiceId provides a mock function with given fields: host +func (_m *NameResolver) ExtractServiceId(host string) string { + ret := _m.Called(host) + + var r0 string + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(host) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// GetGatewayUrl provides a mock function with given fields: id +func (_m *NameResolver) GetGatewayUrl(id string) string { + ret := _m.Called(id) + + var r0 string + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(id) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// GetResourceName provides a mock function with given fields: id +func (_m *NameResolver) GetResourceName(id string) string { + ret := _m.Called(id) + + var r0 string + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(id) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} diff --git a/components/gateway/internal/k8sconsts/nameresolver.go b/components/gateway/internal/k8sconsts/nameresolver.go new file mode 100644 index 000000000000..c8d45913f625 --- /dev/null +++ b/components/gateway/internal/k8sconsts/nameresolver.go @@ -0,0 +1,72 @@ +package k8sconsts + +import ( + "fmt" + "strings" +) + +const ( + resourceNamePrefixFormat = "re-%s-" + gatewayUrlFormat = "http://%s.%s.svc.cluster.local" + + maxResourceNameLength = 63 // Kubernetes limit for services + uuidLength = 36 // UUID has 36 characters +) + +// NameResolver provides names for Kubernetes resources +type NameResolver interface { + // GetResourceName returns resource name with given ID + GetResourceName(id string) string + // GetGatewayUrl return gateway url with given ID + GetGatewayUrl(id string) string + // ExtractServiceId extracts service ID from given host + ExtractServiceId(host string) string +} + +type nameResolver struct { + resourceNamePrefix string + namespace string +} + +// NewNameResolver creates NameResolver that uses remote environment name and namespace. +func NewNameResolver(remoteEnvironment, namespace string) NameResolver { + return nameResolver{ + resourceNamePrefix: getResourceNamePrefix(remoteEnvironment), + namespace: namespace, + } +} + +// GetResourceName returns resource name with given ID +func (resolver nameResolver) GetResourceName(id string) string { + return resolver.resourceNamePrefix + id +} + +// GetGatewayUrl return gateway url with given ID +func (resolver nameResolver) GetGatewayUrl(id string) string { + return fmt.Sprintf(gatewayUrlFormat, resolver.GetResourceName(id), resolver.namespace) +} + +// ExtractServiceId extracts service ID from given host +func (resolver nameResolver) ExtractServiceId(host string) string { + resourceName := strings.Split(host, ".")[0] + return strings.TrimPrefix(resourceName, resolver.resourceNamePrefix) +} + +func getResourceNamePrefix(remoteEnvironment string) string { + truncatedRemoteEnvironment := truncateRemoteEnvironment(remoteEnvironment) + return fmt.Sprintf(resourceNamePrefixFormat, truncatedRemoteEnvironment) +} + +func truncateRemoteEnvironment(remoteEnvironment string) string { + maxResourceNamePrefixLength := maxResourceNameLength - uuidLength + testResourceNamePrefix := fmt.Sprintf(resourceNamePrefixFormat, remoteEnvironment) + testResourceNamePrefixLength := len(testResourceNamePrefix) + + overflowLength := testResourceNamePrefixLength - maxResourceNamePrefixLength + + if overflowLength > 0 { + newRemoteEnvironmentLength := len(remoteEnvironment) - overflowLength + return remoteEnvironment[0:newRemoteEnvironmentLength] + } + return remoteEnvironment +} diff --git a/components/gateway/internal/k8sconsts/nameresolver_test.go b/components/gateway/internal/k8sconsts/nameresolver_test.go new file mode 100644 index 000000000000..48720ddac210 --- /dev/null +++ b/components/gateway/internal/k8sconsts/nameresolver_test.go @@ -0,0 +1,69 @@ +package k8sconsts + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNameResolver(t *testing.T) { + testCases := []struct { + remotEnv string + id string + resourceName string + gatewayUrl string + host string + }{ + { + remotEnv: "short_remoteenv", + id: "c687e68a-9038-4f38-845b-9c61592e59e6", + resourceName: "re-short_remoteenv-c687e68a-9038-4f38-845b-9c61592e59e6", + gatewayUrl: "http://re-short_remoteenv-c687e68a-9038-4f38-845b-9c61592e59e6.namespace.svc.cluster.local", + host: "re-short_remoteenv-c687e68a-9038-4f38-845b-9c61592e59e6.namespace.svc.cluster.local", + }, + { + remotEnv: "max_remoteenv_aaaaaaaaa", + id: "c687e68a-9038-4f38-845b-9c61592e59e6", + resourceName: "re-max_remoteenv_aaaaaaaaa-c687e68a-9038-4f38-845b-9c61592e59e6", + gatewayUrl: "http://re-max_remoteenv_aaaaaaaaa-c687e68a-9038-4f38-845b-9c61592e59e6.namespace.svc.cluster.local", + host: "re-max_remoteenv_aaaaaaaaa-c687e68a-9038-4f38-845b-9c61592e59e6.namespace.svc.cluster.local", + }, + { + remotEnv: "toolong_remoteenv_aaaaaxxxx", + id: "c687e68a-9038-4f38-845b-9c61592e59e6", + resourceName: "re-toolong_remoteenv_aaaaa-c687e68a-9038-4f38-845b-9c61592e59e6", + gatewayUrl: "http://re-toolong_remoteenv_aaaaa-c687e68a-9038-4f38-845b-9c61592e59e6.namespace.svc.cluster.local", + host: "re-toolong_remoteenv_aaaaa-c687e68a-9038-4f38-845b-9c61592e59e6.namespace.svc.cluster.local", + }, + } + + t.Run("should get resource name with truncated remote environment name if needed", func(t *testing.T) { + for _, testCase := range testCases { + resolver := NewNameResolver(testCase.remotEnv, "namespace") + + result := resolver.GetResourceName(testCase.id) + + assert.Equal(t, testCase.resourceName, result) + } + }) + + t.Run("should get gateway url with truncated remote environment name if needed", func(t *testing.T) { + for _, testCase := range testCases { + resolver := NewNameResolver(testCase.remotEnv, "namespace") + + result := resolver.GetGatewayUrl(testCase.id) + + assert.Equal(t, testCase.gatewayUrl, result) + } + }) + + t.Run("should extract service ID from gateway host", func(t *testing.T) { + for _, testCase := range testCases { + resolver := NewNameResolver(testCase.remotEnv, "namespace") + + result := resolver.ExtractServiceId(testCase.host) + + assert.Equal(t, testCase.id, result) + } + }) +} diff --git a/components/gateway/internal/metadata/mocks/ServiceDefinitionService.go b/components/gateway/internal/metadata/mocks/ServiceDefinitionService.go new file mode 100644 index 000000000000..f751ccfc5c42 --- /dev/null +++ b/components/gateway/internal/metadata/mocks/ServiceDefinitionService.go @@ -0,0 +1,37 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. +package mocks + +import apperrors "github.com/kyma-project/kyma/components/gateway/internal/apperrors" + +import mock "github.com/stretchr/testify/mock" +import serviceapi "github.com/kyma-project/kyma/components/gateway/internal/metadata/serviceapi" + +// ServiceDefinitionService is an autogenerated mock type for the ServiceDefinitionService type +type ServiceDefinitionService struct { + mock.Mock +} + +// GetAPI provides a mock function with given fields: serviceId +func (_m *ServiceDefinitionService) GetAPI(serviceId string) (*serviceapi.API, apperrors.AppError) { + ret := _m.Called(serviceId) + + var r0 *serviceapi.API + if rf, ok := ret.Get(0).(func(string) *serviceapi.API); ok { + r0 = rf(serviceId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*serviceapi.API) + } + } + + var r1 apperrors.AppError + if rf, ok := ret.Get(1).(func(string) apperrors.AppError); ok { + r1 = rf(serviceId) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(apperrors.AppError) + } + } + + return r0, r1 +} diff --git a/components/gateway/internal/metadata/model.go b/components/gateway/internal/metadata/model.go new file mode 100644 index 000000000000..a5daae3e5746 --- /dev/null +++ b/components/gateway/internal/metadata/model.go @@ -0,0 +1,27 @@ +package metadata + +import "github.com/kyma-project/kyma/components/gateway/internal/metadata/serviceapi" + +// ServiceDefinition is an internal representation of a service. +type ServiceDefinition struct { + // ID of service + ID string + // Name of a service + Name string + // Provider of a service + Provider string + // Description of a service + Description string + // Api of a service + Api *serviceapi.API + // Events of a service + Events *Events + // Documentation of service + Documentation []byte +} + +// Events contains specification for events. +type Events struct { + // Spec contains data of events specification. + Spec []byte +} diff --git a/components/gateway/internal/metadata/remoteenv/mocks/Manager.go b/components/gateway/internal/metadata/remoteenv/mocks/Manager.go new file mode 100644 index 000000000000..cd809e008cc5 --- /dev/null +++ b/components/gateway/internal/metadata/remoteenv/mocks/Manager.go @@ -0,0 +1,35 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. +package mocks + +import mock "github.com/stretchr/testify/mock" + +import v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +import v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + +// Manager is an autogenerated mock type for the Manager type +type Manager struct { + mock.Mock +} + +// Get provides a mock function with given fields: name, options +func (_m *Manager) Get(name string, options v1.GetOptions) (*v1alpha1.RemoteEnvironment, error) { + ret := _m.Called(name, options) + + var r0 *v1alpha1.RemoteEnvironment + if rf, ok := ret.Get(0).(func(string, v1.GetOptions) *v1alpha1.RemoteEnvironment); ok { + r0 = rf(name, options) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1alpha1.RemoteEnvironment) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, v1.GetOptions) error); ok { + r1 = rf(name, options) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/gateway/internal/metadata/remoteenv/mocks/ServiceRepository.go b/components/gateway/internal/metadata/remoteenv/mocks/ServiceRepository.go new file mode 100644 index 000000000000..8f5dcf5bfb88 --- /dev/null +++ b/components/gateway/internal/metadata/remoteenv/mocks/ServiceRepository.go @@ -0,0 +1,34 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. +package mocks + +import apperrors "github.com/kyma-project/kyma/components/gateway/internal/apperrors" +import mock "github.com/stretchr/testify/mock" +import remoteenv "github.com/kyma-project/kyma/components/gateway/internal/metadata/remoteenv" + +// ServiceRepository is an autogenerated mock type for the ServiceRepository type +type ServiceRepository struct { + mock.Mock +} + +// Get provides a mock function with given fields: id +func (_m *ServiceRepository) Get(id string) (remoteenv.Service, apperrors.AppError) { + ret := _m.Called(id) + + var r0 remoteenv.Service + if rf, ok := ret.Get(0).(func(string) remoteenv.Service); ok { + r0 = rf(id) + } else { + r0 = ret.Get(0).(remoteenv.Service) + } + + var r1 apperrors.AppError + if rf, ok := ret.Get(1).(func(string) apperrors.AppError); ok { + r1 = rf(id) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(apperrors.AppError) + } + } + + return r0, r1 +} diff --git a/components/gateway/internal/metadata/remoteenv/repository.go b/components/gateway/internal/metadata/remoteenv/repository.go new file mode 100644 index 000000000000..b70e05ae8ec1 --- /dev/null +++ b/components/gateway/internal/metadata/remoteenv/repository.go @@ -0,0 +1,100 @@ +// Package remoteenv contains components for accessing/modifying Remote Environment CRD +package remoteenv + +import ( + "fmt" + + "github.com/kyma-project/kyma/components/gateway/internal/apperrors" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + log "github.com/sirupsen/logrus" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + specAPIType = "API" + specEventsType = "Events" +) + +// Manager contains operations for managing Remote Environment CRD +type Manager interface { + Get(name string, options v1.GetOptions) (*v1alpha1.RemoteEnvironment, error) +} + +type repository struct { + name string + reManager Manager +} + +// ServiceAPI stores information needed to call an API +type ServiceAPI struct { + GatewayURL string + AccessLabel string + TargetUrl string + OauthUrl string + CredentialsSecretName string +} + +// Service represents a service stored in Remote Environment RE +type Service struct { + // Mapped to id in Remote Environment CRD + ID string + // Mapped to displayName in Remote Environment CRD + DisplayName string + // Mapped to longDescription in Remote Environment CRD + LongDescription string + // Mapped to providerDisplayName in Remote Environment CRD + ProviderDisplayName string + // Mapped to tags in Remote Environment CRD + Tags []string + // Mapped to type property under entries element (type: API) + API *ServiceAPI + // Mapped to type property under entries element (type: Events) + Events bool +} + +// ServiceRepository contains operations for managing services stored in Remote Environment CRD +type ServiceRepository interface { + Get(id string) (Service, apperrors.AppError) +} + +// NewServiceRepository creates a new RemoteEnvironmentServiceRepository +func NewServiceRepository(name string, reManager Manager) ServiceRepository { + return &repository{name: name, reManager: reManager} +} + +// Get reads Service from Remote Environment by service id +func (r *repository) Get(id string) (Service, apperrors.AppError) { + re, err := r.getRemoteEnvironment() + if err != nil { + return Service{}, err + } + + for _, service := range re.Spec.Services { + if service.ID == id { + return convertFromK8sType(service) + } + } + + message := fmt.Sprintf("Service with ID %s not found", id) + log.Warn(message) + + return Service{}, apperrors.NotFound(message) +} + +func (r *repository) getRemoteEnvironment() (*v1alpha1.RemoteEnvironment, apperrors.AppError) { + re, err := r.reManager.Get(r.name, v1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + message := fmt.Sprintf("Remote environment: %s not found.", r.name) + log.Warn(message) + return nil, apperrors.Internal(message) + } + + message := fmt.Sprintf("failed to get remote environment '%s' : %s", r.name, err.Error()) + log.Error(message) + return nil, apperrors.Internal(message) + } + + return re, nil +} diff --git a/components/gateway/internal/metadata/remoteenv/repository_test.go b/components/gateway/internal/metadata/remoteenv/repository_test.go new file mode 100644 index 000000000000..b6da7c9d261a --- /dev/null +++ b/components/gateway/internal/metadata/remoteenv/repository_test.go @@ -0,0 +1,120 @@ +package remoteenv_test + +import ( + "testing" + + "github.com/kyma-project/kyma/components/gateway/internal/apperrors" + "github.com/kyma-project/kyma/components/gateway/internal/metadata/remoteenv" + "github.com/kyma-project/kyma/components/gateway/internal/metadata/remoteenv/mocks" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestGetServices(t *testing.T) { + + t.Run("should get service by id", func(t *testing.T) { + // given + remoteEnvironment := createRemoteEnvironment("production") + reManagerMock := &mocks.Manager{} + reManagerMock.On("Get", "production", metav1.GetOptions{}). + Return(remoteEnvironment, nil) + + repository := remoteenv.NewServiceRepository("production", reManagerMock) + require.NotNil(t, repository) + + // when + service, err := repository.Get("id1") + + // then + require.NotNil(t, service) + require.NoError(t, err) + + assert.Equal(t, service.ProviderDisplayName, "SAP Hybris") + assert.Equal(t, service.DisplayName, "Orders API") + assert.Equal(t, service.LongDescription, "This is Orders API") + assert.Equal(t, service.API, &remoteenv.ServiceAPI{ + GatewayURL: "https://orders-gateway.production.svc.cluster.local/", + AccessLabel: "access-label-1", + TargetUrl: "https://192.168.1.2", + OauthUrl: "https://192.168.1.3/token", + CredentialsSecretName: "re-ac031e8c-9aa4-4cb7-8999-0d358726ffaa", + }) + }) + + t.Run("should return not found error if service doesn't exist", func(t *testing.T) { + // given + remoteEnvironment := createRemoteEnvironment("production") + reManagerMock := &mocks.Manager{} + reManagerMock.On("Get", "production", metav1.GetOptions{}). + Return(remoteEnvironment, nil) + + repository := remoteenv.NewServiceRepository("production", reManagerMock) + require.NotNil(t, repository) + + // when + service, err := repository.Get("not-existent") + + // then + assert.Equal(t, remoteenv.Service{}, service) + assert.Equal(t, apperrors.CodeNotFound, err.Code()) + }) +} + +func createRemoteEnvironment(name string) *v1alpha1.RemoteEnvironment { + + reService1Entry := v1alpha1.Entry{ + Type: "API", + GatewayUrl: "https://orders-gateway.production.svc.cluster.local/", + AccessLabel: "access-label-1", + TargetUrl: "https://192.168.1.2", + OauthUrl: "https://192.168.1.3/token", + CredentialsSecretName: "re-ac031e8c-9aa4-4cb7-8999-0d358726ffaa", + } + reService1 := v1alpha1.Service{ + ID: "id1", + DisplayName: "Orders API", + LongDescription: "This is Orders API", + ProviderDisplayName: "SAP Hybris", + Tags: []string{"orders"}, + Entries: []v1alpha1.Entry{reService1Entry}, + } + + reService2Entry := v1alpha1.Entry{ + Type: "API", + GatewayUrl: "https://products-gateway.production.svc.cluster.local/", + AccessLabel: "access-label-2", + TargetUrl: "https://192.168.1.3", + OauthUrl: "https://192.168.1.4/token", + CredentialsSecretName: "re-bc031e8c-9aa4-4cb7-8999-0d358726ffab", + } + + reService2 := v1alpha1.Service{ + ID: "id2", + DisplayName: "Products API", + LongDescription: "This is Products API", + ProviderDisplayName: "SAP Hybris", + Tags: []string{"products"}, + Entries: []v1alpha1.Entry{reService2Entry}, + } + + reSource1 := v1alpha1.Source{ + Environment: "production", + Type: "commerce", + Namespace: "local.kyma.commerce"} + + reSpec1 := v1alpha1.RemoteEnvironmentSpec{ + Description: "test_1", + Source: reSource1, + Services: []v1alpha1.Service{ + reService1, + reService2, + }, + } + + return &v1alpha1.RemoteEnvironment{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: reSpec1, + } +} diff --git a/components/gateway/internal/metadata/remoteenv/util.go b/components/gateway/internal/metadata/remoteenv/util.go new file mode 100644 index 000000000000..5321f2dd01fe --- /dev/null +++ b/components/gateway/internal/metadata/remoteenv/util.go @@ -0,0 +1,43 @@ +package remoteenv + +import ( + "fmt" + + "github.com/kyma-project/kyma/components/gateway/internal/apperrors" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + log "github.com/sirupsen/logrus" +) + +func convertFromK8sType(service v1alpha1.Service) (Service, apperrors.AppError) { + var api *ServiceAPI + var events bool + { + for _, entry := range service.Entries { + if entry.Type == specAPIType { + api = &ServiceAPI{ + GatewayURL: entry.GatewayUrl, + AccessLabel: entry.AccessLabel, + TargetUrl: entry.TargetUrl, + OauthUrl: entry.OauthUrl, + CredentialsSecretName: entry.CredentialsSecretName, + } + } else if entry.Type == specEventsType { + events = true + } else { + message := fmt.Sprintf("incorrect type of entry '%s' in Remote Environment Service definition", entry.Type) + log.Error(message) + return Service{}, apperrors.Internal(message) + } + } + } + + return Service{ + ID: service.ID, + DisplayName: service.DisplayName, + LongDescription: service.LongDescription, + ProviderDisplayName: service.ProviderDisplayName, + Tags: service.Tags, + API: api, + Events: events, + }, nil +} diff --git a/components/gateway/internal/metadata/secrets/mocks/Manager.go b/components/gateway/internal/metadata/secrets/mocks/Manager.go new file mode 100644 index 000000000000..0a3fc3cd6eca --- /dev/null +++ b/components/gateway/internal/metadata/secrets/mocks/Manager.go @@ -0,0 +1,35 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. +package mocks + +import corev1 "k8s.io/api/core/v1" +import mock "github.com/stretchr/testify/mock" + +import v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// Manager is an autogenerated mock type for the Manager type +type Manager struct { + mock.Mock +} + +// Get provides a mock function with given fields: name, options +func (_m *Manager) Get(name string, options v1.GetOptions) (*corev1.Secret, error) { + ret := _m.Called(name, options) + + var r0 *corev1.Secret + if rf, ok := ret.Get(0).(func(string, v1.GetOptions) *corev1.Secret); ok { + r0 = rf(name, options) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*corev1.Secret) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, v1.GetOptions) error); ok { + r1 = rf(name, options) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/gateway/internal/metadata/secrets/mocks/Repository.go b/components/gateway/internal/metadata/secrets/mocks/Repository.go new file mode 100644 index 000000000000..f17a2179ccab --- /dev/null +++ b/components/gateway/internal/metadata/secrets/mocks/Repository.go @@ -0,0 +1,40 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. +package mocks + +import apperrors "github.com/kyma-project/kyma/components/gateway/internal/apperrors" +import mock "github.com/stretchr/testify/mock" + +// Repository is an autogenerated mock type for the Repository type +type Repository struct { + mock.Mock +} + +// Get provides a mock function with given fields: name +func (_m *Repository) Get(name string) (string, string, apperrors.AppError) { + ret := _m.Called(name) + + var r0 string + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(name) + } else { + r0 = ret.Get(0).(string) + } + + var r1 string + if rf, ok := ret.Get(1).(func(string) string); ok { + r1 = rf(name) + } else { + r1 = ret.Get(1).(string) + } + + var r2 apperrors.AppError + if rf, ok := ret.Get(2).(func(string) apperrors.AppError); ok { + r2 = rf(name) + } else { + if ret.Get(2) != nil { + r2 = ret.Get(2).(apperrors.AppError) + } + } + + return r0, r1, r2 +} diff --git a/components/gateway/internal/metadata/secrets/repository.go b/components/gateway/internal/metadata/secrets/repository.go new file mode 100644 index 000000000000..153bda89cab6 --- /dev/null +++ b/components/gateway/internal/metadata/secrets/repository.go @@ -0,0 +1,49 @@ +// Package secrets contains components for accessing/modifying client secrets +package secrets + +import ( + "github.com/kyma-project/kyma/components/gateway/internal/apperrors" + "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + ClientIDKey = "clientId" + ClientSecretKey = "clientSecret" +) + +// Repository contains operations for managing client credentials +type Repository interface { + Get(name string) (string, string, apperrors.AppError) +} + +type repository struct { + secretsManager Manager + remoteEnvironment string +} + +// Manager contains operations for managing k8s secrets +type Manager interface { + Get(name string, options metav1.GetOptions) (*v1.Secret, error) +} + +// NewRepository creates a new secrets repository +func NewRepository(secretsManager Manager, remoteEnvironment string) Repository { + return &repository{ + secretsManager: secretsManager, + remoteEnvironment: remoteEnvironment, + } +} + +func (r *repository) Get(name string) (clientId string, clientSecret string, error apperrors.AppError) { + secret, err := r.secretsManager.Get(name, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return "", "", apperrors.NotFound("secret %s not found", name) + } + return "", "", apperrors.Internal("failed to get %s secret, %s", name, err) + } + + return string(secret.Data[ClientIDKey]), string(secret.Data[ClientSecretKey]), nil +} diff --git a/components/gateway/internal/metadata/secrets/repository_test.go b/components/gateway/internal/metadata/secrets/repository_test.go new file mode 100644 index 000000000000..bb12da763414 --- /dev/null +++ b/components/gateway/internal/metadata/secrets/repository_test.go @@ -0,0 +1,100 @@ +package secrets + +import ( + "errors" + "testing" + + "github.com/kyma-project/kyma/components/gateway/internal/apperrors" + "github.com/kyma-project/kyma/components/gateway/internal/k8sconsts" + "github.com/kyma-project/kyma/components/gateway/internal/metadata/secrets/mocks" + "github.com/stretchr/testify/assert" + "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestRepository_Get(t *testing.T) { + t.Run("should get given secret", func(t *testing.T) { + // given + secretsManagerMock := &mocks.Manager{} + repository := NewRepository(secretsManagerMock, "default-ec") + + secret := makeSecret("new-secret", "CLIENT_ID", "CLIENT_SECRET", "secretId", "default-ec") + secretsManagerMock.On("Get", "new-secret", metav1.GetOptions{}).Return(secret, nil) + + // when + clientId, clientSecret, err := repository.Get("new-secret") + + // then + assert.NoError(t, err) + assert.Equal(t, "CLIENT_ID", clientId) + assert.Equal(t, "CLIENT_SECRET", clientSecret) + + secretsManagerMock.AssertExpectations(t) + }) + + t.Run("should return an error in case fetching fails", func(t *testing.T) { + // given + secretsManagerMock := &mocks.Manager{} + repository := NewRepository(secretsManagerMock, "default-ec") + + secretsManagerMock.On("Get", "secret-name", metav1.GetOptions{}).Return( + nil, + errors.New("some error")) + + // when + clientId, clientSecret, err := repository.Get("secret-name") + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.NotEmpty(t, err.Error()) + + assert.Equal(t, "", clientId) + assert.Equal(t, "", clientSecret) + + secretsManagerMock.AssertExpectations(t) + }) + + t.Run("should return not found if secret does not exist", func(t *testing.T) { + // given + secretsManagerMock := &mocks.Manager{} + repository := NewRepository(secretsManagerMock, "default-ec") + + secretsManagerMock.On("Get", "secret-name", metav1.GetOptions{}).Return( + nil, + k8serrors.NewNotFound(schema.GroupResource{}, + "")) + + // when + clientId, clientSecret, err := repository.Get("secret-name") + + // then + assert.Error(t, err) + assert.Equal(t, apperrors.CodeNotFound, err.Code()) + assert.NotEmpty(t, err.Error()) + + assert.Equal(t, "", clientId) + assert.Equal(t, "", clientSecret) + + secretsManagerMock.AssertExpectations(t) + }) +} + +func makeSecret(name, clientID, clientSecret, serviceID, remoteEnvironment string) *v1.Secret { + secretMap := make(map[string][]byte) + secretMap[ClientIDKey] = []byte(clientID) + secretMap[ClientSecretKey] = []byte(clientSecret) + + return &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + k8sconsts.LabelRemoteEnvironment: remoteEnvironment, + k8sconsts.LabelServiceId: serviceID, + }, + }, + Data: secretMap, + } +} diff --git a/components/gateway/internal/metadata/serviceapi/mocks/Service.go b/components/gateway/internal/metadata/serviceapi/mocks/Service.go new file mode 100644 index 000000000000..87fcd74cef54 --- /dev/null +++ b/components/gateway/internal/metadata/serviceapi/mocks/Service.go @@ -0,0 +1,37 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. +package mocks + +import apperrors "github.com/kyma-project/kyma/components/gateway/internal/apperrors" +import mock "github.com/stretchr/testify/mock" +import remoteenv "github.com/kyma-project/kyma/components/gateway/internal/metadata/remoteenv" +import serviceapi "github.com/kyma-project/kyma/components/gateway/internal/metadata/serviceapi" + +// Service is an autogenerated mock type for the Service type +type Service struct { + mock.Mock +} + +// Read provides a mock function with given fields: _a0 +func (_m *Service) Read(_a0 *remoteenv.ServiceAPI) (*serviceapi.API, apperrors.AppError) { + ret := _m.Called(_a0) + + var r0 *serviceapi.API + if rf, ok := ret.Get(0).(func(*remoteenv.ServiceAPI) *serviceapi.API); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*serviceapi.API) + } + } + + var r1 apperrors.AppError + if rf, ok := ret.Get(1).(func(*remoteenv.ServiceAPI) apperrors.AppError); ok { + r1 = rf(_a0) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(apperrors.AppError) + } + } + + return r0, r1 +} diff --git a/components/gateway/internal/metadata/serviceapi/serviceapiservice.go b/components/gateway/internal/metadata/serviceapi/serviceapiservice.go new file mode 100644 index 000000000000..ae96b5a076a8 --- /dev/null +++ b/components/gateway/internal/metadata/serviceapi/serviceapiservice.go @@ -0,0 +1,74 @@ +package serviceapi + +import ( + "github.com/kyma-project/kyma/components/gateway/internal/apperrors" + "github.com/kyma-project/kyma/components/gateway/internal/metadata/remoteenv" + "github.com/kyma-project/kyma/components/gateway/internal/metadata/secrets" +) + +// API is an internal representation of a service's API. +type API struct { + // TargetUrl points to API. + TargetUrl string + // Credentials is a credentials of API. + Credentials *Credentials + // Spec contains specification of an API. + Spec []byte +} + +// Credentials contains OAuth configuration. +type Credentials struct { + // Oauth is OAuth configuration. + Oauth Oauth +} + +// Oauth contains details of OAuth configuration +type Oauth struct { + // URL to OAuth token provider. + URL string + // ClientID to use for authentication. + ClientID string + // ClientSecret to use for authentication. + ClientSecret string +} + +// Service manages API definition of a service +type Service interface { + // Read reads API from Remote Environment API definition. It also reads all additional information. + Read(*remoteenv.ServiceAPI) (*API, apperrors.AppError) +} + +type defaultService struct { + secretsRepository secrets.Repository +} + +func NewService(secretsRepository secrets.Repository) Service { + + return defaultService{ + secretsRepository: secretsRepository, + } +} + +func (sas defaultService) Read(remoteenvAPI *remoteenv.ServiceAPI) (*API, apperrors.AppError) { + api := &API{ + TargetUrl: remoteenvAPI.TargetUrl, + } + + if remoteenvAPI.OauthUrl != "" && remoteenvAPI.CredentialsSecretName != "" { + api.Credentials = &Credentials{ + Oauth: Oauth{ + URL: remoteenvAPI.OauthUrl, + }, + } + + clientId, clientSecret, err := sas.secretsRepository.Get(remoteenvAPI.CredentialsSecretName) + if err != nil { + return nil, apperrors.Internal("failed to read oauth credentials from %s secret, %s", + remoteenvAPI.CredentialsSecretName, err.Error()) + } + api.Credentials.Oauth.ClientID = clientId + api.Credentials.Oauth.ClientSecret = clientSecret + } + + return api, nil +} diff --git a/components/gateway/internal/metadata/serviceapi/serviceapiservice_test.go b/components/gateway/internal/metadata/serviceapi/serviceapiservice_test.go new file mode 100644 index 000000000000..0b07e430a0e3 --- /dev/null +++ b/components/gateway/internal/metadata/serviceapi/serviceapiservice_test.go @@ -0,0 +1,84 @@ +package serviceapi + +import ( + "testing" + + secretsmocks "github.com/kyma-project/kyma/components/gateway/internal/metadata/secrets/mocks" + + "github.com/kyma-project/kyma/components/gateway/internal/apperrors" + "github.com/kyma-project/kyma/components/gateway/internal/metadata/remoteenv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaultService_Read(t *testing.T) { + t.Run("should read API with oauth credentials", func(t *testing.T) { + // given + remoteEnvServiceAPi := &remoteenv.ServiceAPI{ + TargetUrl: "http://target.com", + OauthUrl: "http://oauth.com", + CredentialsSecretName: "secret-name", + } + + secretsRepository := new(secretsmocks.Repository) + secretsRepository.On("Get", "secret-name").Return("clientId", "clientSecret", nil) + + service := NewService(secretsRepository) + + // when + api, err := service.Read(remoteEnvServiceAPi) + + // then + require.NoError(t, err) + assert.Equal(t, "http://target.com", api.TargetUrl) + assert.Equal(t, "http://oauth.com", api.Credentials.Oauth.URL) + assert.Equal(t, "clientId", api.Credentials.Oauth.ClientID) + assert.Equal(t, "clientSecret", api.Credentials.Oauth.ClientSecret) + assert.Nil(t, api.Spec) + + secretsRepository.AssertExpectations(t) + }) + + t.Run("should read API without oauth credentials", func(t *testing.T) { + // given + remoteEnvServiceAPi := &remoteenv.ServiceAPI{ + TargetUrl: "http://target.com", + } + + service := NewService(nil) + + // when + api, err := service.Read(remoteEnvServiceAPi) + + // then + require.NoError(t, err) + assert.Equal(t, "http://target.com", api.TargetUrl) + assert.Nil(t, api.Credentials) + assert.Nil(t, api.Spec) + }) + + t.Run("should return error when reading secret fails", func(t *testing.T) { + // given + remoteEnvServiceAPi := &remoteenv.ServiceAPI{ + TargetUrl: "http://target.com", + OauthUrl: "http://oauth.com", + CredentialsSecretName: "secret-name", + } + + secretsRepository := new(secretsmocks.Repository) + secretsRepository.On("Get", "secret-name"). + Return("", "", apperrors.Internal("secret error")) + + service := NewService(secretsRepository) + + // when + api, err := service.Read(remoteEnvServiceAPi) + + // then + assert.Error(t, err) + assert.Nil(t, api) + assert.Contains(t, err.Error(), "secret error") + + secretsRepository.AssertExpectations(t) + }) +} diff --git a/components/gateway/internal/metadata/servicedefservice.go b/components/gateway/internal/metadata/servicedefservice.go new file mode 100644 index 000000000000..d9956772fbd8 --- /dev/null +++ b/components/gateway/internal/metadata/servicedefservice.go @@ -0,0 +1,51 @@ +// Package metadata contains components for accessing Kyma storage (Remote Environments, Minio) +package metadata + +import ( + "github.com/kyma-project/kyma/components/gateway/internal/apperrors" + "github.com/kyma-project/kyma/components/gateway/internal/metadata/remoteenv" + "github.com/kyma-project/kyma/components/gateway/internal/metadata/serviceapi" + log "github.com/sirupsen/logrus" +) + +// ServiceDefinitionService is a service that manages ServiceDefinition objects. +type ServiceDefinitionService interface { + // GetAPI gets API of a service with given ID + GetAPI(serviceId string) (*serviceapi.API, apperrors.AppError) +} + +type serviceDefinitionService struct { + serviceAPIService serviceapi.Service + remoteEnvironmentRepository remoteenv.ServiceRepository +} + +// NewServiceDefinitionService creates new ServiceDefinitionService with provided dependencies. +func NewServiceDefinitionService(serviceAPIService serviceapi.Service, remoteEnvironmentRepository remoteenv.ServiceRepository) ServiceDefinitionService { + return &serviceDefinitionService{ + serviceAPIService: serviceAPIService, + remoteEnvironmentRepository: remoteEnvironmentRepository, + } +} + +// GetAPI gets API of a service with given ID +func (sds *serviceDefinitionService) GetAPI(serviceId string) (*serviceapi.API, apperrors.AppError) { + service, err := sds.remoteEnvironmentRepository.Get(serviceId) + if err != nil { + if err.Code() == apperrors.CodeNotFound { + return nil, apperrors.NotFound("service with ID %s not found", serviceId) + } + log.Errorf("failed to service with id '%s': %s", serviceId, err.Error()) + return nil, apperrors.Internal("failed to read %s service, %s", serviceId, err) + } + + if service.API == nil { + return nil, apperrors.WrongInput("service with ID '%s' has no API") + } + + api, err := sds.serviceAPIService.Read(service.API) + if err != nil { + log.Errorf("failed to read api for serviceId '%s': %s", serviceId, err.Error()) + return nil, apperrors.Internal("failed to read API for %s service, %s", serviceId, err) + } + return api, nil +} diff --git a/components/gateway/internal/metadata/servicedefservice_test.go b/components/gateway/internal/metadata/servicedefservice_test.go new file mode 100644 index 000000000000..5bc15b049b8a --- /dev/null +++ b/components/gateway/internal/metadata/servicedefservice_test.go @@ -0,0 +1,115 @@ +package metadata + +import ( + "testing" + + "github.com/kyma-project/kyma/components/gateway/internal/apperrors" + "github.com/kyma-project/kyma/components/gateway/internal/metadata/remoteenv" + remoteenvmocks "github.com/kyma-project/kyma/components/gateway/internal/metadata/remoteenv/mocks" + "github.com/kyma-project/kyma/components/gateway/internal/metadata/serviceapi" + serviceapimocks "github.com/kyma-project/kyma/components/gateway/internal/metadata/serviceapi/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + empty []byte +) + +func TestServiceDefinitionService_GetAPI(t *testing.T) { + + t.Run("should get API", func(t *testing.T) { + // given + remoteEnvServiceAPI := &remoteenv.ServiceAPI{} + remoteEnvService := remoteenv.Service{API: remoteEnvServiceAPI} + serviceAPI := &serviceapi.API{} + + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Get", "uuid-1").Return(remoteEnvService, nil) + + serviceAPIService := new(serviceapimocks.Service) + serviceAPIService.On("Read", remoteEnvServiceAPI).Return(serviceAPI, nil) + + service := NewServiceDefinitionService(serviceAPIService, serviceRepository) + + // when + result, err := service.GetAPI("uuid-1") + + // then + require.NoError(t, err) + + assert.Equal(t, serviceAPI, result) + }) + + t.Run("should return not found error if service does not exist", func(t *testing.T) { + // given + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Get", "uuid-1").Return(remoteenv.Service{}, apperrors.NotFound("missing")) + + service := NewServiceDefinitionService(nil, serviceRepository) + + // when + result, err := service.GetAPI("uuid-1") + + // then + assert.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, apperrors.CodeNotFound, err.Code()) + }) + + t.Run("should return internal error if service does not exist", func(t *testing.T) { + // given + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Get", "uuid-1").Return(remoteenv.Service{}, apperrors.Internal("some error")) + + service := NewServiceDefinitionService(nil, serviceRepository) + + // when + result, err := service.GetAPI("uuid-1") + + // then + assert.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.Contains(t, err.Error(), "some error") + }) + + t.Run("should return bad request if service does not have API", func(t *testing.T) { + // given + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Get", "uuid-1").Return(remoteenv.Service{}, nil) + + service := NewServiceDefinitionService(nil, serviceRepository) + + // when + result, err := service.GetAPI("uuid-1") + + // then + assert.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, apperrors.CodeWrongInput, err.Code()) + }) + + t.Run("should return internal error if reading service API fails", func(t *testing.T) { + // given + remoteEnvServiceAPI := &remoteenv.ServiceAPI{} + remoteEnvService := remoteenv.Service{API: remoteEnvServiceAPI} + + serviceRepository := new(remoteenvmocks.ServiceRepository) + serviceRepository.On("Get", "uuid-1").Return(remoteEnvService, nil) + + serviceAPIService := new(serviceapimocks.Service) + serviceAPIService.On("Read", remoteEnvServiceAPI).Return(nil, apperrors.Internal("some error")) + + service := NewServiceDefinitionService(serviceAPIService, serviceRepository) + + // when + result, err := service.GetAPI("uuid-1") + + // then + assert.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, apperrors.CodeInternal, err.Code()) + assert.Contains(t, err.Error(), "some error") + }) +} diff --git a/components/gateway/internal/proxy/mocks/OAuthClient.go b/components/gateway/internal/proxy/mocks/OAuthClient.go new file mode 100644 index 000000000000..e157a46ec03b --- /dev/null +++ b/components/gateway/internal/proxy/mocks/OAuthClient.go @@ -0,0 +1,33 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. +package mocks + +import apperrors "github.com/kyma-project/kyma/components/gateway/internal/apperrors" +import mock "github.com/stretchr/testify/mock" + +// OAuthClient is an autogenerated mock type for the OAuthClient type +type OAuthClient struct { + mock.Mock +} + +// GetToken provides a mock function with given fields: clientID, clientSecret, authURL +func (_m *OAuthClient) GetToken(clientID string, clientSecret string, authURL string) (string, apperrors.AppError) { + ret := _m.Called(clientID, clientSecret, authURL) + + var r0 string + if rf, ok := ret.Get(0).(func(string, string, string) string); ok { + r0 = rf(clientID, clientSecret, authURL) + } else { + r0 = ret.Get(0).(string) + } + + var r1 apperrors.AppError + if rf, ok := ret.Get(1).(func(string, string, string) apperrors.AppError); ok { + r1 = rf(clientID, clientSecret, authURL) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(apperrors.AppError) + } + } + + return r0, r1 +} diff --git a/components/gateway/internal/proxy/oauthclient.go b/components/gateway/internal/proxy/oauthclient.go new file mode 100644 index 000000000000..86c9d2e67948 --- /dev/null +++ b/components/gateway/internal/proxy/oauthclient.go @@ -0,0 +1,81 @@ +package proxy + +import ( + "context" + "crypto/tls" + "encoding/json" + "io/ioutil" + "net/http" + "net/url" + "strings" + "time" + + "github.com/kyma-project/kyma/components/gateway/internal/apperrors" + "github.com/kyma-project/kyma/components/gateway/internal/httpconsts" +) + +type oauthResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` +} + +type OAuthClient interface { + GetToken(clientID string, clientSecret string, authURL string) (string, apperrors.AppError) +} + +type oauthClient struct { + timeoutDuration int +} + +func NewOauthClient(timeoutDuration int) OAuthClient { + return &oauthClient{timeoutDuration: timeoutDuration} +} + +func (oc *oauthClient) GetToken(clientID string, clientSecret string, authURL string) (string, apperrors.AppError) { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + client := &http.Client{Transport: transport} + + form := url.Values{} + form.Add("client_id", clientID) + form.Add("client_secret", clientSecret) + form.Add("grant_type", "client_credentials") + + req, err := http.NewRequest(http.MethodPost, authURL, strings.NewReader(form.Encode())) + if err != nil { + return "", apperrors.Internal("failed to create token request: %s", err.Error()) + } + + req.Header.Add(httpconsts.HeaderContentType, httpconsts.ContentTypeApplicationURLEncoded) + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(oc.timeoutDuration)*time.Second) + defer cancel() + requestWithContext := req.WithContext(ctx) + + response, err := client.Do(requestWithContext) + if err != nil { + return "", apperrors.UpstreamServerCallFailed("failed to make a request to '%s': %s", authURL, err.Error()) + } + + if response.StatusCode != http.StatusOK { + return "", apperrors.UpstreamServerCallFailed("incorrect response code '%s' while getting token from %s", response.StatusCode, authURL) + } + + body, err := ioutil.ReadAll(response.Body) + defer response.Body.Close() + if err != nil { + return "", apperrors.UpstreamServerCallFailed("failed to read token response body from '%s': %s", authURL, err.Error()) + } + + tokenResponse := oauthResponse{} + + err = json.Unmarshal(body, &tokenResponse) + if err != nil { + return "", apperrors.UpstreamServerCallFailed("failed to unmarshal token response body: %s", err.Error()) + } + + return "Bearer " + tokenResponse.AccessToken, nil +} diff --git a/components/gateway/internal/proxy/oauthclient_test.go b/components/gateway/internal/proxy/oauthclient_test.go new file mode 100644 index 000000000000..2006c99b7071 --- /dev/null +++ b/components/gateway/internal/proxy/oauthclient_test.go @@ -0,0 +1,87 @@ +package proxy + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetToken(t *testing.T) { + + t.Run("should fetch token from EC", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + r.ParseForm() + + assert.Equal(t, "test", r.PostForm.Get("client_id")) + assert.Equal(t, "test", r.PostForm.Get("client_secret")) + assert.Equal(t, "client_credentials", r.PostForm.Get("grant_type")) + + response := oauthResponse{AccessToken: "123456789", TokenType: "bearer", ExpiresIn: 3600, Scope: "basic"} + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + })) + defer ts.Close() + + oauthClient := NewOauthClient(10) + token, err := oauthClient.GetToken("test", "test", ts.URL) + + require.NoError(t, err) + assert.Equal(t, "Bearer 123456789", token) + }) + + t.Run("should fail when unable to get token", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + r.ParseForm() + + assert.Equal(t, "test", r.PostForm.Get("client_id")) + assert.Equal(t, "test", r.PostForm.Get("client_secret")) + assert.Equal(t, "client_credentials", r.PostForm.Get("grant_type")) + + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + + oauthClient := NewOauthClient(10) + token, err := oauthClient.GetToken("test", "test", ts.URL) + + require.Error(t, err) + assert.Equal(t, "", token) + }) + + t.Run("should fail if payload is empty", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + r.ParseForm() + + assert.Equal(t, "test", r.PostForm.Get("client_id")) + assert.Equal(t, "test", r.PostForm.Get("client_secret")) + assert.Equal(t, "client_credentials", r.PostForm.Get("grant_type")) + + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + oauthClient := NewOauthClient(10) + token, err := oauthClient.GetToken("test", "test", ts.URL) + + require.Error(t, err) + assert.Equal(t, "", token) + }) + + t.Run("should fail if OAuth address is incorrect", func(t *testing.T) { + + oauthClient := NewOauthClient(10) + token, err := oauthClient.GetToken("test", "test", "http://some_no_existent_address.com/token") + + require.Error(t, err) + assert.Equal(t, "", token) + }) + +} diff --git a/components/gateway/internal/proxy/proxy.go b/components/gateway/internal/proxy/proxy.go new file mode 100644 index 000000000000..b97098c1e63e --- /dev/null +++ b/components/gateway/internal/proxy/proxy.go @@ -0,0 +1,136 @@ +package proxy + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/kyma-project/kyma/components/gateway/internal/apperrors" + "github.com/kyma-project/kyma/components/gateway/internal/httpconsts" + "github.com/kyma-project/kyma/components/gateway/internal/httperrors" + "github.com/kyma-project/kyma/components/gateway/internal/k8sconsts" + "github.com/kyma-project/kyma/components/gateway/internal/metadata" + "github.com/kyma-project/kyma/components/gateway/internal/metadata/serviceapi" + "github.com/kyma-project/kyma/components/gateway/internal/proxy/proxycache" + log "github.com/sirupsen/logrus" +) + +type proxy struct { + nameResolver k8sconsts.NameResolver + serviceDefService metadata.ServiceDefinitionService + oauthClient OAuthClient + httpProxyCache proxycache.HTTPProxyCache + skipVerify bool + proxyTimeout int +} + +// New creates proxy for handling user's services calls +func New(nameResolver k8sconsts.NameResolver, serviceDefService metadata.ServiceDefinitionService, + oauthClient OAuthClient, httpProxyCache proxycache.HTTPProxyCache, skipVerify bool, proxyTimeout int) http.Handler { + return &proxy{ + nameResolver: nameResolver, + serviceDefService: serviceDefService, + oauthClient: oauthClient, + httpProxyCache: httpProxyCache, + skipVerify: skipVerify, + proxyTimeout: proxyTimeout, + } +} + +// NewInvalidStateHandler creates handler always returning 500 response +func NewInvalidStateHandler(message string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handleErrors(w, apperrors.Internal(message)) + }) +} + +func (p *proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + id := p.nameResolver.ExtractServiceId(r.Host) + + cacheObj, found := p.httpProxyCache.Get(id) + + var err apperrors.AppError + if !found { + cacheObj, err = p.createAndCacheProxy(id) + if err != nil { + handleErrors(w, err) + return + } + } + + kymaAuthorization := r.Header.Get(httpconsts.HeaderAccessToken) + if kymaAuthorization != "" { + r.Header.Del(httpconsts.HeaderAccessToken) + r.Header.Set(httpconsts.HeaderAuthorization, kymaAuthorization) + } else if cacheObj.OauthUrl != "" { + err = p.addCredentials(r, cacheObj.OauthUrl, cacheObj.ClientId, cacheObj.ClientSecret) + if err != nil { + handleErrors(w, err) + return + } + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(p.proxyTimeout)*time.Second) + defer cancel() + requestWithContext := r.WithContext(ctx) + + cacheObj.Proxy.ServeHTTP(w, requestWithContext) +} + +func (p *proxy) createAndCacheProxy(id string) (*proxycache.Proxy, apperrors.AppError) { + serviceApi, err := p.serviceDefService.GetAPI(id) + if err != nil { + return nil, err + } + + proxy, err := makeProxy(serviceApi.TargetUrl, id, p.skipVerify) + + if oauthCredentialsProvided(serviceApi.Credentials) { + return p.httpProxyCache.Add( + id, + serviceApi.Credentials.Oauth.URL, + serviceApi.Credentials.Oauth.ClientID, + serviceApi.Credentials.Oauth.ClientSecret, + proxy, + ), nil + } + + return p.httpProxyCache.Add( + id, + "", + "", + "", + proxy, + ), nil +} + +func (p *proxy) addCredentials(r *http.Request, oauthUrl, clientId, clientSecret string) apperrors.AppError { + token, err := p.oauthClient.GetToken(clientId, clientSecret, oauthUrl) + if err != nil { + log.Errorf("failed to get token : '%s'", err) + return err + } + + r.Header.Set(httpconsts.HeaderAuthorization, token) + log.Infof("OAuth token fecthed. Adding Authorization header: %s", r.Header.Get("Authorization")) + + return nil +} + +func respondWithBody(w http.ResponseWriter, code int, body httperrors.ErrorResponse) { + w.Header().Set(httpconsts.HeaderContentType, httpconsts.ContentTypeApplicationJson) + + w.WriteHeader(code) + + json.NewEncoder(w).Encode(body) +} + +func handleErrors(w http.ResponseWriter, apperr apperrors.AppError) { + code, body := httperrors.AppErrorToResponse(apperr) + respondWithBody(w, code, body) +} + +func oauthCredentialsProvided(credentials *serviceapi.Credentials) bool { + return credentials != nil && credentials.Oauth.ClientID != "" && credentials.Oauth.ClientSecret != "" +} diff --git a/components/gateway/internal/proxy/proxy_test.go b/components/gateway/internal/proxy/proxy_test.go new file mode 100644 index 000000000000..503f58aad5a3 --- /dev/null +++ b/components/gateway/internal/proxy/proxy_test.go @@ -0,0 +1,392 @@ +package proxy + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "testing" + + "github.com/kyma-project/kyma/components/gateway/internal/apperrors" + "github.com/kyma-project/kyma/components/gateway/internal/httpconsts" + "github.com/kyma-project/kyma/components/gateway/internal/httperrors" + k8smocks "github.com/kyma-project/kyma/components/gateway/internal/k8sconsts/mocks" + "github.com/kyma-project/kyma/components/gateway/internal/metadata" + metadataMock "github.com/kyma-project/kyma/components/gateway/internal/metadata/mocks" + "github.com/kyma-project/kyma/components/gateway/internal/metadata/serviceapi" + "github.com/kyma-project/kyma/components/gateway/internal/proxy/mocks" + "github.com/kyma-project/kyma/components/gateway/internal/proxy/proxycache" + cacheMock "github.com/kyma-project/kyma/components/gateway/internal/proxy/proxycache/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestProxy(t *testing.T) { + + proxyTimeout := 10 + + t.Run("should proxy", func(t *testing.T) { + // given + ts := NewTestServer(func(req *http.Request) { + assert.Equal(t, req.Method, http.MethodGet) + assert.Equal(t, req.RequestURI, "/orders/123") + }) + defer ts.Close() + + req, _ := http.NewRequest(http.MethodGet, "/orders/123", nil) + req.Host = "uuid-1.cluster.local" + + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("ExtractServiceId", "uuid-1.cluster.local").Return("uuid-1") + + u, _ := url.Parse(ts.URL) + httpCacheMock := &cacheMock.HTTPProxyCache{} + httpCacheMock.On("Get", "uuid-1").Return( + &proxycache.Proxy{ + Proxy: httputil.NewSingleHostReverseProxy(u), + ClientId: "", + OauthUrl: "", + ClientSecret: "", + }, true) + + handler := New(nameResolver, nil, nil, httpCacheMock, true, proxyTimeout) + rr := httptest.NewRecorder() + + // when + handler.ServeHTTP(rr, req) + + // then + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "test", rr.Body.String()) + + httpCacheMock.AssertExpectations(t) + }) + + t.Run("should proxy with prefetching oauth token", func(t *testing.T) { + // given + ts := NewTestServer(func(req *http.Request) { + assert.Equal(t, "Bearer access_token", req.Header.Get(httpconsts.HeaderAuthorization)) + }) + defer ts.Close() + + req, _ := http.NewRequest(http.MethodGet, "/orders/123", nil) + req.Host = "uuid-1.cluster.local" + + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("ExtractServiceId", "uuid-1.cluster.local").Return("uuid-1") + + oauthClientMock := &mocks.OAuthClient{} + oauthClientMock.On( + "GetToken", + "clientId", + "clientSecret", + "www.example.com/oauth", + ).Return("Bearer access_token", nil) + + u, _ := url.Parse(ts.URL) + httpCacheMock := &cacheMock.HTTPProxyCache{} + httpCacheMock.On("Get", "uuid-1").Return( + &proxycache.Proxy{ + Proxy: httputil.NewSingleHostReverseProxy(u), + ClientId: "clientId", + ClientSecret: "clientSecret", + OauthUrl: "www.example.com/oauth", + }, true) + + handler := New(nameResolver, nil, oauthClientMock, httpCacheMock, true, proxyTimeout) + rr := httptest.NewRecorder() + + // when + handler.ServeHTTP(rr, req) + + // then + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "test", rr.Body.String()) + + oauthClientMock.AssertExpectations(t) + httpCacheMock.AssertExpectations(t) + }) + + t.Run("should handle Kyma-Target-Token header", func(t *testing.T) { + // given + ts := NewTestServer(func(req *http.Request) { + assert.Equal(t, "token", req.Header.Get(httpconsts.HeaderAuthorization)) + assert.Equal(t, "", req.Header.Get(httpconsts.HeaderAccessToken)) + }) + defer ts.Close() + + req, _ := http.NewRequest(http.MethodGet, "/orders/123", nil) + req.Host = "uuid-1.cluster.local" + req.Header.Set(httpconsts.HeaderAccessToken, "token") + + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("ExtractServiceId", "uuid-1.cluster.local").Return("uuid-1") + + u, _ := url.Parse(ts.URL) + httpCacheMock := &cacheMock.HTTPProxyCache{} + httpCacheMock.On("Get", "uuid-1").Return( + &proxycache.Proxy{ + Proxy: httputil.NewSingleHostReverseProxy(u), + ClientId: "clientId", + ClientSecret: "clientSecret", + OauthUrl: "www.example.com/oauth", + }, true) + + handler := New(nameResolver, nil, nil, httpCacheMock, true, proxyTimeout) + rr := httptest.NewRecorder() + + // when + handler.ServeHTTP(rr, req) + + // then + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "test", rr.Body.String()) + + httpCacheMock.AssertExpectations(t) + }) + + t.Run("should proxy on cache miss", func(t *testing.T) { + // given + ts := NewTestServer(func(req *http.Request) { + assert.Equal(t, req.Method, http.MethodGet) + assert.Equal(t, req.RequestURI, "/orders/123") + assert.Equal(t, "", req.Header.Get(httpconsts.HeaderAuthorization)) + }) + defer ts.Close() + + req, _ := http.NewRequest(http.MethodGet, "/orders/123", nil) + req.Host = "uuid-1.cluster.local" + + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("ExtractServiceId", "uuid-1.cluster.local").Return("uuid-1") + + serviceDefServiceMock := &metadataMock.ServiceDefinitionService{} + serviceDefServiceMock.On("GetAPI", "uuid-1").Return(&serviceapi.API{ + TargetUrl: ts.URL, + }, nil) + + u, _ := url.Parse(ts.URL) + httpCacheMock := &cacheMock.HTTPProxyCache{} + httpCacheMock.On("Get", "uuid-1").Return(nil, false) + httpCacheMock.On("Add", "uuid-1", "", "", "", mock.AnythingOfType("*httputil.ReverseProxy")).Return( + &proxycache.Proxy{ + Proxy: httputil.NewSingleHostReverseProxy(u), + ClientId: "", + OauthUrl: "", + ClientSecret: "", + }) + + handler := New(nameResolver, serviceDefServiceMock, nil, httpCacheMock, true, proxyTimeout) + rr := httptest.NewRecorder() + + // when + handler.ServeHTTP(rr, req) + + // then + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "test", rr.Body.String()) + + serviceDefServiceMock.AssertExpectations(t) + httpCacheMock.AssertExpectations(t) + }) + + t.Run("should proxy on cache miss with prefetching oauth token ", func(t *testing.T) { + // given + ts := NewTestServer(func(req *http.Request) { + assert.Equal(t, "Bearer access_token", req.Header.Get(httpconsts.HeaderAuthorization)) + }) + defer ts.Close() + + req, _ := http.NewRequest(http.MethodGet, "/orders/123", nil) + req.Host = "uuid-1.cluster.local" + + serviceDefinition := metadata.ServiceDefinition{ + ID: "uuid-1", + Name: "service1", + Api: &serviceapi.API{ + TargetUrl: ts.URL, + Credentials: &serviceapi.Credentials{ + Oauth: serviceapi.Oauth{ + URL: "www.example.com/oauth", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + }} + + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("ExtractServiceId", "uuid-1.cluster.local").Return(serviceDefinition.ID) + + serviceDefServiceMock := &metadataMock.ServiceDefinitionService{} + serviceDefServiceMock.On("GetAPI", serviceDefinition.ID).Return(serviceDefinition.Api, nil) + + oauthClientMock := &mocks.OAuthClient{} + oauthClientMock.On( + "GetToken", + serviceDefinition.Api.Credentials.Oauth.ClientID, + serviceDefinition.Api.Credentials.Oauth.ClientSecret, + serviceDefinition.Api.Credentials.Oauth.URL, + ).Return("Bearer access_token", nil) + + u, _ := url.Parse(serviceDefinition.Api.TargetUrl) + httpCacheMock := &cacheMock.HTTPProxyCache{} + httpCacheMock.On("Get", "uuid-1").Return(nil, false) + httpCacheMock.On( + "Add", + "uuid-1", + serviceDefinition.Api.Credentials.Oauth.URL, + serviceDefinition.Api.Credentials.Oauth.ClientID, + serviceDefinition.Api.Credentials.Oauth.ClientSecret, + mock.AnythingOfType("*httputil.ReverseProxy"), + ).Return( + &proxycache.Proxy{ + Proxy: httputil.NewSingleHostReverseProxy(u), + ClientId: serviceDefinition.Api.Credentials.Oauth.ClientID, + OauthUrl: serviceDefinition.Api.Credentials.Oauth.URL, + ClientSecret: serviceDefinition.Api.Credentials.Oauth.ClientSecret, + }) + + handler := New(nameResolver, serviceDefServiceMock, oauthClientMock, httpCacheMock, true, proxyTimeout) + rr := httptest.NewRecorder() + + // when + handler.ServeHTTP(rr, req) + + // then + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "test", rr.Body.String()) + + serviceDefServiceMock.AssertExpectations(t) + oauthClientMock.AssertExpectations(t) + httpCacheMock.AssertExpectations(t) + }) + + t.Run("should return 500 if failed to get service definition", func(t *testing.T) { + // given + req, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + req.Host = "uuid-1.cluster.local" + rr := httptest.NewRecorder() + + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("ExtractServiceId", "uuid-1.cluster.local").Return("uuid-1") + + serviceDefServiceMock := &metadataMock.ServiceDefinitionService{} + serviceDefServiceMock.On("GetAPI", "uuid-1"). + Return(&serviceapi.API{}, apperrors.Internal("Failed to read services")) + + proxyCacheMock := &cacheMock.HTTPProxyCache{} + proxyCacheMock.On("Get", "uuid-1").Return(nil, false) + + handler := New(nameResolver, serviceDefServiceMock, nil, proxyCacheMock, true, proxyTimeout) + + // when + handler.ServeHTTP(rr, req) + + // then + var errorResponse httperrors.ErrorResponse + + json.Unmarshal([]byte(rr.Body.String()), &errorResponse) + + serviceDefServiceMock.AssertExpectations(t) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + assert.Equal(t, http.StatusInternalServerError, errorResponse.Code) + + proxyCacheMock.AssertExpectations(t) + }) + + t.Run("should return 502 if failed to prefetch token", func(t *testing.T) { + // given + req, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + req.Host = "uuid-1.cluster.local" + rr := httptest.NewRecorder() + + serviceDefinition := metadata.ServiceDefinition{ + ID: "uuid-1", + Name: "service1", + Api: &serviceapi.API{ + TargetUrl: "www.exaple.com/service1", + Credentials: &serviceapi.Credentials{ + Oauth: serviceapi.Oauth{ + URL: "www.example.com/oauth", + ClientID: "clientId", + ClientSecret: "clientSecret", + }, + }, + }} + + nameResolver := new(k8smocks.NameResolver) + nameResolver.On("ExtractServiceId", "uuid-1.cluster.local").Return("uuid-1") + + serviceDefServiceMock := &metadataMock.ServiceDefinitionService{} + + oauthClientMock := &mocks.OAuthClient{} + oauthClientMock.On( + "GetToken", + serviceDefinition.Api.Credentials.Oauth.ClientID, + serviceDefinition.Api.Credentials.Oauth.ClientSecret, + serviceDefinition.Api.Credentials.Oauth.URL, + ).Return("", apperrors.UpstreamServerCallFailed("failed to get token")) + + httpCacheMock := &cacheMock.HTTPProxyCache{} + httpCacheMock.On("Get", "uuid-1"). + Return(&proxycache.Proxy{ + Proxy: &httputil.ReverseProxy{}, + OauthUrl: serviceDefinition.Api.Credentials.Oauth.URL, + ClientId: serviceDefinition.Api.Credentials.Oauth.ClientID, + ClientSecret: serviceDefinition.Api.Credentials.Oauth.ClientSecret, + }, true) + + handler := New(nameResolver, serviceDefServiceMock, oauthClientMock, httpCacheMock, true, 10) + + // when + handler.ServeHTTP(rr, req) + + // then + var errorResponse httperrors.ErrorResponse + + json.Unmarshal([]byte(rr.Body.String()), &errorResponse) + + assert.Equal(t, http.StatusBadGateway, rr.Code) + assert.Equal(t, http.StatusBadGateway, errorResponse.Code) + + serviceDefServiceMock.AssertExpectations(t) + httpCacheMock.AssertExpectations(t) + oauthClientMock.AssertExpectations(t) + }) +} + +func TestInvalidStateHandler(t *testing.T) { + t.Run("should always return Internal Server Error", func(t *testing.T) { + // given + req, err := http.NewRequest(http.MethodGet, "/test", nil) + require.NoError(t, err) + + rr := httptest.NewRecorder() + + handler := NewInvalidStateHandler("Gateway id not initialized properly") + + // when + handler.ServeHTTP(rr, req) + + // then + var errorResponse httperrors.ErrorResponse + + json.Unmarshal([]byte(rr.Body.String()), &errorResponse) + + assert.Equal(t, http.StatusInternalServerError, rr.Code) + assert.Equal(t, http.StatusInternalServerError, errorResponse.Code) + }) +} + +func NewTestServer(check func(req *http.Request)) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + check(r) + w.WriteHeader(http.StatusOK) + w.Write([]byte("test")) + })) +} diff --git a/components/gateway/internal/proxy/proxycache/mocks/HTTPProxyCache.go b/components/gateway/internal/proxy/proxycache/mocks/HTTPProxyCache.go new file mode 100644 index 000000000000..d30fc7adca63 --- /dev/null +++ b/components/gateway/internal/proxy/proxycache/mocks/HTTPProxyCache.go @@ -0,0 +1,50 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. +package mocks + +import httputil "net/http/httputil" +import mock "github.com/stretchr/testify/mock" +import proxycache "github.com/kyma-project/kyma/components/gateway/internal/proxy/proxycache" + +// HTTPProxyCache is an autogenerated mock type for the HTTPProxyCache type +type HTTPProxyCache struct { + mock.Mock +} + +// Add provides a mock function with given fields: id, oauthUrl, clientId, clientSecret, proxy +func (_m *HTTPProxyCache) Add(id string, oauthUrl string, clientId string, clientSecret string, proxy *httputil.ReverseProxy) *proxycache.Proxy { + ret := _m.Called(id, oauthUrl, clientId, clientSecret, proxy) + + var r0 *proxycache.Proxy + if rf, ok := ret.Get(0).(func(string, string, string, string, *httputil.ReverseProxy) *proxycache.Proxy); ok { + r0 = rf(id, oauthUrl, clientId, clientSecret, proxy) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*proxycache.Proxy) + } + } + + return r0 +} + +// Get provides a mock function with given fields: id +func (_m *HTTPProxyCache) Get(id string) (*proxycache.Proxy, bool) { + ret := _m.Called(id) + + var r0 *proxycache.Proxy + if rf, ok := ret.Get(0).(func(string) *proxycache.Proxy); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*proxycache.Proxy) + } + } + + var r1 bool + if rf, ok := ret.Get(1).(func(string) bool); ok { + r1 = rf(id) + } else { + r1 = ret.Get(1).(bool) + } + + return r0, r1 +} diff --git a/components/gateway/internal/proxy/proxycache/proxycache.go b/components/gateway/internal/proxy/proxycache/proxycache.go new file mode 100644 index 000000000000..39c2ad9a1dde --- /dev/null +++ b/components/gateway/internal/proxy/proxycache/proxycache.go @@ -0,0 +1,50 @@ +package proxycache + +import ( + "net/http/httputil" + "time" + + "github.com/patrickmn/go-cache" +) + +const cleanupInterval = 60 + +type Proxy struct { + Proxy *httputil.ReverseProxy + OauthUrl string + ClientId string + ClientSecret string +} + +type HTTPProxyCache interface { + Get(id string) (*Proxy, bool) + Add(id, oauthUrl, clientId, clientSecret string, proxy *httputil.ReverseProxy) *Proxy +} + +type httpProxyCache struct { + skipVerify bool + proxyCache *cache.Cache +} + +func NewProxyCache(skipVerify bool, proxyCacheTTL int) HTTPProxyCache { + return &httpProxyCache{ + skipVerify: skipVerify, + proxyCache: cache.New(time.Duration(proxyCacheTTL)*time.Second, cleanupInterval*time.Second), + } +} + +func (p *httpProxyCache) Get(id string) (*Proxy, bool) { + proxy, found := p.proxyCache.Get(id) + if !found { + return nil, false + } + + return proxy.(*Proxy), found +} + +func (p *httpProxyCache) Add(id, oauthUrl, clientId, clientSecret string, reverseProxy *httputil.ReverseProxy) *Proxy { + proxy := &Proxy{Proxy: reverseProxy, OauthUrl: oauthUrl, ClientId: clientId, ClientSecret: clientSecret} + p.proxyCache.Set(id, proxy, cache.DefaultExpiration) + + return proxy +} diff --git a/components/gateway/internal/proxy/proxycache/proxycache_test.go b/components/gateway/internal/proxy/proxycache/proxycache_test.go new file mode 100644 index 000000000000..6858ae1829cb --- /dev/null +++ b/components/gateway/internal/proxy/proxycache/proxycache_test.go @@ -0,0 +1,102 @@ +package proxycache + +import ( + "net/http" + "net/http/httptest" + "net/http/httputil" + url2 "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHttpProxyCache_Create(t *testing.T) { + t.Run("should create a reverse proxy", func(t *testing.T) { + // given + ts := prepareHttpServer(t) + defer ts.Close() + + proxyCache := NewProxyCache(false, 60) + + url, err := url2.Parse(ts.URL) + require.NoError(t, err) + + proxy := httputil.NewSingleHostReverseProxy(url) + + // when + cacheObj := proxyCache.Add("id1", "", "", "", proxy) + + // then + require.NotNil(t, cacheObj) + require.NotNil(t, cacheObj.Proxy) + + // when + req, err := http.NewRequest(http.MethodGet, "/test", nil) + require.NoError(t, err) + rr := httptest.NewRecorder() + + cacheObj.Proxy.ServeHTTP(rr, req) + + // then + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "test", rr.Body.String()) + }) +} + +func TestHttpProxyCache_Get(t *testing.T) { + t.Run("should return a proxy cache object", func(t *testing.T) { + // given + ts := prepareHttpServer(t) + defer ts.Close() + + proxyCache := NewProxyCache(false, 60) + + // when + proxyCache.Add("id1", "", "", "", &httputil.ReverseProxy{}) + cacheObj, _ := proxyCache.Get("id1") + + // then + require.NotNil(t, cacheObj) + require.NotNil(t, cacheObj.Proxy) + }) + + t.Run("should return false if id was not found", func(t *testing.T) { + // given + proxyCache := NewProxyCache(false, 60) + + // when + _, found := proxyCache.Get("id1") + + // then + require.False(t, found) + }) + + t.Run("should return nil if cache expired", func(t *testing.T) { + // given + proxyCache := NewProxyCache(false, 2) + + proxyCache.Add("id1", "http://test.url", "", "", &httputil.ReverseProxy{}) + + // when + time.Sleep(3 * time.Second) + + proxy, _ := proxyCache.Get("id1") + + // then + assert.Nil(t, proxy) + }) +} + +func prepareHttpServer(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/test", r.URL.Path) + assert.NotContains(t, r.Host, "someurl") + + w.WriteHeader(http.StatusOK) + w.Write([]byte("test")) + })) +} diff --git a/components/gateway/internal/proxy/proxyfactory.go b/components/gateway/internal/proxy/proxyfactory.go new file mode 100644 index 000000000000..7251f3f1b8ad --- /dev/null +++ b/components/gateway/internal/proxy/proxyfactory.go @@ -0,0 +1,63 @@ +package proxy + +import ( + "crypto/tls" + "net/http" + "net/http/httputil" + "net/url" + "strings" + + "github.com/kyma-project/kyma/components/gateway/internal/apperrors" + log "github.com/sirupsen/logrus" +) + +func makeProxy(targetUrl string, id string, skipVerify bool) (*httputil.ReverseProxy, apperrors.AppError) { + target, err := url.Parse(targetUrl) + if err != nil { + return nil, apperrors.Internal("failed to parse target url '%s'", target) + } + + targetQuery := target.RawQuery + director := func(req *http.Request) { + log.Infof("Proxy call for service '%s' to '%s'", id, targetUrl) + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + req.Host = target.Host + + req.URL.Path = joinPaths(target.Path, req.URL.Path) + + if targetQuery == "" || req.URL.RawQuery == "" { + req.URL.RawQuery = targetQuery + req.URL.RawQuery + } else { + req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery + } + if _, ok := req.Header["User-Agent"]; !ok { + // explicitly disable User-Agent so it's not set to default value + req.Header.Set("User-Agent", "") + } + log.Infof("Modified request url : '%s', schema : '%s', path : '%s'", req.URL.String(), req.URL.Scheme, req.URL.Path) + } + newProxy := &httputil.ReverseProxy{Director: director} + + if skipVerify { + newProxy.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} + } + + return newProxy, nil +} + +func joinPaths(a, b string) string { + if b == "" { + return a + } + + aslash := strings.HasSuffix(a, "/") + bslash := strings.HasPrefix(b, "/") + switch { + case aslash && bslash: + return a + b[1:] + case !aslash && !bslash: + return a + "/" + b + } + return a + b +} diff --git a/components/gateway/scripts/can-i-commit.sh b/components/gateway/scripts/can-i-commit.sh new file mode 100755 index 000000000000..4877d884c2cd --- /dev/null +++ b/components/gateway/scripts/can-i-commit.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +RED='\033[0;31m' +GREEN='\033[0;32m' +INVERTED='\033[7m' +NC='\033[0m' # No Color + +echo -e "${INVERTED}" +echo "USER: " + $USER +echo "PATH: " + $PATH +echo "GOPATH:" + $GOPATH +echo -e "${NC}" + +## +# DEP STATUS +## +echo "? dep status" +depResult=$(dep status -v) +if [ $? != 0 ] + then + echo -e "${RED}✗ dep status\n$depResult${NC}" + exit 1; + else echo -e "${GREEN}√ dep status${NC}" +fi + +## +# MAKE BUILD +## +echo "? make build" +( make build ) +if [ $? != 0 ]; # Check make build passed + then + echo -e "${RED}✗ make build\n${NC}" + exit 1; + else echo -e "${GREEN}√ make build${NC}" +fi + +filesToCheck=$(find . -type f -name "*.go" | egrep -v "\/vendor\/|_*/automock/|_*/testdata/|/pkg\/|_*export_test.go") + +# +# GO FMT +# +goFmtResult=$(echo "${filesToCheck}" | xargs -L1 go fmt) +if [ $(echo ${#goFmtResult}) != 0 ] + then + echo -e "${RED}✗ go fmt${NC}\n$goFmtResult${NC}" + exit 1; + else echo -e "${GREEN}√ go fmt${NC}" +fi + +echo -e "${GREEN}Congrats $(whoami)! You've made it! Now you can commit.${NC}" diff --git a/components/gateway/scripts/delete-all.sh b/components/gateway/scripts/delete-all.sh new file mode 100755 index 000000000000..b694d7f12aea --- /dev/null +++ b/components/gateway/scripts/delete-all.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +HOST=51.144.236.138:8081 + +curl http://$HOST/v1/metadata/services | jq ".[].id" -r > IDs.txt + +while read p; do + echo "$p => $(curl -sIX DELETE http://$HOST/v1/metadata/services/$p | head -n 1)" + echo +done { + let body = []; + request.on('data', (chunk) => { + body.push(chunk); + }).on('end', () => { + body = Buffer.concat(body).toString(); + + console.log(`==== ${request.method} ${request.url}`); + console.log('> Headers'); + console.log(request.headers); + + console.log('> Body'); + console.log(body); + response.writeHead(200, {"Content-Type": "application/json"}); + response.end(JSON.stringify({ + "token_type": "Bearer", + "access_token": "122-b012b9bd-0073-4415-0b2b-f06c36cc4031", + "expires_in": 3600, + "scope": "" + })); + }); +}).listen(8084); diff --git a/components/gateway/scripts/log-request.js b/components/gateway/scripts/log-request.js new file mode 100644 index 000000000000..9a2d26439f5a --- /dev/null +++ b/components/gateway/scripts/log-request.js @@ -0,0 +1,19 @@ +const http = require('http'); +const server = http.createServer(); + +server.on('request', (request, response) => { + let body = []; + request.on('data', (chunk) => { + body.push(chunk); + }).on('end', () => { + body = Buffer.concat(body).toString(); + + console.log(`==== ${request.method} ${request.url}`); + console.log('> Headers'); + console.log(request.headers); + + console.log('> Body'); + console.log(body); + response.end(); + }); +}).listen(8083); diff --git a/components/gateway/scripts/telepresence.log b/components/gateway/scripts/telepresence.log new file mode 100644 index 000000000000..c2889c350943 --- /dev/null +++ b/components/gateway/scripts/telepresence.log @@ -0,0 +1,846 @@ + 0.0 TL | Telepresence launched at Thu Jun 7 11:59:47 2018 + 0.0 TL | ['/usr/local/bin/telepresence', '--namespace', 'kyma-integration', '--swap-deployment', 'dj-test-gateway:dj-test-gateway', '--run-shell'] + 0.1 TL | BEGIN SPAN main.py:408(go) + 0.1 TL | Scout info: {'latest_version': '0.88', 'application': 'telepresence', 'notices': []} + 0.1 TL | Context: minikube, namespace: kyma-integration, kubectl_command: kubectl + 0.1 TL | Looks like we're in a local VM, e.g. minikube. + 0.1 TL | [1] Capturing: ['kubectl', '--context', 'minikube', 'cluster-info']... + 0.3 TL | [1] captured. + 0.3 TL | [2] Capturing: ['ssh', '-V']... + 0.4 TL | [2] captured. + 0.4 TL | [3] Capturing: ['which', 'torsocks']... + 0.4 TL | [3] captured. + 0.4 TL | [4] Capturing: ['which', 'sshfs']... + 0.4 TL | [4] captured. + 0.4 TL | BEGIN SPAN main.py:230(start_proxy) + 0.4 TL | BEGIN SPAN deployment.py:86(swap_deployment) + 0.4 TL | [5] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'deployment', '-o', 'json', '--export', 'dj-test-gateway']... + 0.6 TL | [5] captured. + 0.6 TL | [6] Running: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'delete', 'deployment', 'dj-test-gateway']... + 4.7 6 | deployment "dj-test-gateway" deleted + 4.7 TL | [6] ran. + 4.7 TL | [7] Running: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'apply', '-f', '-']... + 5.0 TL | [7] ran. + 5.0 TL | END SPAN deployment.py:86(swap_deployment) 4.6s + 5.0 TL | BEGIN SPAN remote.py:148(get_remote_info) + 5.0 TL | [8] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'deployment', '-o', 'json', '--export', '--selector=telepresence=bf6aeb3f-e420-48ac-98ac-c7a21b4553d8']... + 5.2 TL | [8] captured. + 5.2 TL | Expected metadata for pods: {'annotations': {'sidecar.istio.io/inject': 'true'}, 'creationTimestamp': None, 'labels': {'app': 'dj-test-gateway', 'release': 'dj-test-gateway', 'telepresence': 'bf6aeb3f-e420-48ac-98ac-c7a21b4553d8'}} + 5.2 TL | [9] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', '-o', 'json', '--export']... + 5.8 TL | [9] captured. + 5.9 TL | Checking {'app': 'dj-test-gateway', 'pod-template-hash': '308556081', 'release': 'dj-test-gateway', 'telepresence': 'bf6aeb3f-e420-48ac-98ac-c7a21b4553d8'} (phase Pending)... + 5.9 TL | Looks like we've found our pod! + 5.9 TL | [10] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 6.2 TL | [10] captured. + 6.5 TL | [11] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 7.3 TL | [11] captured. + 7.6 TL | [12] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 8.0 TL | [12] captured. + 8.3 TL | [13] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 11.5 TL | [13] captured. + 11.7 TL | [14] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 12.0 TL | [14] captured. + 12.3 TL | [15] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 12.6 TL | [15] captured. + 12.8 TL | [16] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 13.1 TL | [16] captured. + 13.3 TL | [17] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 13.7 TL | [17] captured. + 14.0 TL | [18] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 14.4 TL | [18] captured. + 14.7 TL | [19] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 15.1 TL | [19] captured. + 15.4 TL | [20] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 15.7 TL | [20] captured. + 15.9 TL | [21] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 16.2 TL | [21] captured. + 16.5 TL | [22] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 17.0 TL | [22] captured. + 17.3 TL | [23] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 17.5 TL | [23] captured. + 17.7 TL | [24] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 18.6 TL | [24] captured. + 18.9 TL | [25] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 19.2 TL | [25] captured. + 19.5 TL | [26] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 19.7 TL | [26] captured. + 20.0 TL | [27] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 20.3 TL | [27] captured. + 20.5 TL | [28] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 20.7 TL | [28] captured. + 21.0 TL | [29] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 21.2 TL | [29] captured. + 21.5 TL | [30] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 21.7 TL | [30] captured. + 21.9 TL | [31] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 22.1 TL | [31] captured. + 22.4 TL | [32] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 22.6 TL | [32] captured. + 22.9 TL | [33] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 23.2 TL | [33] captured. + 23.4 TL | [34] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 23.7 TL | [34] captured. + 24.0 TL | [35] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 24.5 TL | [35] captured. + 24.7 TL | [36] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 25.2 TL | [36] captured. + 25.4 TL | [37] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 25.8 TL | [37] captured. + 26.1 TL | [38] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 26.3 TL | [38] captured. + 26.6 TL | [39] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 26.8 TL | [39] captured. + 27.1 TL | [40] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 27.3 TL | [40] captured. + 27.6 TL | [41] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 27.8 TL | [41] captured. + 28.1 TL | [42] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 28.3 TL | [42] captured. + 28.5 TL | [43] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 28.7 TL | [43] captured. + 29.0 TL | [44] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 29.2 TL | [44] captured. + 29.5 TL | [45] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 29.6 TL | [45] captured. + 29.9 TL | [46] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 30.1 TL | [46] captured. + 30.4 TL | [47] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 30.6 TL | [47] captured. + 30.9 TL | [48] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 31.1 TL | [48] captured. + 31.4 TL | [49] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 31.7 TL | [49] captured. + 31.9 TL | [50] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 32.6 TL | [50] captured. + 32.8 TL | [51] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 33.1 TL | [51] captured. + 33.3 TL | [52] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 33.6 TL | [52] captured. + 33.8 TL | [53] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 34.0 TL | [53] captured. + 34.3 TL | [54] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 34.5 TL | [54] captured. + 34.8 TL | [55] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 35.0 TL | [55] captured. + 35.2 TL | [56] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 35.4 TL | [56] captured. + 35.7 TL | [57] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 35.9 TL | [57] captured. + 36.1 TL | [58] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 36.3 TL | [58] captured. + 36.6 TL | [59] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 36.8 TL | [59] captured. + 37.0 TL | [60] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 37.2 TL | [60] captured. + 37.4 TL | [61] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 37.6 TL | [61] captured. + 37.8 TL | [62] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 38.0 TL | [62] captured. + 38.2 TL | [63] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 38.3 TL | [63] captured. + 38.6 TL | [64] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 38.7 TL | [64] captured. + 39.0 TL | [65] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 39.1 TL | [65] captured. + 39.4 TL | [66] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 39.5 TL | [66] captured. + 39.8 TL | [67] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 39.9 TL | [67] captured. + 40.2 TL | [68] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 40.4 TL | [68] captured. + 40.7 TL | [69] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 40.9 TL | [69] captured. + 41.1 TL | [70] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 41.3 TL | [70] captured. + 41.6 TL | [71] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 41.8 TL | [71] captured. + 42.0 TL | [72] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 42.2 TL | [72] captured. + 42.4 TL | [73] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 42.6 TL | [73] captured. + 42.8 TL | [74] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 43.0 TL | [74] captured. + 43.2 TL | [75] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 43.4 TL | [75] captured. + 43.6 TL | [76] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 43.8 TL | [76] captured. + 44.0 TL | [77] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 44.2 TL | [77] captured. + 44.4 TL | [78] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 44.6 TL | [78] captured. + 44.8 TL | [79] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 45.0 TL | [79] captured. + 45.2 TL | [80] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 45.4 TL | [80] captured. + 45.6 TL | [81] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 45.8 TL | [81] captured. + 46.0 TL | [82] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 46.2 TL | [82] captured. + 46.5 TL | [83] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 46.6 TL | [83] captured. + 46.8 TL | [84] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 47.0 TL | [84] captured. + 47.3 TL | [85] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 47.4 TL | [85] captured. + 47.6 TL | [86] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 47.8 TL | [86] captured. + 48.0 TL | [87] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 48.2 TL | [87] captured. + 48.4 TL | [88] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 48.6 TL | [88] captured. + 48.9 TL | [89] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 49.0 TL | [89] captured. + 49.3 TL | [90] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 49.4 TL | [90] captured. + 49.6 TL | [91] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 49.8 TL | [91] captured. + 50.1 TL | [92] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 50.2 TL | [92] captured. + 50.4 TL | [93] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 50.6 TL | [93] captured. + 50.8 TL | [94] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 51.0 TL | [94] captured. + 51.3 TL | [95] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 51.5 TL | [95] captured. + 51.7 TL | [96] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 51.9 TL | [96] captured. + 52.1 TL | [97] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 52.3 TL | [97] captured. + 52.5 TL | [98] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 52.7 TL | [98] captured. + 52.9 TL | [99] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 53.1 TL | [99] captured. + 53.3 TL | [100] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 53.4 TL | [100] captured. + 53.7 TL | [101] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 53.8 TL | [101] captured. + 54.1 TL | [102] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 54.2 TL | [102] captured. + 54.5 TL | [103] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 54.6 TL | [103] captured. + 54.9 TL | [104] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 55.0 TL | [104] captured. + 55.3 TL | [105] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 55.4 TL | [105] captured. + 55.6 TL | [106] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 55.8 TL | [106] captured. + 56.0 TL | [107] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 56.2 TL | [107] captured. + 56.5 TL | [108] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 56.6 TL | [108] captured. + 56.9 TL | [109] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 57.0 TL | [109] captured. + 57.3 TL | [110] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 57.5 TL | [110] captured. + 57.7 TL | [111] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 57.9 TL | [111] captured. + 58.1 TL | [112] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 58.3 TL | [112] captured. + 58.6 TL | [113] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 58.8 TL | [113] captured. + 59.0 TL | [114] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 59.2 TL | [114] captured. + 59.4 TL | [115] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 59.6 TL | [115] captured. + 59.8 TL | [116] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 60.0 TL | [116] captured. + 60.2 TL | [117] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 60.4 TL | [117] captured. + 60.7 TL | [118] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 60.8 TL | [118] captured. + 61.1 TL | [119] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 61.2 TL | [119] captured. + 61.5 TL | [120] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 61.6 TL | [120] captured. + 61.9 TL | [121] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 62.0 TL | [121] captured. + 62.3 TL | [122] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 62.4 TL | [122] captured. + 62.7 TL | [123] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 62.8 TL | [123] captured. + 63.0 TL | [124] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 63.2 TL | [124] captured. + 63.4 TL | [125] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 63.6 TL | [125] captured. + 63.8 TL | [126] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 64.0 TL | [126] captured. + 64.3 TL | [127] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 64.4 TL | [127] captured. + 64.7 TL | [128] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 64.9 TL | [128] captured. + 65.1 TL | [129] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 65.2 TL | [129] captured. + 65.5 TL | [130] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 65.7 TL | [130] captured. + 65.9 TL | [131] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 66.1 TL | [131] captured. + 66.3 TL | [132] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 66.5 TL | [132] captured. + 66.7 TL | [133] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 66.9 TL | [133] captured. + 67.1 TL | [134] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 67.3 TL | [134] captured. + 67.5 TL | [135] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 67.7 TL | [135] captured. + 67.9 TL | [136] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 68.1 TL | [136] captured. + 68.3 TL | [137] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 68.5 TL | [137] captured. + 68.7 TL | [138] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 68.9 TL | [138] captured. + 69.2 TL | [139] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 69.4 TL | [139] captured. + 69.6 TL | [140] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 69.8 TL | [140] captured. + 70.1 TL | [141] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 70.2 TL | [141] captured. + 70.5 TL | [142] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 70.8 TL | [142] captured. + 71.1 TL | [143] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 71.3 TL | [143] captured. + 71.5 TL | [144] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 71.7 TL | [144] captured. + 72.0 TL | [145] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 72.2 TL | [145] captured. + 72.4 TL | [146] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 72.6 TL | [146] captured. + 72.9 TL | [147] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 73.2 TL | [147] captured. + 73.4 TL | [148] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 73.6 TL | [148] captured. + 73.9 TL | [149] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 74.0 TL | [149] captured. + 74.3 TL | [150] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 74.5 TL | [150] captured. + 74.8 TL | [151] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 75.0 TL | [151] captured. + 75.2 TL | [152] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 75.4 TL | [152] captured. + 75.6 TL | [153] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 76.3 TL | [153] captured. + 76.5 TL | [154] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 76.8 TL | [154] captured. + 77.1 TL | [155] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 77.2 TL | [155] captured. + 77.5 TL | [156] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 77.6 TL | [156] captured. + 77.9 TL | [157] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 78.1 TL | [157] captured. + 78.3 TL | [158] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 78.5 TL | [158] captured. + 78.7 TL | [159] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 78.9 TL | [159] captured. + 79.1 TL | [160] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 79.3 TL | [160] captured. + 79.5 TL | [161] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 79.7 TL | [161] captured. + 80.0 TL | [162] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 80.1 TL | [162] captured. + 80.4 TL | [163] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 80.6 TL | [163] captured. + 80.8 TL | [164] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 81.0 TL | [164] captured. + 81.2 TL | [165] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 81.4 TL | [165] captured. + 81.6 TL | [166] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 81.8 TL | [166] captured. + 82.0 TL | [167] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 82.2 TL | [167] captured. + 82.4 TL | [168] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 82.5 TL | [168] captured. + 82.8 TL | [169] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 82.9 TL | [169] captured. + 83.2 TL | [170] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 83.3 TL | [170] captured. + 83.6 TL | [171] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 83.9 TL | [171] captured. + 84.1 TL | [172] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 84.2 TL | [172] captured. + 84.5 TL | [173] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 84.7 TL | [173] captured. + 84.9 TL | [174] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 85.0 TL | [174] captured. + 85.3 TL | [175] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 85.5 TL | [175] captured. + 85.7 TL | [176] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 85.9 TL | [176] captured. + 86.1 TL | [177] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 86.4 TL | [177] captured. + 86.6 TL | [178] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 86.9 TL | [178] captured. + 87.1 TL | [179] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 87.2 TL | [179] captured. + 87.5 TL | [180] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 87.6 TL | [180] captured. + 87.9 TL | [181] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 88.0 TL | [181] captured. + 88.3 TL | [182] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 88.4 TL | [182] captured. + 88.7 TL | [183] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 88.9 TL | [183] captured. + 89.1 TL | [184] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 89.3 TL | [184] captured. + 89.6 TL | [185] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 89.8 TL | [185] captured. + 90.0 TL | [186] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 90.2 TL | [186] captured. + 90.4 TL | [187] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 90.7 TL | [187] captured. + 90.9 TL | [188] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 91.1 TL | [188] captured. + 91.3 TL | [189] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 91.7 TL | [189] captured. + 92.0 TL | [190] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 92.1 TL | [190] captured. + 92.4 TL | [191] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 92.5 TL | [191] captured. + 92.8 TL | [192] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 93.0 TL | [192] captured. + 93.2 TL | [193] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 93.4 TL | [193] captured. + 93.6 TL | [194] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 93.8 TL | [194] captured. + 94.0 TL | [195] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 94.2 TL | [195] captured. + 94.4 TL | [196] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 94.6 TL | [196] captured. + 94.9 TL | [197] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 95.1 TL | [197] captured. + 95.3 TL | [198] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 95.5 TL | [198] captured. + 95.8 TL | [199] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 95.9 TL | [199] captured. + 96.2 TL | [200] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 96.3 TL | [200] captured. + 96.6 TL | [201] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 96.8 TL | [201] captured. + 97.0 TL | [202] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 97.2 TL | [202] captured. + 97.4 TL | [203] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 97.6 TL | [203] captured. + 97.8 TL | [204] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 97.9 TL | [204] captured. + 98.2 TL | [205] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 98.4 TL | [205] captured. + 98.6 TL | [206] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 98.8 TL | [206] captured. + 99.0 TL | [207] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 99.2 TL | [207] captured. + 99.4 TL | [208] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 99.5 TL | [208] captured. + 99.8 TL | [209] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 99.9 TL | [209] captured. + 100.2 TL | [210] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 100.4 TL | [210] captured. + 100.6 TL | [211] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 100.8 TL | [211] captured. + 101.1 TL | [212] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 101.2 TL | [212] captured. + 101.5 TL | [213] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 101.6 TL | [213] captured. + 101.9 TL | [214] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 102.0 TL | [214] captured. + 102.3 TL | [215] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 102.4 TL | [215] captured. + 102.7 TL | [216] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 102.8 TL | [216] captured. + 103.0 TL | [217] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 103.2 TL | [217] captured. + 103.4 TL | [218] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 103.6 TL | [218] captured. + 103.8 TL | [219] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 104.0 TL | [219] captured. + 104.2 TL | [220] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 104.4 TL | [220] captured. + 104.6 TL | [221] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 104.8 TL | [221] captured. + 105.0 TL | [222] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 105.1 TL | [222] captured. + 105.4 TL | [223] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 105.5 TL | [223] captured. + 105.8 TL | [224] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 105.9 TL | [224] captured. + 106.2 TL | [225] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 106.4 TL | [225] captured. + 106.6 TL | [226] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 106.8 TL | [226] captured. + 107.0 TL | [227] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 107.2 TL | [227] captured. + 107.4 TL | [228] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 107.5 TL | [228] captured. + 107.8 TL | [229] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 107.9 TL | [229] captured. + 108.2 TL | [230] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 108.4 TL | [230] captured. + 108.6 TL | [231] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 108.8 TL | [231] captured. + 109.0 TL | [232] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 109.2 TL | [232] captured. + 109.5 TL | [233] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 109.7 TL | [233] captured. + 109.9 TL | [234] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 110.1 TL | [234] captured. + 110.3 TL | [235] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 110.5 TL | [235] captured. + 110.7 TL | [236] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 110.9 TL | [236] captured. + 111.1 TL | [237] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 111.3 TL | [237] captured. + 111.5 TL | [238] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 111.7 TL | [238] captured. + 112.0 TL | [239] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 112.3 TL | [239] captured. + 112.5 TL | [240] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 112.7 TL | [240] captured. + 112.9 TL | [241] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 113.0 TL | [241] captured. + 113.3 TL | [242] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 113.4 TL | [242] captured. + 113.7 TL | [243] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 113.8 TL | [243] captured. + 114.1 TL | [244] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 114.2 TL | [244] captured. + 114.5 TL | [245] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 114.6 TL | [245] captured. + 114.8 TL | [246] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 115.0 TL | [246] captured. + 115.3 TL | [247] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 115.5 TL | [247] captured. + 115.7 TL | [248] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 115.9 TL | [248] captured. + 116.1 TL | [249] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 116.3 TL | [249] captured. + 116.5 TL | [250] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 116.7 TL | [250] captured. + 116.9 TL | [251] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 117.1 TL | [251] captured. + 117.3 TL | [252] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 117.5 TL | [252] captured. + 117.8 TL | [253] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 117.9 TL | [253] captured. + 118.2 TL | [254] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 118.3 TL | [254] captured. + 118.6 TL | [255] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 118.7 TL | [255] captured. + 119.0 TL | [256] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 119.1 TL | [256] captured. + 119.3 TL | [257] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 119.5 TL | [257] captured. + 119.7 TL | [258] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 119.9 TL | [258] captured. + 120.1 TL | [259] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 120.3 TL | [259] captured. + 120.6 TL | [260] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 120.7 TL | [260] captured. + 120.9 TL | [261] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 121.1 TL | [261] captured. + 121.3 TL | [262] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 121.5 TL | [262] captured. + 121.7 TL | [263] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 121.9 TL | [263] captured. + 122.1 TL | [264] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 122.3 TL | [264] captured. + 122.5 TL | [265] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 122.6 TL | [265] captured. + 122.9 TL | [266] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 123.1 TL | [266] captured. + 123.3 TL | [267] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 123.5 TL | [267] captured. + 123.8 TL | [268] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 123.9 TL | [268] captured. + 124.1 TL | [269] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 124.3 TL | [269] captured. + 124.5 TL | [270] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 124.7 TL | [270] captured. + 124.9 TL | [271] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 125.0 TL | [271] captured. + 125.3 TL | [272] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 125.4 TL | [272] captured. + 125.7 TL | [273] Capturing: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'get', 'pod', 'dj-test-gateway-74d99b4d5-nrt6c', '-o', 'json']... + 125.8 TL | [273] captured. + 247.9 TL | [274] Running: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'delete', 'deployment', 'dj-test-gateway']... + 251.3 274 | deployment "dj-test-gateway" deleted + 251.3 TL | [274] ran. + 251.3 TL | [275] Running: ['kubectl', '--context', 'minikube', '--namespace', 'kyma-integration', 'apply', '-f', '-']... + 251.5 TL | [275] ran. + 251.5 TL | END SPAN main.py:408(go) 251.4s + 251.5 TL | SPAN SUMMARY: + 251.5 TL | 251.4s main.py:408(go) + 251.5 TL | 0.2s 1 kubectl --context minikube cluster-info + 251.5 TL | 0.0s 2 ssh -V + 251.5 TL | 0.0s 3 which torsocks + 251.5 TL | 0.0s 4 which sshfs + 251.5 TL | ??? main.py:230(start_proxy) + 251.5 TL | 4.6s deployment.py:86(swap_deployment) + 251.5 TL | 0.2s 5 kubectl --context minikube --namespace kyma-integration get deployment -o json + 251.5 TL | 4.0s 6 kubectl --context minikube --namespace kyma-integration delete deployment dj-t + 251.5 TL | 0.3s 7 kubectl --context minikube --namespace kyma-integration apply -f - + 251.5 TL | ??? remote.py:148(get_remote_info) + 251.5 TL | 0.3s 8 kubectl --context minikube --namespace kyma-integration get deployment -o json + 251.5 TL | 0.6s 9 kubectl --context minikube --namespace kyma-integration get pod -o json --expo + 251.5 TL | 0.4s 10 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.9s 11 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.4s 12 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 3.2s 13 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.3s 14 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.3s 15 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 16 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.4s 17 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.4s 18 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.4s 19 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.3s 20 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.3s 21 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.5s 22 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 23 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.9s 24 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.3s 25 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.3s 26 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.3s 27 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 28 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 29 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 30 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 31 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 32 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.3s 33 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.3s 34 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.5s 35 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.4s 36 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.4s 37 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 38 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.3s 39 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 40 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 41 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 42 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 43 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 44 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 45 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 46 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.3s 47 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 48 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.3s 49 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.7s 50 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 51 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 52 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 53 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 54 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 55 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 56 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 57 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 58 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 59 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 60 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 61 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 62 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 63 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 64 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 65 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 66 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 67 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 68 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 69 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 70 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 71 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 72 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 73 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 74 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 75 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 76 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 77 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 78 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 79 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 80 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 81 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 82 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 83 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 84 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 85 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 86 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 87 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 88 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 89 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 90 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 91 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 92 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 93 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 94 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 95 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 96 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.2s 97 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 98 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 99 kubectl --context minikube --namespace kyma-integration get pod dj-test-gatew + 251.5 TL | 0.1s 100 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 101 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 102 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 103 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 104 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 105 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 106 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 107 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 108 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 109 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 110 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 111 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 112 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 113 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 114 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 115 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 116 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 117 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 118 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 119 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 120 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 121 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 122 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 123 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 124 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 125 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 126 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 127 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 128 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 129 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 130 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 131 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 132 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 133 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 134 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 135 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 136 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 137 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 138 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 139 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 140 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 141 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.3s 142 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 143 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 144 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 145 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 146 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.3s 147 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 148 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 149 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 150 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 151 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 152 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.6s 153 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.3s 154 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 155 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 156 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 157 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 158 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 159 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 160 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 161 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 162 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 163 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 164 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 165 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 166 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 167 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 168 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 169 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 170 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.3s 171 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 172 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 173 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 174 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 175 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 176 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 177 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.3s 178 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 179 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 180 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 181 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 182 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 183 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 184 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 185 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 186 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 187 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 188 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.4s 189 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 190 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 191 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 192 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 193 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 194 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 195 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 196 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 197 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 198 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 199 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 200 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 201 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 202 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 203 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 204 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 205 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 206 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 207 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 208 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 209 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 210 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 211 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 212 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 213 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 214 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 215 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 216 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 217 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 218 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 219 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 220 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 221 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 222 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 223 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 224 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 225 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 226 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 227 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 228 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 229 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 230 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 231 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 232 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 233 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 234 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 235 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 236 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 237 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 238 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.3s 239 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 240 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 241 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 242 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 243 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 244 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 245 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 246 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 247 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 248 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 249 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 250 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 251 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 252 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 253 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 254 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 255 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 256 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 257 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 258 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 259 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 260 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 261 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 262 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 263 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 264 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 265 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 266 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 267 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 268 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 269 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 270 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 271 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.1s 272 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 0.2s 273 kubectl --context minikube --namespace kyma-integration get pod dj-test-gate + 251.5 TL | 3.4s 274 kubectl --context minikube --namespace kyma-integration delete deployment dj + 251.5 TL | 0.2s 275 kubectl --context minikube --namespace kyma-integration apply -f - diff --git a/components/gateway/test/echo-service/README.md b/components/gateway/test/echo-service/README.md new file mode 100644 index 000000000000..530d28ccfa13 --- /dev/null +++ b/components/gateway/test/echo-service/README.md @@ -0,0 +1,145 @@ +# Echo Service + +This is internal tool implemented to test Wormhole connection - a simple HTTP service which echoes REST/HTTP requests back to user. + +It responds with data that was sent in a request and any additional information that user requested through special headers. + +## Building And Running Echo Service + +### Building Echo Service + +To build Echo Service you can use: + +```bash +go build -o echo echo.go logger.go types.go +``` + +### Starting Echo Service Locally + +The command to run Echo Service is: + +```bash +go run echo.go logger.go types.go +``` + +If you've built Echo Service in previous step you can also execute created binary: + +```bash +./echo +``` + +Echo Service can also be ran by using Makefile, to do so following command needs to be executed in root directory of the project: + +```bash +make start-echo-service +``` + +### Building And Publishing Docker Image + +Echo Service Docker image can be built using: +```bash +docker build -t eu.gcr.io/kyma-project/tools/echo-service:0.0.1 -f Dockerfile-echo . +``` +The above command should be executed from root project catalog. + +In order to publish it to repository following command needs to be executed: + +```bash +docker push eu.gcr.io/kyma-project/tools/echo-service:0.0.1 +``` + +### Running Dockerized Echo Service + +First of all, docker image needs to be pulled from repository: + +```bash +docker pull eu.gcr.io/kyma-project/tools/echo-service:0.0.1 +``` + +And then, it can be started: + +```bash +docker run eu.gcr.io/kyma-project/tools/echo-service:0.0.1 +``` + +### Changing Port Number + +Default port for Echo Service is 9000, to change it simply add --port='XXXX' flag. +For example: + +```bash +go run echo.go types.go logger.go --port="8080" +``` + + +## Using Echo Service + +Echo Service accepts special headers to modify it's response: + +### Requesting Response Headers + +Default headers returned in response are: +- Content-Length +- Content-Type +- Date + +For the test purposes, you can request to return the additional headers together with the echo response. + +In order to receive any additional headers in response you can apply header to your request: +``` +echo-header-yourHeaderName : yourHeaderValue +``` + +After echo-service receives header of this structure it will append response headers with: +``` +yourHeaderName : yourHeaderValue +``` + +### Requesting Response Status Code + +By default Echo Service responds with status code 200, for testing you can request exact status code to be returned with the response. + +In order to force Echo Service to respond with given status code you can apply a header to your request: +``` +echo-statuscode : requestedValue +``` + +### Response Structure + +Echo Service always responds with a JSON body of following structure: + +``` +{ + "path":"/path/that/was/requested", + "method":"GET/POST/PUT/...", + "headers:" { + "HeaderName":"HeaderValue", + "AnotherHeader":"AnotherValue", + }, + "body":"bodyThatWasSent" +} +``` + +### Example Request/Response + +Executing given request: + +```bash +curl -X POST -H "Content-Type: application/json" -d '{"testValue1":"xyz","testValue2":"xyz"}' http://localhost:9000/test +``` + +Will result in following response: + +```json +{ + "path":"/test", + "method":"POST", + "headers":{ + "Accept":["*/*"], + "Content-Length":["39"], + "Content-Type":["application/json"], + "User-Agent":["curl/7.54.0"] + }, + "body":"{\"testValue1\":\"xyz\",\"testValue2\":\"xyz\"}" +} +``` diff --git a/components/helm-broker/.gitignore b/components/helm-broker/.gitignore new file mode 100644 index 000000000000..76086ed42dd0 --- /dev/null +++ b/components/helm-broker/.gitignore @@ -0,0 +1,106 @@ +### LOCAL +# info.json is always generated by before-commit.sh +/info.json + +/.idea + +### macOS template +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/dictionaries + +# Sensitive or high-churn files: +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.xml +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml + +# Gradle: +.idea/**/gradle.xml +.idea/**/libraries + +# CMake +cmake-build-debug/ + +# Mongo Explorer plugin: +.idea/**/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties +### Go template +# Binaries for programs and plugins +*.exe +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 +.glide/ + +*.iml + +/targz +/reposerver +/indexbuilder +/broker +/vendor diff --git a/components/helm-broker/Gopkg.lock b/components/helm-broker/Gopkg.lock new file mode 100644 index 000000000000..41b71ddd9b18 --- /dev/null +++ b/components/helm-broker/Gopkg.lock @@ -0,0 +1,812 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/BurntSushi/toml" + packages = ["."] + revision = "b26d9c308763d68093482582cea63d69be07a0f0" + version = "v0.3.0" + +[[projects]] + name = "github.com/Masterminds/semver" + packages = ["."] + revision = "15d8430ab86497c5c0da827b748823945e1cf1e1" + version = "v1.4.0" + +[[projects]] + name = "github.com/Masterminds/sprig" + packages = ["."] + revision = "efda631a76d70875162cdc25ffa0d0164bf69758" + version = "v2.14.0" + +[[projects]] + branch = "master" + name = "github.com/alecthomas/jsonschema" + packages = ["."] + revision = "f2c93856175a7dd6abe88c5c3900b67ad054adcc" + +[[projects]] + name = "github.com/aokoli/goutils" + packages = ["."] + revision = "3391d3790d23d03408670993e957e8f408993c34" + version = "v1.0.1" + +[[projects]] + name = "github.com/asaskevich/govalidator" + packages = ["."] + revision = "73945b6115bfbbcc57d89b7316e28109364124e1" + version = "v7" + +[[projects]] + branch = "master" + name = "github.com/beorn7/perks" + packages = ["quantile"] + revision = "4c0e84591b9aa9e6dcfdf3e020114cd81f89d5f9" + +[[projects]] + name = "github.com/coreos/bbolt" + packages = ["."] + revision = "48ea1b39c25fc1bab3506fbc712ecbaa842c4d2d" + version = "v1.3.1-coreos.6" + +[[projects]] + branch = "release-3.3" + name = "github.com/coreos/etcd" + packages = [ + "alarm", + "auth", + "auth/authpb", + "client", + "clientv3", + "clientv3/concurrency", + "clientv3/namespace", + "clientv3/naming", + "compactor", + "discovery", + "embed", + "error", + "etcdserver", + "etcdserver/api", + "etcdserver/api/etcdhttp", + "etcdserver/api/v2http", + "etcdserver/api/v2http/httptypes", + "etcdserver/api/v2v3", + "etcdserver/api/v3client", + "etcdserver/api/v3election", + "etcdserver/api/v3election/v3electionpb", + "etcdserver/api/v3election/v3electionpb/gw", + "etcdserver/api/v3lock", + "etcdserver/api/v3lock/v3lockpb", + "etcdserver/api/v3lock/v3lockpb/gw", + "etcdserver/api/v3rpc", + "etcdserver/api/v3rpc/rpctypes", + "etcdserver/auth", + "etcdserver/etcdserverpb", + "etcdserver/etcdserverpb/gw", + "etcdserver/membership", + "etcdserver/stats", + "integration", + "lease", + "lease/leasehttp", + "lease/leasepb", + "mvcc", + "mvcc/backend", + "mvcc/mvccpb", + "pkg/adt", + "pkg/contention", + "pkg/cors", + "pkg/cpuutil", + "pkg/crc", + "pkg/debugutil", + "pkg/fileutil", + "pkg/httputil", + "pkg/idutil", + "pkg/ioutil", + "pkg/logutil", + "pkg/netutil", + "pkg/pathutil", + "pkg/pbutil", + "pkg/runtime", + "pkg/schedule", + "pkg/srv", + "pkg/testutil", + "pkg/tlsutil", + "pkg/transport", + "pkg/types", + "pkg/wait", + "proxy/grpcproxy", + "proxy/grpcproxy/adapter", + "proxy/grpcproxy/cache", + "raft", + "raft/raftpb", + "rafthttp", + "snap", + "snap/snappb", + "store", + "version", + "wal", + "wal/walpb" + ] + revision = "3282d9070759d8111465928dd9b36f0e401c9b13" + +[[projects]] + name = "github.com/coreos/go-semver" + packages = ["semver"] + revision = "8ab6407b697782a06568d4b7f1db25550ec2e4c6" + version = "v0.2.0" + +[[projects]] + name = "github.com/coreos/go-systemd" + packages = ["journal"] + revision = "d2196463941895ee908e13531a23a39feb9e1243" + version = "v15" + +[[projects]] + name = "github.com/coreos/pkg" + packages = ["capnslog"] + revision = "3ac0863d7acf3bc44daf49afef8919af12f704ef" + version = "v3" + +[[projects]] + name = "github.com/davecgh/go-spew" + packages = ["spew"] + revision = "346938d642f2ec3594ed81d874461961cd0faa76" + version = "v1.1.0" + +[[projects]] + name = "github.com/dgrijalva/jwt-go" + packages = ["."] + revision = "dbeaa9332f19a944acb5736b4456cfcc02140e29" + version = "v3.1.0" + +[[projects]] + branch = "master" + name = "github.com/dustin/go-humanize" + packages = ["."] + revision = "bb3d318650d48840a39aa21a027c6630e198e626" + +[[projects]] + name = "github.com/fatih/structs" + packages = ["."] + revision = "a720dfa8df582c51dee1b36feabb906bde1588bd" + version = "v1.0" + +[[projects]] + name = "github.com/ghodss/yaml" + packages = ["."] + revision = "0ca9ea5df5451ffdf184b4428c902747c2c11cd7" + version = "v1.0.0" + +[[projects]] + name = "github.com/gobwas/glob" + packages = [ + ".", + "compiler", + "match", + "syntax", + "syntax/ast", + "syntax/lexer", + "util/runes", + "util/strings" + ] + revision = "bea32b9cd2d6f55753d94a28e959b13f0244797a" + version = "v0.2.2" + +[[projects]] + name = "github.com/gogo/protobuf" + packages = [ + "gogoproto", + "proto", + "protoc-gen-gogo/descriptor", + "sortkeys" + ] + revision = "342cbe0a04158f6dcb03ca0079991a51a4248c02" + version = "v0.5" + +[[projects]] + branch = "master" + name = "github.com/golang/glog" + packages = ["."] + revision = "23def4e6c14b4da8ac2ed8007337bc5eb5007998" + +[[projects]] + branch = "master" + name = "github.com/golang/groupcache" + packages = ["lru"] + revision = "b710c8433bd175204919eb38776e944233235d03" + +[[projects]] + branch = "master" + name = "github.com/golang/lint" + packages = ["golint"] + revision = "1fb4e4796c08a9a18d8796a6aed584bf4b346bc9" + +[[projects]] + branch = "master" + name = "github.com/golang/protobuf" + packages = [ + "jsonpb", + "proto", + "ptypes", + "ptypes/any", + "ptypes/duration", + "ptypes/struct", + "ptypes/timestamp" + ] + revision = "130e6b02ab059e7b717a096f397c5b60111cae74" + +[[projects]] + branch = "master" + name = "github.com/google/btree" + packages = ["."] + revision = "316fb6d3f031ae8f4d457c6c5186b9e3ded70435" + +[[projects]] + branch = "master" + name = "github.com/google/gofuzz" + packages = ["."] + revision = "24818f796faf91cd76ec7bddd72458fbced7a6c1" + +[[projects]] + name = "github.com/googleapis/gnostic" + packages = [ + "OpenAPIv2", + "compiler", + "extensions" + ] + revision = "ee43cbb60db7bd22502942cccbc39059117352ab" + version = "v0.1.0" + +[[projects]] + name = "github.com/gorilla/context" + packages = ["."] + revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a" + version = "v1.1" + +[[projects]] + name = "github.com/gorilla/mux" + packages = ["."] + revision = "24fca303ac6da784b9e8269f724ddeb0b2eea5e7" + version = "v1.5.0" + +[[projects]] + name = "github.com/gorilla/websocket" + packages = ["."] + revision = "ea4d1f681babbce9545c9c5f3d5194a789c89f5b" + version = "v1.2.0" + +[[projects]] + name = "github.com/grpc-ecosystem/go-grpc-prometheus" + packages = ["."] + revision = "6b7015e65d366bf3f19b2b2a000a831940f0f7e0" + version = "v1.1" + +[[projects]] + name = "github.com/grpc-ecosystem/grpc-gateway" + packages = [ + "runtime", + "runtime/internal", + "utilities" + ] + revision = "07f5e79768022f9a3265235f0db4ac8c3f675fec" + version = "v1.3.1" + +[[projects]] + branch = "master" + name = "github.com/howeyc/gopass" + packages = ["."] + revision = "bf9dde6d0d2c004a008c27aaee91170c786f6db8" + +[[projects]] + branch = "master" + name = "github.com/huandu/xstrings" + packages = ["."] + revision = "d6590c0c31d16526217fa60fbd2067f7afcd78c5" + +[[projects]] + name = "github.com/imdario/mergo" + packages = ["."] + revision = "7fe0c75c13abdee74b09fcacef5ea1c6bba6a874" + version = "0.2.4" + +[[projects]] + name = "github.com/jonboulle/clockwork" + packages = ["."] + revision = "2eee05ed794112d45db504eb05aa693efd2b8b09" + version = "v0.1.0" + +[[projects]] + name = "github.com/json-iterator/go" + packages = ["."] + revision = "3353055b2a1a5ae1b6a8dfde887a524e7088f3a2" + version = "1.1.2" + +[[projects]] + name = "github.com/matttproud/golang_protobuf_extensions" + packages = ["pbutil"] + revision = "3247c84500bff8d9fb6d579d800f20b3e091582c" + version = "v1.0.0" + +[[projects]] + name = "github.com/mcuadros/go-defaults" + packages = ["."] + revision = "ac8540f0fc7e0fb5f1eb9e25c6fd0b8db8f97eed" + version = "v1.1.0" + +[[projects]] + branch = "master" + name = "github.com/meatballhat/negroni-logrus" + packages = ["."] + revision = "31067281800f66f57548a7a32d9c6c5f963fef83" + +[[projects]] + name = "github.com/modern-go/concurrent" + packages = ["."] + revision = "bacd9c7ef1dd9b15be4a9909b8ac7a4e313eec94" + version = "1.0.3" + +[[projects]] + name = "github.com/modern-go/reflect2" + packages = ["."] + revision = "1df9eeb2bb81f327b96228865c5687bc2194af3f" + version = "1.0.0" + +[[projects]] + name = "github.com/oklog/ulid" + packages = ["."] + revision = "d311cb43c92434ec4072dfbbda3400741d0a6337" + version = "v0.3.0" + +[[projects]] + name = "github.com/pborman/uuid" + packages = ["."] + revision = "e790cca94e6cc75c7064b1332e63811d4aae1a53" + version = "v1.1" + +[[projects]] + branch = "master" + name = "github.com/petar/GoLLRB" + packages = ["llrb"] + revision = "53be0d36a84c2a886ca057d34b6aa4468df9ccb4" + +[[projects]] + name = "github.com/pkg/errors" + packages = ["."] + revision = "645ef00459ed84a119197bfb8d8205042c6df63d" + version = "v0.8.0" + +[[projects]] + name = "github.com/pmezard/go-difflib" + packages = ["difflib"] + revision = "792786c7400a136282c1664665ae0a8db921c6c2" + version = "v1.0.0" + +[[projects]] + name = "github.com/pmorie/go-open-service-broker-client" + packages = ["v2"] + revision = "31d8027f493f8f23f850415d171c7c52a972a6f2" + +[[projects]] + name = "github.com/prometheus/client_golang" + packages = [ + "prometheus", + "prometheus/promhttp" + ] + revision = "c5b7fccd204277076155f10851dad72b76a49317" + version = "v0.8.0" + +[[projects]] + branch = "master" + name = "github.com/prometheus/client_model" + packages = ["go"] + revision = "6f3806018612930941127f2a7c6c453ba2c527d2" + +[[projects]] + branch = "master" + name = "github.com/prometheus/common" + packages = [ + "expfmt", + "internal/bitbucket.org/ww/goautoneg", + "model" + ] + revision = "1bab55dd05dbff384524a6a1c99006d9eb5f139b" + +[[projects]] + branch = "master" + name = "github.com/prometheus/procfs" + packages = [ + ".", + "xfs" + ] + revision = "a6e9df898b1336106c743392c48ee0b71f5c4efa" + +[[projects]] + name = "github.com/renstrom/dedent" + packages = ["."] + revision = "a1eba44eaecc89804e4b05ce2d17168eb353d524" + version = "v1.0.0" + +[[projects]] + name = "github.com/satori/go.uuid" + packages = ["."] + revision = "879c5887cd475cd7864858769793b2ceb0d44feb" + version = "v1.1.0" + +[[projects]] + name = "github.com/sirupsen/logrus" + packages = ["."] + revision = "f006c2ac4710855cf0f916dd6b77acf6b048dc6e" + version = "v1.0.3" + +[[projects]] + name = "github.com/soheilhy/cmux" + packages = ["."] + revision = "e09e9389d85d8492d313d73d1469c029e710623f" + version = "v0.1.4" + +[[projects]] + name = "github.com/spf13/pflag" + packages = ["."] + revision = "e57e3eeb33f795204c1ca35f56c44f83227c6e66" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "github.com/stretchr/objx" + packages = ["."] + revision = "1a9d0bb9f541897e62256577b352fdbc1fb4fd94" + +[[projects]] + name = "github.com/stretchr/testify" + packages = [ + "assert", + "mock", + "require" + ] + revision = "69483b4bd14f5845b5a1e55bca19e954e827f1d0" + version = "v1.1.4" + +[[projects]] + branch = "master" + name = "github.com/tmc/grpc-websocket-proxy" + packages = ["wsproxy"] + revision = "830351dc03c6f07d625727d5f993a463babb20e1" + +[[projects]] + name = "github.com/ugorji/go" + packages = ["codec"] + revision = "bdcc60b419d136a85cdf2e7cbcac34b3f1cd6e57" + +[[projects]] + branch = "master" + name = "github.com/urfave/negroni" + packages = ["."] + revision = "079cf0c7a6b0145ebcf4103ec90380370cd89917" + +[[projects]] + branch = "master" + name = "github.com/vrischmann/envconfig" + packages = ["."] + revision = "757beaaeac8d14bcc7ea3f71488d65cf45cf2eff" + +[[projects]] + name = "github.com/xiang90/probing" + packages = ["."] + revision = "07dd2e8dfe18522e9c447ba95f2fe95262f63bb2" + version = "0.0.1" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = [ + "bcrypt", + "blowfish", + "pbkdf2", + "scrypt", + "ssh/terminal" + ] + revision = "9419663f5a44be8b34ca85f08abc5fe1be11f8a3" + +[[projects]] + branch = "master" + name = "golang.org/x/lint" + packages = ["."] + revision = "1fb4e4796c08a9a18d8796a6aed584bf4b346bc9" + +[[projects]] + branch = "master" + name = "golang.org/x/net" + packages = [ + "context", + "http2", + "http2/hpack", + "idna", + "internal/timeseries", + "lex/httplex", + "trace" + ] + revision = "1087133bc4af3073e18add999345c6ae75918503" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = [ + "unix", + "windows" + ] + revision = "8dbc5d05d6edcc104950cc299a1ce6641235bc86" + +[[projects]] + branch = "master" + name = "golang.org/x/text" + packages = [ + "collate", + "collate/build", + "internal/colltab", + "internal/gen", + "internal/tag", + "internal/triegen", + "internal/ucd", + "language", + "secure/bidirule", + "transform", + "unicode/bidi", + "unicode/cldr", + "unicode/norm", + "unicode/rangetable" + ] + revision = "c01e4764d870b77f8abe5096ee19ad20d80e8075" + +[[projects]] + branch = "master" + name = "golang.org/x/time" + packages = ["rate"] + revision = "6dc17368e09b0e8634d71cac8168d853e869a0c7" + +[[projects]] + branch = "master" + name = "golang.org/x/tools" + packages = [ + "cmd/goimports", + "go/ast/astutil", + "go/gcexportdata", + "go/internal/gcimporter", + "go/types/typeutil", + "imports", + "internal/fastwalk" + ] + revision = "c1def519f03ddf76f16b3e444ee1095d73afa01b" + +[[projects]] + branch = "master" + name = "google.golang.org/genproto" + packages = ["googleapis/rpc/status"] + revision = "f676e0f3ac6395ff1a529ae59a6670878a8371a6" + +[[projects]] + branch = "v1.7.x" + name = "google.golang.org/grpc" + packages = [ + ".", + "balancer", + "codes", + "connectivity", + "credentials", + "grpclb/grpc_lb_v1/messages", + "grpclog", + "health", + "health/grpc_health_v1", + "internal", + "keepalive", + "metadata", + "naming", + "peer", + "resolver", + "stats", + "status", + "tap", + "transport" + ] + revision = "fb57512bcf559adb14c19326b46a54cf0bbdefb0" + +[[projects]] + name = "gopkg.in/inf.v0" + packages = ["."] + revision = "3887ee99ecf07df5b447e9b00d9c0b2adaa9f3e4" + version = "v0.9.0" + +[[projects]] + branch = "v2" + name = "gopkg.in/yaml.v2" + packages = ["."] + revision = "eb3733d160e74a9c7e442f435eb3bea458e1d19f" + +[[projects]] + name = "k8s.io/api" + packages = [ + "admissionregistration/v1alpha1", + "admissionregistration/v1beta1", + "apps/v1", + "apps/v1beta1", + "apps/v1beta2", + "authentication/v1", + "authentication/v1beta1", + "authorization/v1", + "authorization/v1beta1", + "autoscaling/v1", + "autoscaling/v2beta1", + "batch/v1", + "batch/v1beta1", + "batch/v2alpha1", + "certificates/v1beta1", + "core/v1", + "events/v1beta1", + "extensions/v1beta1", + "networking/v1", + "policy/v1beta1", + "rbac/v1", + "rbac/v1alpha1", + "rbac/v1beta1", + "scheduling/v1alpha1", + "settings/v1alpha1", + "storage/v1", + "storage/v1alpha1", + "storage/v1beta1" + ] + revision = "73d903622b7391f3312dcbac6483fed484e185f8" + version = "kubernetes-1.10.0" + +[[projects]] + name = "k8s.io/apimachinery" + packages = [ + "pkg/api/errors", + "pkg/api/meta", + "pkg/api/resource", + "pkg/apis/meta/v1", + "pkg/apis/meta/v1/unstructured", + "pkg/apis/meta/v1beta1", + "pkg/conversion", + "pkg/conversion/queryparams", + "pkg/fields", + "pkg/labels", + "pkg/runtime", + "pkg/runtime/schema", + "pkg/runtime/serializer", + "pkg/runtime/serializer/json", + "pkg/runtime/serializer/protobuf", + "pkg/runtime/serializer/recognizer", + "pkg/runtime/serializer/streaming", + "pkg/runtime/serializer/versioning", + "pkg/selection", + "pkg/types", + "pkg/util/clock", + "pkg/util/errors", + "pkg/util/framer", + "pkg/util/intstr", + "pkg/util/json", + "pkg/util/net", + "pkg/util/runtime", + "pkg/util/sets", + "pkg/util/validation", + "pkg/util/validation/field", + "pkg/util/wait", + "pkg/util/yaml", + "pkg/version", + "pkg/watch", + "third_party/forked/golang/reflect" + ] + revision = "302974c03f7e50f16561ba237db776ab93594ef6" + version = "kubernetes-1.10.0" + +[[projects]] + name = "k8s.io/client-go" + packages = [ + "discovery", + "discovery/fake", + "kubernetes", + "kubernetes/fake", + "kubernetes/scheme", + "kubernetes/typed/admissionregistration/v1alpha1", + "kubernetes/typed/admissionregistration/v1alpha1/fake", + "kubernetes/typed/admissionregistration/v1beta1", + "kubernetes/typed/admissionregistration/v1beta1/fake", + "kubernetes/typed/apps/v1", + "kubernetes/typed/apps/v1/fake", + "kubernetes/typed/apps/v1beta1", + "kubernetes/typed/apps/v1beta1/fake", + "kubernetes/typed/apps/v1beta2", + "kubernetes/typed/apps/v1beta2/fake", + "kubernetes/typed/authentication/v1", + "kubernetes/typed/authentication/v1/fake", + "kubernetes/typed/authentication/v1beta1", + "kubernetes/typed/authentication/v1beta1/fake", + "kubernetes/typed/authorization/v1", + "kubernetes/typed/authorization/v1/fake", + "kubernetes/typed/authorization/v1beta1", + "kubernetes/typed/authorization/v1beta1/fake", + "kubernetes/typed/autoscaling/v1", + "kubernetes/typed/autoscaling/v1/fake", + "kubernetes/typed/autoscaling/v2beta1", + "kubernetes/typed/autoscaling/v2beta1/fake", + "kubernetes/typed/batch/v1", + "kubernetes/typed/batch/v1/fake", + "kubernetes/typed/batch/v1beta1", + "kubernetes/typed/batch/v1beta1/fake", + "kubernetes/typed/batch/v2alpha1", + "kubernetes/typed/batch/v2alpha1/fake", + "kubernetes/typed/certificates/v1beta1", + "kubernetes/typed/certificates/v1beta1/fake", + "kubernetes/typed/core/v1", + "kubernetes/typed/core/v1/fake", + "kubernetes/typed/events/v1beta1", + "kubernetes/typed/events/v1beta1/fake", + "kubernetes/typed/extensions/v1beta1", + "kubernetes/typed/extensions/v1beta1/fake", + "kubernetes/typed/networking/v1", + "kubernetes/typed/networking/v1/fake", + "kubernetes/typed/policy/v1beta1", + "kubernetes/typed/policy/v1beta1/fake", + "kubernetes/typed/rbac/v1", + "kubernetes/typed/rbac/v1/fake", + "kubernetes/typed/rbac/v1alpha1", + "kubernetes/typed/rbac/v1alpha1/fake", + "kubernetes/typed/rbac/v1beta1", + "kubernetes/typed/rbac/v1beta1/fake", + "kubernetes/typed/scheduling/v1alpha1", + "kubernetes/typed/scheduling/v1alpha1/fake", + "kubernetes/typed/settings/v1alpha1", + "kubernetes/typed/settings/v1alpha1/fake", + "kubernetes/typed/storage/v1", + "kubernetes/typed/storage/v1/fake", + "kubernetes/typed/storage/v1alpha1", + "kubernetes/typed/storage/v1alpha1/fake", + "kubernetes/typed/storage/v1beta1", + "kubernetes/typed/storage/v1beta1/fake", + "pkg/apis/clientauthentication", + "pkg/apis/clientauthentication/v1alpha1", + "pkg/version", + "plugin/pkg/client/auth/exec", + "rest", + "rest/watch", + "testing", + "third_party/forked/golang/template", + "tools/auth", + "tools/clientcmd", + "tools/clientcmd/api", + "tools/clientcmd/api/latest", + "tools/clientcmd/api/v1", + "tools/metrics", + "tools/reference", + "transport", + "util/cert", + "util/flowcontrol", + "util/homedir", + "util/integer", + "util/jsonpath" + ] + revision = "989be4278f353e42f26c416c53757d16fcff77db" + version = "kubernetes-1.10.1" + +[[projects]] + name = "k8s.io/helm" + packages = [ + "pkg/chartutil", + "pkg/engine", + "pkg/helm", + "pkg/ignore", + "pkg/proto/hapi/chart", + "pkg/proto/hapi/release", + "pkg/proto/hapi/services", + "pkg/proto/hapi/version", + "pkg/sympath", + "pkg/version" + ] + revision = "a80231648a1473929271764b920a8e346f6de844" + version = "v2.8.2" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "cc0653fd6ecb4b6e739a06c268459476200bbfaa824d56c19c0caa34f2dfc79a" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/components/helm-broker/Gopkg.toml b/components/helm-broker/Gopkg.toml new file mode 100644 index 000000000000..fdde9f3bfbcf --- /dev/null +++ b/components/helm-broker/Gopkg.toml @@ -0,0 +1,91 @@ + +# Gopkg.toml example +# +# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" + +required= [ + "google.golang.org/grpc", + "github.com/ugorji/go/codec", + "github.com/golang/lint/golint", + "golang.org/x/tools/cmd/goimports" + ] + +[prune] + unused-packages = true + go-tests = true + +[[constraint]] + name = "github.com/ugorji/go" + revision = "bdcc60b419d136a85cdf2e7cbcac34b3f1cd6e57" + +[[constraint]] + name = "google.golang.org/grpc" + branch = "v1.7.x" + +[[constraint]] + name = "github.com/coreos/etcd" + branch = "release-3.3" + +[[constraint]] + name = "github.com/Masterminds/semver" + version = "1.4.0" + +[[constraint]] + name = "k8s.io/apimachinery" + version = "kubernetes-1.10.1" + +[[constraint]] + name = "k8s.io/api" + version = "kubernetes-1.10.1" + +[[constraint]] + name = "k8s.io/client-go" + version = "kubernetes-1.10.1" + +[[constraint]] + # latests release was 0.2.0 and we are depending on functionality added later + name = "github.com/urfave/negroni" + branch = "master" + +[[constraint]] + name = "k8s.io/helm" + version = "2.8.2" + +# etcd(release-3.3) has dependency to bbolt in this version: +# see https://github.com/coreos/etcd/blob/release-3.3/glide.yaml +[[override]] + name = "github.com/coreos/bbolt" + version = "v1.3.1-coreos.6" + +[[override]] + name = "k8s.io/api" + version = "kubernetes-1.10.1" + +[[override]] + name = "k8s.io/apimachinery" + version = "kubernetes-1.10.1" + +[[override]] + name = "k8s.io/client-go" + version = "kubernetes-1.10.1" + +[[override]] + name = "k8s.io/code-generator" + version = "kubernetes-1.10.1" diff --git a/components/helm-broker/Jenkinsfile b/components/helm-broker/Jenkinsfile new file mode 100644 index 000000000000..5b22d98dd9dc --- /dev/null +++ b/components/helm-broker/Jenkinsfile @@ -0,0 +1,99 @@ +#!/usr/bin/env groovy + +def label = "kyma-${UUID.randomUUID().toString()}" +def application = "helm-broker" + +def isMaster = params.GIT_BRANCH == 'master' + +def dockerPushRoot = isMaster + ? "${env.DOCKER_REGISTRY}" + : "${env.DOCKER_REGISTRY}snapshot/" + +def dockerImageTag = isMaster + ? params.APP_VERSION + : params.GIT_BRANCH + +echo """ +******************************** +Job started with the following parameters: +DOCKER_REGISTRY=${env.DOCKER_REGISTRY} +DOCKER_CREDENTIALS=${env.DOCKER_CREDENTIALS} +GIT_REVISION=${params.GIT_REVISION} +GIT_BRANCH=${params.GIT_BRANCH} +APP_VERSION=${params.APP_VERSION} +APP_FOLDER=${env.APP_FOLDER} +******************************** +""" + +podTemplate(label: label) { + node(label) { + try { + timestamps { + ansiColor('xterm') { + timeout(time: 20, unit: "MINUTES") { + stage("setup") { + checkout scm + + if(dockerImageTag == ""){ + error("No version for docker tag defined, please set APP_VERSION parameter for master branch or GIT_BRANCH parameter for any branch") + } + + withCredentials([usernamePassword(credentialsId: env.DOCKER_CREDENTIALS, passwordVariable: 'pwd', usernameVariable: 'uname')]) { + sh "docker login -u $uname -p '$pwd' $env.DOCKER_REGISTRY" + } + } + + stage("build and test $application") { + execute "./before-commit.sh ci" + } + + if (isMaster) { + stage("IP scan $application (Sourceclear)"){ + withCredentials([string(credentialsId: 'SRCCLR_API_TOKEN', variable: 'SRCCLR_API_TOKEN')]) { + execute("make scan","SRCCLR_API_TOKEN=$SRCCLR_API_TOKEN") + } + } + } + + stage("build image $application") { + dir(env.APP_FOLDER){ + sh "cp ./broker deploy/broker/helm-broker" + sh "cp ./reposerver deploy/reposerver/reposerver" + sh "cp ./indexbuilder deploy/tools/indexbuilder" + sh "cp ./targz deploy/tools/targz" + + sh "docker build -t helm-broker:latest deploy/broker" + sh "docker build -t helm-broker-reposerver:latest deploy/reposerver" + sh "docker build -t helm-broker-tools:latest deploy/tools" + } + } + + stage("push image $application") { + sh "docker tag helm-broker:latest ${dockerPushRoot}helm-broker:${dockerImageTag}" + sh "docker push ${dockerPushRoot}helm-broker:${dockerImageTag}" + + sh "docker tag helm-broker-reposerver:latest ${dockerPushRoot}helm-broker-reposerver:${dockerImageTag}" + sh "docker push ${dockerPushRoot}helm-broker-reposerver:${dockerImageTag}" + + sh "docker tag helm-broker-tools:latest ${dockerPushRoot}helm-broker-tools:${dockerImageTag}" + sh "docker push ${dockerPushRoot}helm-broker-tools:${dockerImageTag}" + + } + } + } + } + } catch (ex) { + echo "Got exception: ${ex}" + currentBuild.result = "FAILURE" + def body = "${currentBuild.currentResult} ${env.JOB_NAME}${env.BUILD_DISPLAY_NAME}: on branch: ${params.GIT_BRANCH}. See details: ${env.BUILD_URL}" + emailext body: body, recipientProviders: [[$class: 'DevelopersRecipientProvider'], [$class: 'CulpritsRecipientProvider'], [$class: 'RequesterRecipientProvider']], subject: "${currentBuild.currentResult}: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'" + } + } +} + +def execute(command, envs = '') { + def buildpack = 'golang-buildpack:0.0.8' + def envText = envs=='' ? '' : "--env $envs" + workDir = pwd() + sh "docker run --rm -v $workDir:/go/src/github.com/kyma-project/kyma/ -w /go/src/github.com/kyma-project/kyma/$env.APP_FOLDER $envText ${env.DOCKER_REGISTRY}$buildpack /bin/bash -c '$command'" +} diff --git a/components/helm-broker/README.md b/components/helm-broker/README.md new file mode 100644 index 000000000000..05200dd1d1e0 --- /dev/null +++ b/components/helm-broker/README.md @@ -0,0 +1,20 @@ +# Helm Broker + +## Overview + +The Helm Broker is an implementation of a service broker which runs on the Kyma cluster and deploys applications into the Kubernetes cluster using Kyma bundles, and the [Helm](https://github.com/kubernetes/helm) client. A bundle is an abstraction layer over a Helm chart which allows you to represent it as a ClusterServiceClass in the Service Catalog. For example, a bundle can provide plan definitions or binding details. The Helm Broker fetches bundle definitions from an HTTP server. By default, the Helm Broker contains an embedded HTTP server which serves bundles from the Kyma bundles directory. + +For the details about Helm Broker configuration, see [this](https://github.com/kyma-project/kyma/blob/master/docs/service-brokers/docs/011-configuration-helm-broker.md) document. See [How to create a yBundle](https://github.com/kyma-project/kyma/blob/master/docs/service-brokers/docs/012-configuration-helm-broker-bundles.md) and [Binding yBundles](https://github.com/kyma-project/kyma/blob/master/docs/service-brokers/docs/013-configuration-helm-broker-bundles-binding.md) to learn more about the bundles. +The Helm Broker implements the Service Broker API. For more information about the Service Brokers, see the [Service Brokers](https://github.com/kyma-project/kyma/blob/master/docs/service-brokers/docs/001-overview-service-brokers.md) overview document. + +## Prerequisites + +You need the following tools to set up the project: +* The 1.9 or higher version of [Go](https://golang.org/dl/) +* The latest version of [Docker](https://www.docker.com/) +* The latest version of [Dep](https://github.com/golang/dep) + + +## Development + +Before each commit, use the `before-commit.sh` script, which tests your changes. diff --git a/components/helm-broker/before-commit.sh b/components/helm-broker/before-commit.sh new file mode 100755 index 000000000000..45ec7f1de45d --- /dev/null +++ b/components/helm-broker/before-commit.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash + +readonly CI_FLAG=ci + +RED='\033[0;31m' +GREEN='\033[0;32m' +INVERTED='\033[7m' +NC='\033[0m' # No Color + +echo -e "${INVERTED}" +echo "USER: " + $USER +echo "PATH: " + $PATH +echo "GOPATH:" + $GOPATH +echo -e "${NC}" + +## +# DEP ENSURE +## +dep ensure -v --vendor-only +ensureResult=$? +if [ ${ensureResult} != 0 ]; then + echo -e "${RED}✗ dep ensure -v --vendor-only${NC}\n$ensureResult${NC}" + exit 1 +else echo -e "${GREEN}√ dep ensure -v --vendor-only${NC}" +fi + +## +# GO BUILD +## +binaries=("broker" "indexbuilder" "reposerver" "targz") +buildEnv="" +if [ "$1" == "$CI_FLAG" ]; then + # build binary statically + buildEnv="env CGO_ENABLED=0" +fi + +for binary in "${binaries[@]}"; do + ${buildEnv} go build -o ${binary} ./cmd/${binary} + goBuildResult=$? + if [ ${goBuildResult} != 0 ]; then + echo -e "${RED}✗ go build ${binary} ${NC}\n$goBuildResult${NC}" + exit 1 + else echo -e "${GREEN}√ go build ${binary} ${NC}" + fi +done + +## +# DEP STATUS +## +echo "? dep status" +depResult=$(dep status -v) +if [ $? != 0 ]; then + echo -e "${RED}✗ dep status\n$depResult${NC}" + exit 1 +else echo -e "${GREEN}√ dep status${NC}" +fi + +## +# GO TEST +## +echo "? go test" +go test ./... +# Check if tests passed +if [ $? != 0 ]; then + echo -e "${RED}✗ go test\n${NC}" + exit 1 +else echo -e "${GREEN}√ go test${NC}" +fi + +goFilesToCheck=$(find . -type f -name "*.go" | egrep -v "\/vendor\/|_*/automock/|_*/testdata/|/pkg\/|_*export_test.go") + +## +# GO LINT +## +go build -o golint-vendored ./vendor/github.com/golang/lint/golint +buildLintResult=$? +if [ ${buildLintResult} != 0 ]; then + echo -e "${RED}✗ go build lint${NC}\n$buildLintResult${NC}" + exit 1 +fi + +golintResult=$(echo "${goFilesToCheck}" | xargs -L1 ./golint-vendored) +rm golint-vendored + +if [ $(echo ${#golintResult}) != 0 ]; then + echo -e "${RED}✗ golint\n$golintResult${NC}" + exit 1 +else echo -e "${GREEN}√ golint${NC}" +fi + +## +# GO IMPORTS & FMT +## +go build -o goimports-vendored ./vendor/golang.org/x/tools/cmd/goimports +buildGoImportResult=$? +if [ ${buildGoImportResult} != 0 ]; then + echo -e "${RED}✗ go build goimports${NC}\n$buildGoImportResult${NC}" + exit 1 +fi + +goImportsResult=$(echo "${goFilesToCheck}" | xargs -L1 ./goimports-vendored -w -l) +rm goimports-vendored + +if [ $(echo ${#goImportsResult}) != 0 ]; then + echo -e "${RED}✗ goimports and fmt${NC}\n$goImportsResult${NC}" + exit 1 +else echo -e "${GREEN}√ goimports and fmt${NC}" +fi + +## +# GO VET +## +packagesToVet=("./cmd/..." "./internal/...") + +for vPackage in "${packagesToVet[@]}"; do + vetResult=$(go vet ${vPackage}) + if [ $(echo ${#vetResult}) != 0 ]; then + echo -e "${RED}✗ go vet ${vPackage} ${NC}\n$vetResult${NC}" + exit 1 + else echo -e "${GREEN}√ go vet ${vPackage} ${NC}" + fi +done \ No newline at end of file diff --git a/components/helm-broker/cmd/broker/main.go b/components/helm-broker/cmd/broker/main.go new file mode 100644 index 000000000000..42642a81c4f6 --- /dev/null +++ b/components/helm-broker/cmd/broker/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/kyma-project/kyma/components/helm-broker/internal/broker" + "github.com/kyma-project/kyma/components/helm-broker/internal/config" + "github.com/kyma-project/kyma/components/helm-broker/internal/helm" + "github.com/kyma-project/kyma/components/helm-broker/internal/storage" + "github.com/kyma-project/kyma/components/helm-broker/internal/ybind" + "github.com/kyma-project/kyma/components/helm-broker/internal/ybundle" + "github.com/kyma-project/kyma/components/helm-broker/platform/logger" + "github.com/sirupsen/logrus" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +func main() { + verbose := flag.Bool("verbose", false, "specify if log verbosely loading configuration") + flag.Parse() + cfg, err := config.Load(*verbose) + fatalOnError(err) + + // creates the in-cluster k8sConfig + k8sConfig, err := rest.InClusterConfig() + if err != nil { + panic(err.Error()) + } + // creates the clientset + clientset, err := kubernetes.NewForConfig(k8sConfig) + if err != nil { + panic(err.Error()) + } + + log := logger.New(&cfg.Logger) + + ybLoader := ybundle.NewLoader(cfg.TmpDir, log) + + storageConfig := storage.ConfigList(cfg.Storage) + sFact, err := storage.NewFactory(&storageConfig) + fatalOnError(err) + + cachePop := ybundle.NewPopulator( + ybundle.NewHTTPRepository(cfg.Repository), + ybLoader, sFact.Bundle(), sFact.Chart(), log, + ) + err = cachePop.Init() + fatalOnError(err) + + helmClient := helm.NewClient(cfg.Helm, log) + + srv := broker.New(sFact.Bundle(), sFact.Chart(), sFact.InstanceOperation(), sFact.Instance(), sFact.InstanceBindData(), + ybind.NewRenderer(), ybind.NewResolver(clientset.CoreV1()), helmClient, log) + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + cancelOnInterrupt(ctx, cancelFunc) + err = srv.Run(ctx, fmt.Sprintf(":%d", cfg.Port)) + fatalOnError(err) +} + +func fatalOnError(err error) { + if err != nil { + logrus.Fatal(err.Error()) + } +} + +// cancelOnInterrupt calls cancel func when os.Interrupt or SIGTERM is received +func cancelOnInterrupt(ctx context.Context, cancel context.CancelFunc) { + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + go func() { + select { + case <-ctx.Done(): + case <-c: + cancel() + } + }() +} diff --git a/components/helm-broker/cmd/checker/main.go b/components/helm-broker/cmd/checker/main.go new file mode 100644 index 000000000000..3a3d2a265c5c --- /dev/null +++ b/components/helm-broker/cmd/checker/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "fmt" + "io/ioutil" + "os" + + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/kyma-project/kyma/components/helm-broker/internal/ybundle" + "github.com/sirupsen/logrus" +) + +func main() { + logger := logrus.New() + logger.Formatter = &logrus.TextFormatter{ + FullTimestamp: true, + } + + dir, err := ioutil.TempDir("", "bundlechecker") + if err != nil { + panic(err) + } + loader := ybundle.NewLoader(dir, logger.WithField("service", "bundle checker")) + + if len(os.Args) < 2 { + fmt.Println("Provide path to a bundle") + return + } + + bundle, _, err := loader.LoadDir(os.Args[1]) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + printBundleInfo(bundle) +} + +func printBundleInfo(b *internal.Bundle) { + fmt.Printf("Bundle name: %s\n", b.Name) + fmt.Printf("Version: %s\n", b.Version.String()) + fmt.Printf("Description: %s\n", b.Description) +} diff --git a/components/helm-broker/cmd/indexbuilder/index.go b/components/helm-broker/cmd/indexbuilder/index.go new file mode 100644 index 000000000000..9688c35e6d14 --- /dev/null +++ b/components/helm-broker/cmd/indexbuilder/index.go @@ -0,0 +1,48 @@ +package main + +import ( + "io" + + "github.com/ghodss/yaml" + "github.com/pkg/errors" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +type index struct { + APIVersion string `json:"apiVersion"` + Entries map[internal.BundleName][]indexEntry `json:"entries"` +} + +type indexEntry struct { + Name internal.BundleName `json:"name"` + Description string `json:"description"` + Version string `json:"version"` +} + +func render(in []*internal.Bundle, w io.Writer) error { + dto := &index{ + APIVersion: "v1", + Entries: make(map[internal.BundleName][]indexEntry), + } + + for _, b := range in { + e := indexEntry{ + Name: b.Name, + Description: b.Description, + Version: b.Version.String(), + } + dto.Entries[b.Name] = append(dto.Entries[b.Name], e) + } + + entEnc, err := yaml.Marshal(dto) + if err != nil { + return errors.Wrap(err, "while encoding to YAML") + } + + if _, err := w.Write(entEnc); err != nil { + return errors.Wrap(err, "while writing encoded index") + } + + return nil +} diff --git a/components/helm-broker/cmd/indexbuilder/index_test.go b/components/helm-broker/cmd/indexbuilder/index_test.go new file mode 100644 index 000000000000..5c4458b3a190 --- /dev/null +++ b/components/helm-broker/cmd/indexbuilder/index_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "bytes" + "testing" + + "github.com/Masterminds/semver" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +func TestYAMLRender_Render(t *testing.T) { + // GIVEN + fix := []*internal.Bundle{ + {Name: "A", Description: "Desc A 1", Version: *semver.MustParse("1.2.3")}, + {Name: "A", Description: "Desc A 2", Version: *semver.MustParse("1.2.4")}, + {Name: "B", Description: "Desc B", Version: *semver.MustParse("4.5.6")}, + } + + // YAML imposes order on serialisation which in our implementation uses asc ordering by key as string. + // It allows us to do simple text comparision on result even with maps. + exp := `apiVersion: v1 +entries: + A: + - description: Desc A 1 + name: A + version: 1.2.3 + - description: Desc A 2 + name: A + version: 1.2.4 + B: + - description: Desc B + name: B + version: 4.5.6 +` + + buf := &bytes.Buffer{} + + // WHEN + require.NoError(t, render(fix, buf)) + + // THEN + assert.Equal(t, exp, string(buf.String())) +} diff --git a/components/helm-broker/cmd/indexbuilder/main.go b/components/helm-broker/cmd/indexbuilder/main.go new file mode 100644 index 000000000000..e5e2bbdfd36f --- /dev/null +++ b/components/helm-broker/cmd/indexbuilder/main.go @@ -0,0 +1,88 @@ +package main + +import ( + "flag" + "io" + "io/ioutil" + "os" + "path" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/kyma-project/kyma/components/helm-broker/internal/ybundle" +) + +func main() { + srcDir := flag.String("s", "", "Source directory with repository.") + dstDir := flag.String("d", "", "Destination directory where index.yaml is saved. If not set than file is printed to stdout.") + flag.Parse() + + l := logrus.New() + l.Formatter = &logrus.TextFormatter{ + FullTimestamp: true, + } + + if srcDir == nil { + l.Panicln("src dir must be defined") + } + + srcObjs, err := ioutil.ReadDir(*srcDir) + if err != nil { + l.Panicln(errors.Wrap(err, "while listing source dir")) + } + + var loader *ybundle.Loader + yLog := l.WithField("service", "bundle checker") + + removeTempDir := func(path string) { + if err := os.RemoveAll(path); err != nil { + l.Panicln(errors.Wrap(err, "while removing temp dir")) + } + } + + processSingleBundle := func(fullPath string) *internal.Bundle { + tmpDir, err := ioutil.TempDir("", "indexbuilder") + if err != nil { + l.Panicln(errors.Wrap(err, "while creating temp dir")) + } + defer removeTempDir(tmpDir) + + loader = ybundle.NewLoader(tmpDir, yLog) + b, _, err := loader.LoadDir(fullPath) + if err != nil { + l.Panicln(errors.Wrap(err, "while loading bundle")) + } + + return b + } + + var allBundles []*internal.Bundle + + for _, oi := range srcObjs { + if !oi.IsDir() { + continue + } + + fPath := path.Join(*srcDir, oi.Name()) + b := processSingleBundle(fPath) + allBundles = append(allBundles, b) + } + + var dst io.Writer + if dstDir == nil || *dstDir == "" { + dst = os.Stdout + } else { + fullFilename := path.Join(*dstDir, "index.yaml") + f, err := os.OpenFile(fullFilename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + l.Panicln(errors.Wrapf(err, "while opening file to write (file: %s)", fullFilename)) + } + dst = f + } + + if err := render(allBundles, dst); err != nil { + l.Panicln(errors.Wrap(err, "while rendering index")) + } +} diff --git a/components/helm-broker/cmd/reposerver/README.md b/components/helm-broker/cmd/reposerver/README.md new file mode 100644 index 000000000000..eb363b943928 --- /dev/null +++ b/components/helm-broker/cmd/reposerver/README.md @@ -0,0 +1,7 @@ +# Reposerver +Reposerver is a simple HTTP server, which provides bundles. + +## Usage +Reposerver requires two environment variables: + * PORT - the HTTP port + * CONTENT_PATH - path to the folder, which contains bundles and `index.yaml` file \ No newline at end of file diff --git a/components/helm-broker/cmd/reposerver/main.go b/components/helm-broker/cmd/reposerver/main.go new file mode 100644 index 000000000000..a5fc76da7ede --- /dev/null +++ b/components/helm-broker/cmd/reposerver/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "net/http" + "os" + "path/filepath" + + "github.com/urfave/negroni" +) + +var contentTypes = map[string]string{ + ".tgz": "application/gzip", + ".yaml": "text/plain", // text/plain allows to see the content in the browser. +} + +func filteringMiddleware(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + if r.URL.Path != "/index.yaml" && filepath.Ext(r.URL.Path) != ".tgz" { + rw.WriteHeader(http.StatusNotFound) + return + } + next(rw, r) +} + +func contentTypeMiddleware(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + ext := filepath.Ext(r.URL.Path) + if contentType, ok := contentTypes[ext]; ok { + rw.Header().Set("Content-Type", contentType) + } + next(rw, r) +} + +func main() { + port := os.Getenv("PORT") + if port == "" { + panic("PORT env must be set") + } + contentPath := os.Getenv("CONTENT_PATH") + if contentPath == "" { + panic("CONTENT_PATH env must be set") + } + + mux := http.NewServeMux() + mux.Handle("/", http.FileServer(http.Dir(contentPath))) + + n := negroni.New(negroni.NewLogger()) + n.UseFunc(filteringMiddleware) + n.UseFunc(contentTypeMiddleware) + n.UseHandler(mux) + + err := http.ListenAndServe(":"+port, n) + if err != nil { + panic(err) + } +} diff --git a/components/helm-broker/cmd/reposerver/main_test.go b/components/helm-broker/cmd/reposerver/main_test.go new file mode 100644 index 000000000000..603140383a93 --- /dev/null +++ b/components/helm-broker/cmd/reposerver/main_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFilterMiddlewareFiltersRequests(t *testing.T) { + for tn, tc := range map[string]struct { + givenURLPath string + expectedCode int + }{ + "index.yaml": { + givenURLPath: "index.yaml", + expectedCode: http.StatusOK, + }, + "tgz archive": { + givenURLPath: "archive.tgz", + expectedCode: http.StatusOK, + }, + "Not allowed extension": { + givenURLPath: "archive.html", + expectedCode: http.StatusNotFound, + }, + } { + t.Run(tn, func(t *testing.T) { + // given + req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost/%s", tc.givenURLPath), nil) + recorder := httptest.NewRecorder() + called := false + next := func(rw http.ResponseWriter, r *http.Request) { + called = true + } + + // when + filteringMiddleware(recorder, req, next) + + // then + assert.Equal(t, tc.expectedCode, recorder.Code) + // next must be called only if code is 200 + assert.Equal(t, http.StatusOK == recorder.Code, called) + }) + } +} + +func TestContentTypeSetting(t *testing.T) { + for tn, tc := range map[string]struct { + givenURLPath string + expectedContentType string + }{ + "index.yaml": { + givenURLPath: "index.yaml", + expectedContentType: "text/plain", + }, + "tgz archive": { + givenURLPath: "archive.tgz", + expectedContentType: "application/gzip", + }, + } { + t.Run(tn, func(t *testing.T) { + // given + req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost/%s", tc.givenURLPath), nil) + recorder := httptest.NewRecorder() + next := func(rw http.ResponseWriter, r *http.Request) {} // handler stub + + // when + contentTypeMiddleware(recorder, req, next) + + // then + assert.Equal(t, tc.expectedContentType, recorder.Header().Get("Content-Type")) + }) + } +} diff --git a/components/helm-broker/cmd/targz/README.md b/components/helm-broker/cmd/targz/README.md new file mode 100644 index 000000000000..bd4e8da5de64 --- /dev/null +++ b/components/helm-broker/cmd/targz/README.md @@ -0,0 +1,7 @@ +# TARGZ +TARGZ converts all bundles stored in and store them as a 'tar.gz' files inside + +## Usage +``` +targz " +``` \ No newline at end of file diff --git a/components/helm-broker/cmd/targz/archiver/archiver.go b/components/helm-broker/cmd/targz/archiver/archiver.go new file mode 100644 index 000000000000..45d48ada2367 --- /dev/null +++ b/components/helm-broker/cmd/targz/archiver/archiver.go @@ -0,0 +1,109 @@ +package archiver + +// Copied from https://github.com/mholt/archiver + +import ( + "fmt" + "io" + "log" + "os" + "path/filepath" + "runtime" +) + +// Archiver represent a archive format +type Archiver interface { + // Match checks supported files + Match(filename string) bool + // Make makes an archive file on disk. + Make(destination string, sources []string) error + // Open extracts an archive file on disk. + Open(source, destination string) error + // Write writes an archive to a Writer. + Write(output io.Writer, sources []string) error + // Read reads an archive from a Reader. + Read(input io.Reader, destination string) error +} + +// SupportedFormats contains all supported archive formats +var SupportedFormats = map[string]Archiver{} + +// RegisterFormat adds a supported archive format +func RegisterFormat(name string, format Archiver) { + if _, ok := SupportedFormats[name]; ok { + log.Printf("Format %s already exists, skip!\n", name) + return + } + SupportedFormats[name] = format +} + +// MatchingFormat returns the first archive format that matches +// the given file, or nil if there is no match +func MatchingFormat(fpath string) Archiver { + for _, fmt := range SupportedFormats { + if fmt.Match(fpath) { + return fmt + } + } + return nil +} + +func writeNewFile(fpath string, in io.Reader, fm os.FileMode) error { + err := os.MkdirAll(filepath.Dir(fpath), 0755) + if err != nil { + return fmt.Errorf("%s: making directory for file: %v", fpath, err) + } + + out, err := os.Create(fpath) + if err != nil { + return fmt.Errorf("%s: creating new file: %v", fpath, err) + } + defer out.Close() + + err = out.Chmod(fm) + if err != nil && runtime.GOOS != "windows" { + return fmt.Errorf("%s: changing file mode: %v", fpath, err) + } + + _, err = io.Copy(out, in) + if err != nil { + return fmt.Errorf("%s: writing file: %v", fpath, err) + } + return nil +} + +func writeNewSymbolicLink(fpath string, target string) error { + err := os.MkdirAll(filepath.Dir(fpath), 0755) + if err != nil { + return fmt.Errorf("%s: making directory for file: %v", fpath, err) + } + + err = os.Symlink(target, fpath) + if err != nil { + return fmt.Errorf("%s: making symbolic link for: %v", fpath, err) + } + + return nil +} + +func writeNewHardLink(fpath string, target string) error { + err := os.MkdirAll(filepath.Dir(fpath), 0755) + if err != nil { + return fmt.Errorf("%s: making directory for file: %v", fpath, err) + } + + err = os.Link(target, fpath) + if err != nil { + return fmt.Errorf("%s: making hard link for: %v", fpath, err) + } + + return nil +} + +func mkdir(dirPath string) error { + err := os.MkdirAll(dirPath, 0755) + if err != nil { + return fmt.Errorf("%s: making directory: %v", dirPath, err) + } + return nil +} diff --git a/components/helm-broker/cmd/targz/archiver/tar.go b/components/helm-broker/cmd/targz/archiver/tar.go new file mode 100644 index 000000000000..9a15ac64ccd8 --- /dev/null +++ b/components/helm-broker/cmd/targz/archiver/tar.go @@ -0,0 +1,236 @@ +package archiver + +// Copied from https://github.com/mholt/archiver + +import ( + "archive/tar" + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" +) + +// Tar is for Tar format +var Tar tarFormat + +func init() { + RegisterFormat("Tar", Tar) +} + +type tarFormat struct{} + +func (tarFormat) Match(filename string) bool { + return strings.HasSuffix(strings.ToLower(filename), ".tar") || isTar(filename) +} + +const tarBlockSize int = 512 + +// isTar checks the file has the Tar format header by reading its beginning +// block. +func isTar(tarPath string) bool { + f, err := os.Open(tarPath) + if err != nil { + return false + } + defer f.Close() + + buf := make([]byte, tarBlockSize) + if _, err = io.ReadFull(f, buf); err != nil { + return false + } + + return hasTarHeader(buf) +} + +// hasTarHeader checks passed bytes has a valid tar header or not. buf must +// contain at least 512 bytes and if not, it always returns false. +func hasTarHeader(buf []byte) bool { + if len(buf) < tarBlockSize { + return false + } + + b := buf[148:156] + b = bytes.Trim(b, " \x00") // clean up all spaces and null bytes + if len(b) == 0 { + return false // unknown format + } + hdrSum, err := strconv.ParseUint(string(b), 8, 64) + if err != nil { + return false + } + + // According to the go official archive/tar, Sun tar uses signed byte + // values so this calcs both signed and unsigned + var usum uint64 + var sum int64 + for i, c := range buf { + if 148 <= i && i < 156 { + c = ' ' // checksum field itself is counted as branks + } + usum += uint64(uint8(c)) + sum += int64(int8(c)) + } + + if hdrSum != usum && int64(hdrSum) != sum { + return false // invalid checksum + } + + return true +} + +// Write outputs a .tar file to a Writer containing the +// contents of files listed in filePaths. File paths can +// be those of regular files or directories. Regular +// files are stored at the 'root' of the archive, and +// directories are recursively added. +func (tarFormat) Write(output io.Writer, filePaths []string) error { + return writeTar(filePaths, output, "") +} + +// Make creates a .tar file at tarPath containing the +// contents of files listed in filePaths. File paths can +// be those of regular files or directories. Regular +// files are stored at the 'root' of the archive, and +// directories are recursively added. +func (tarFormat) Make(tarPath string, filePaths []string) error { + out, err := os.Create(tarPath) + if err != nil { + return fmt.Errorf("error creating %s: %v", tarPath, err) + } + defer out.Close() + + return writeTar(filePaths, out, tarPath) +} + +func writeTar(filePaths []string, output io.Writer, dest string) error { + tarWriter := tar.NewWriter(output) + defer tarWriter.Close() + + return tarball(filePaths, tarWriter, dest) +} + +// tarball writes all files listed in filePaths into tarWriter, which is +// writing into a file located at dest. +func tarball(filePaths []string, tarWriter *tar.Writer, dest string) error { + for _, fpath := range filePaths { + err := tarFile(tarWriter, fpath, dest) + if err != nil { + return err + } + } + return nil +} + +// tarFile writes the file at source into tarWriter. It does so +// recursively for directories. +func tarFile(tarWriter *tar.Writer, source, dest string) error { + sourceInfo, err := os.Stat(source) + if err != nil { + return fmt.Errorf("%s: stat: %v", source, err) + } + + var baseDir string + if sourceInfo.IsDir() { + baseDir = filepath.Base(source) + } + + return filepath.Walk(source, func(path string, info os.FileInfo, err error) error { + if err != nil { + return fmt.Errorf("error walking to %s: %v", path, err) + } + + header, err := tar.FileInfoHeader(info, path) + if err != nil { + return fmt.Errorf("%s: making header: %v", path, err) + } + + if baseDir != "" { + header.Name = filepath.Join(baseDir, strings.TrimPrefix(path, source)) + } + + if header.Name == dest { + // our new tar file is inside the directory being archived; skip it + return nil + } + + if info.IsDir() { + header.Name += "/" + } + + err = tarWriter.WriteHeader(header) + if err != nil { + return fmt.Errorf("%s: writing header: %v", path, err) + } + + if info.IsDir() { + return nil + } + + if header.Typeflag == tar.TypeReg { + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("%s: open: %v", path, err) + } + defer file.Close() + + _, err = io.CopyN(tarWriter, file, info.Size()) + if err != nil && err != io.EOF { + return fmt.Errorf("%s: copying contents: %v", path, err) + } + } + return nil + }) +} + +// Read untars a .tar file read from a Reader and puts +// the contents into destination. +func (tarFormat) Read(input io.Reader, destination string) error { + return untar(tar.NewReader(input), destination) +} + +// Open untars source and puts the contents into destination. +func (tarFormat) Open(source, destination string) error { + f, err := os.Open(source) + if err != nil { + return fmt.Errorf("%s: failed to open archive: %v", source, err) + } + defer f.Close() + + return Tar.Read(f, destination) +} + +// untar un-tarballs the contents of tr into destination. +func untar(tr *tar.Reader, destination string) error { + for { + header, err := tr.Next() + if err == io.EOF { + break + } else if err != nil { + return err + } + + if err := untarFile(tr, header, destination); err != nil { + return err + } + } + return nil +} + +// untarFile untars a single file from tr with header header into destination. +func untarFile(tr *tar.Reader, header *tar.Header, destination string) error { + switch header.Typeflag { + case tar.TypeDir: + return mkdir(filepath.Join(destination, header.Name)) + case tar.TypeReg, tar.TypeRegA, tar.TypeChar, tar.TypeBlock, tar.TypeFifo: + return writeNewFile(filepath.Join(destination, header.Name), tr, header.FileInfo().Mode()) + case tar.TypeSymlink: + return writeNewSymbolicLink(filepath.Join(destination, header.Name), header.Linkname) + case tar.TypeLink: + return writeNewHardLink(filepath.Join(destination, header.Name), filepath.Join(destination, header.Linkname)) + default: + return fmt.Errorf("%s: unknown type flag: %c", header.Name, header.Typeflag) + } +} diff --git a/components/helm-broker/cmd/targz/archiver/targz.go b/components/helm-broker/cmd/targz/archiver/targz.go new file mode 100644 index 000000000000..93d0a8f1d64f --- /dev/null +++ b/components/helm-broker/cmd/targz/archiver/targz.go @@ -0,0 +1,100 @@ +package archiver + +// Copied from https://github.com/mholt/archiver + +import ( + "compress/gzip" + "fmt" + "io" + "os" + "strings" +) + +// TarGz is for TarGz format +var TarGz tarGzFormat + +func init() { + RegisterFormat("TarGz", TarGz) +} + +type tarGzFormat struct{} + +func (tarGzFormat) Match(filename string) bool { + return strings.HasSuffix(strings.ToLower(filename), ".tar.gz") || + strings.HasSuffix(strings.ToLower(filename), ".tgz") || + isTarGz(filename) +} + +// isTarGz checks the file has the gzip compressed Tar format header by reading +// its beginning block. +func isTarGz(targzPath string) bool { + f, err := os.Open(targzPath) + if err != nil { + return false + } + defer f.Close() + + gzr, err := gzip.NewReader(f) + if err != nil { + return false + } + defer gzr.Close() + + buf := make([]byte, tarBlockSize) + n, err := gzr.Read(buf) + if err != nil || n < tarBlockSize { + return false + } + + return hasTarHeader(buf) +} + +// Write outputs a .tar.gz file to a Writer containing +// the contents of files listed in filePaths. It works +// the same way Tar does, but with gzip compression. +func (tarGzFormat) Write(output io.Writer, filePaths []string) error { + return writeTarGz(filePaths, output, "") +} + +// Make creates a .tar.gz file at targzPath containing +// the contents of files listed in filePaths. It works +// the same way Tar does, but with gzip compression. +func (tarGzFormat) Make(targzPath string, filePaths []string) error { + out, err := os.Create(targzPath) + if err != nil { + return fmt.Errorf("error creating %s: %v", targzPath, err) + } + defer out.Close() + + return writeTarGz(filePaths, out, targzPath) +} + +func writeTarGz(filePaths []string, output io.Writer, dest string) error { + gzw := gzip.NewWriter(output) + defer gzw.Close() + + return writeTar(filePaths, gzw, dest) +} + +// Read untars a .tar.gz file read from a Reader and decompresses +// the contents into destination. +func (tarGzFormat) Read(input io.Reader, destination string) error { + gzr, err := gzip.NewReader(input) + if err != nil { + return fmt.Errorf("error decompressing: %v", err) + } + defer gzr.Close() + + return Tar.Read(gzr, destination) +} + +// Open untars source and decompresses the contents into destination. +func (tarGzFormat) Open(source, destination string) error { + f, err := os.Open(source) + if err != nil { + return fmt.Errorf("%s: failed to open archive: %v", source, err) + } + defer f.Close() + + return TarGz.Read(f, destination) +} diff --git a/components/helm-broker/cmd/targz/main.go b/components/helm-broker/cmd/targz/main.go new file mode 100644 index 000000000000..d591c34a6fd3 --- /dev/null +++ b/components/helm-broker/cmd/targz/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "io/ioutil" + "os" + + "github.com/pkg/errors" + + "fmt" + "path/filepath" + + "github.com/kyma-project/kyma/components/helm-broker/cmd/targz/archiver" +) + +func main() { + if len(os.Args) != 3 { + fmt.Println("Usage: targz ") + return + } + inDir := os.Args[1] + outDir := os.Args[2] + + if err := archiveBundles(inDir, outDir); err != nil { + panic(err) + } +} + +func archiveBundles(inDir, outDir string) error { + bundles, err := ioutil.ReadDir(inDir) + if err != nil { + return errors.Wrap(err, "while reading input directory") + } + + for _, bundleInfo := range bundles { + if bundleInfo.IsDir() { + bundlePath := filepath.Join(inDir, bundleInfo.Name()) + bundleContent, err := ioutil.ReadDir(bundlePath) + if err != nil { + return errors.Wrapf(err, "while reading bundle '%s'", bundlePath) + } + bundleFileNames := make([]string, len(bundleContent)) + + for i, elem := range bundleContent { + bundleFileNames[i] = filepath.Join(bundlePath, elem.Name()) + } + + tarGzFile := filepath.Join(outDir, bundleInfo.Name()+".tgz") + err = archiver.TarGz.Make(tarGzFile, bundleFileNames) + if err != nil { + return errors.Wrapf(err, "while creating archive '%s'", tarGzFile) + } + } + } + return nil +} diff --git a/components/helm-broker/cmd/targz/main_test.go b/components/helm-broker/cmd/targz/main_test.go new file mode 100644 index 000000000000..bb3429c02bc2 --- /dev/null +++ b/components/helm-broker/cmd/targz/main_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/kyma-project/kyma/components/helm-broker/internal/platform/logger/spy" + "github.com/kyma-project/kyma/components/helm-broker/internal/ybundle" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestArchiveBundles(t *testing.T) { + // GIVEN + loaderTempDir, err := ioutil.TempDir("", "helm-broker-loader") + require.NoError(t, err) + defer os.RemoveAll(loaderTempDir) + + outputDir, err := ioutil.TempDir("", "helm-broker-archive-output") + require.NoError(t, err) + defer os.RemoveAll(outputDir) + + // WHEN + err = archiveBundles("testdata/input", outputDir) + // THEN + require.NoError(t, err) + + loader := ybundle.NewLoader(loaderTempDir, spy.NewLogDummy()) + + quote, err := os.Open(filepath.Join(outputDir, "quote-1.0.1.tgz")) + assert.NoError(t, err) + + redis, err := os.Open(filepath.Join(outputDir, "redis-0.0.3.tgz")) + assert.NoError(t, err) + + _, _, err = loader.Load(quote) + assert.NoError(t, err) + + _, _, err = loader.Load(redis) + assert.NoError(t, err) +} diff --git a/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/.helmignore b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/.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/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/Chart.yaml b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/Chart.yaml new file mode 100644 index 000000000000..c8dafd7a4dc8 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +description: A Helm chart for quote service +name: quote +version: 0.1.0 + +sources: +- https://github.com diff --git a/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/templates/_helpers.tpl b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/templates/_helpers.tpl new file mode 100644 index 000000000000..f0d83d2edba6 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/templates/_helpers.tpl @@ -0,0 +1,16 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "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). +*/}} +{{- define "fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/templates/deployment.yaml b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/templates/deployment.yaml new file mode 100644 index 000000000000..954af2de4ea2 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/templates/deployment.yaml @@ -0,0 +1,43 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: {{ template "fullname" . }} + labels: + app: {{ template "name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: {{ .Release.Name }} +spec: + replicas: {{ .Values.replicaCount }} + strategy: + rollingUpdate: + maxUnavailable: 0 + template: + metadata: + labels: + app: {{ template "name" . }} + release: {{ .Release.Name }} + spec: + imagePullSecrets: + - name: {{ .Release.Namespace }} + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: {{ .Values.service.internalPort }} + livenessProbe: + httpGet: + path: /status + port: {{ .Values.service.internalPort }} + periodSeconds: 60 + timeoutSeconds: 1 + volumeMounts: + - name: quotes + mountPath: "/data" + readOnly: true + volumes: + - name: quotes + secret: + #optional flag set to true - deployment won't fail if secret is not defined + optional: true + secretName: quotes diff --git a/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/templates/ingress.yaml b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/templates/ingress.yaml new file mode 100644 index 000000000000..77299557e2fe --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/templates/ingress.yaml @@ -0,0 +1,18 @@ +{{- if .Values.ingress.enabled -}} +{{- $serviceName := include "fullname" . -}} +{{- $servicePort := .Values.service.externalPort -}} +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: {{ template "fullname" . }} +spec: + rules: + - host: "{{ .Values.ingress.host }}" + http: + paths: + - path: / + backend: + serviceName: {{ $serviceName }} + servicePort: {{ $servicePort }} +{{- end -}} + diff --git a/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/templates/service.yaml b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/templates/service.yaml new file mode 100644 index 000000000000..6a9bbe2d9743 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/templates/service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ template "fullname" . }} + labels: + app: {{ template "name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: {{ .Release.Name }} +spec: + type: {{ .Values.service.type }} + selector: + app: {{ template "name" . }} + release: {{ .Release.Name }} + ports: + - protocol: TCP + port: {{ .Values.service.externalPort }} + targetPort: {{ .Values.service.internalPort }} diff --git a/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/values.yaml b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/values.yaml new file mode 100644 index 000000000000..6a93f2752337 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/chart/quote/values.yaml @@ -0,0 +1,17 @@ +# Default values for quote. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +replicaCount: 2 +image: + repository: repository.com/quote + tag: 1.0.0 + pullPolicy: IfNotPresent +service: + name: quote + type: NodePort + externalPort: 8080 + internalPort: 8080 +ingress: + enabled: true + # Used to create an Ingress record. + host: quote.gopher.com diff --git a/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/meta.yaml b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/meta.yaml new file mode 100644 index 000000000000..3321ef33644f --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/meta.yaml @@ -0,0 +1,7 @@ +name: quote +version: 1.0.1 +id: 0df8ec66-6d6b-41fa-a99d-babbdb09fa61 +description: Quote service + +displayName: Quote +longDescription: Quote service returns random quotes diff --git a/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/plans/default/create-instance-schema.json b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/plans/default/create-instance-schema.json new file mode 100644 index 000000000000..2566850fa0a9 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/plans/default/create-instance-schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "replicaCount": { + "type": "number", + "description": "Specifies the number of desired Pods", + "default": "1" + } + } +} diff --git a/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/plans/default/meta.yaml b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/plans/default/meta.yaml new file mode 100644 index 000000000000..933b5a3939fa --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/plans/default/meta.yaml @@ -0,0 +1,4 @@ +name: default +id: ba8730fc-1796-41c6-9b84-b475f590a25c +description: Default plan +displayName: Default \ No newline at end of file diff --git a/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/plans/default/values.yaml b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/plans/default/values.yaml new file mode 100644 index 000000000000..d6b6855dcd68 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/quote-1.0.1/plans/default/values.yaml @@ -0,0 +1 @@ +replicaCount: 1 \ No newline at end of file diff --git a/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/.helmignore b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/.helmignore new file mode 100644 index 000000000000..6b8710a711f3 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/.helmignore @@ -0,0 +1 @@ +.git diff --git a/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/Chart.yaml b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/Chart.yaml new file mode 100644 index 000000000000..4159b7dcdb21 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/Chart.yaml @@ -0,0 +1,16 @@ +name: redis +version: 0.10.1 +appVersion: 3.2.9 +description: Open source, advanced key-value store. It is often referred to as a data structure server since keys can contain strings, hashes, lists, sets and sorted sets. +keywords: +- redis +- keyvalue +- database +home: http://redis.io/ +icon: https://bitnami.com/assets/stacks/redis/img/redis-stack-220x234.png +sources: +- https://github.com/bitnami/bitnami-docker-redis +maintainers: +- name: bitnami-bot + email: containers@bitnami.com +engine: gotpl diff --git a/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/README.md b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/README.md new file mode 100644 index 000000000000..a92fe50db3a3 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/README.md @@ -0,0 +1,120 @@ +# Redis + +[Redis](http://redis.io/) is an advanced key-value cache and store. It is often referred to as a data structure server since keys can contain strings, hashes, lists, sets, sorted sets, bitmaps and hyperloglogs. + +## TL;DR; + +```bash +$ helm install stable/redis +``` + +## Introduction + +This chart bootstraps a [Redis](https://github.com/bitnami/bitnami-docker-redis) deployment on a [Kubernetes](http://kubernetes.io) cluster using the [Helm](https://helm.sh) package manager. + +## Prerequisites + +- Kubernetes 1.4+ with Beta APIs enabled +- PV provisioner support in the underlying infrastructure + +## Installing the Chart + +To install the chart with the release name `my-release`: + +```bash +$ helm install --name my-release stable/redis +``` + +The command deploys Redis on the Kubernetes cluster in the default configuration. The [configuration](#configuration) section lists the parameters that can be configured during installation. + +> **Tip**: List all releases using `helm list` + +## Uninstalling the Chart + +To uninstall/delete the `my-release` deployment: + +```bash +$ helm delete my-release +``` + +The command removes all the Kubernetes components associated with the chart and deletes the release. + +## Configuration + +The following tables lists the configurable parameters of the Redis chart and their default values. + +| Parameter | Description | Default | +| -------------------------- | ------------------------------------- | --------------------------------------------------------- | +| `image` | Redis image | `bitnami/redis:{VERSION}` | +| `imagePullPolicy` | Image pull policy | `IfNotPresent` | +| `usePassword` | Use password | `true` | +| `redisPassword` | Redis password | Randomly generated | +| `args` | Redis command-line args | [] | +| `persistence.enabled` | Use a PVC to persist data | `true` | +| `persistence.existingClaim`| Use an existing PVC to persist data | `nil` | +| `persistence.storageClass` | Storage class of backing PVC | `generic` | +| `persistence.accessMode` | Use volume as ReadOnly or ReadWrite | `ReadWriteOnce` | +| `persistence.size` | Size of data volume | `8Gi` | +| `resources` | CPU/Memory resource requests/limits | Memory: `256Mi`, CPU: `100m` | +| `metrics.enabled` | Start a side-car prometheus exporter | `false` | +| `metrics.image` | Exporter image | `oliver006/redis_exporter` | +| `metrics.imageTag` | Exporter image | `v0.11` | +| `metrics.imagePullPolicy` | Exporter image pull policy | `IfNotPresent` | +| `metrics.resources` | Exporter resource requests/limit | Memory: `256Mi`, CPU: `100m` | +| `nodeSelector` | Node labels for pod assignment | {} | +| `tolerations` | Toleration labels for pod assignment | [] | +| `networkPolicy.enabled` | Enable NetworkPolicy | `false` | +| `networkPolicy.allowExternal` | Don't require client label for connections | `true` | + +The above parameters map to the env variables defined in [bitnami/redis](http://github.com/bitnami/bitnami-docker-redis). For more information please refer to the [bitnami/redis](http://github.com/bitnami/bitnami-docker-redis) image documentation. + +Specify each parameter using the `--set key=value[,key=value]` argument to `helm install`. For example, + +```bash +$ helm install --name my-release \ + --set redisPassword=secretpassword \ + stable/redis +``` + +The above command sets the Redis server password to `secretpassword`. + +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/redis +``` + +> **Tip**: You can use the default [values.yaml](values.yaml) + +## NetworkPolicy + +To enable network policy for Redis, install +[a networking plugin that implements the Kubernetes NetworkPolicy spec](https://kubernetes.io/docs/tasks/administer-cluster/declare-network-policy#before-you-begin), +and set `networkPolicy.enabled` to `true`. + +For Kubernetes v1.5 & v1.6, you must also turn on NetworkPolicy by setting +the DefaultDeny namespace annotation. Note: this will enforce policy for _all_ pods in the namespace: + + kubectl annotate namespace default "net.beta.kubernetes.io/network-policy={\"ingress\":{\"isolation\":\"DefaultDeny\"}}" + +With NetworkPolicy enabled, only pods with the generated client label will be +able to connect to Redis. This label will be displayed in the output +after a successful install. + +## Persistence + +The [Bitnami Redis](https://github.com/bitnami/bitnami-docker-redis) image stores the Redis data and configurations at the `/bitnami/redis` path of the container. + +By default, the chart mounts a [Persistent Volume](http://kubernetes.io/docs/user-guide/persistent-volumes/) volume at this location. The volume is created using dynamic volume provisioning. If a Persistent Volume Claim already exists, specify it during installation. + +### Existing PersistentVolumeClaim + +1. Create the PersistentVolume +1. Create the PersistentVolumeClaim +1. Install the chart +```bash +$ helm install --set persistence.existingClaim=PVC_NAME redis +``` + +## Metrics +The chart optionally can start a metrics exporter for [prometheus](https://prometheus.io). The metrics endpoint (port 9121) is not exposed and it is expected that the metrics are collected from inside the k8s cluster using something similar as the described in the [example Prometheus scrape configuration](https://github.com/prometheus/prometheus/blob/master/documentation/examples/prometheus-kubernetes.yml). diff --git a/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/NOTES.txt b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/NOTES.txt new file mode 100644 index 000000000000..1110b6ba8b49 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/NOTES.txt @@ -0,0 +1,28 @@ +Redis can be accessed via port 6379 on the following DNS name from within your cluster: +{{ template "redis.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local + +{{- if .Values.usePassword }} +To get your password run: + + REDIS_PASSWORD=$(kubectl get secret --namespace {{ .Release.Namespace }} {{ template "redis.fullname" . }} -o jsonpath="{.data.redis-password}" | base64 --decode) +{{- end }} + +To connect to your Redis server: + +1. Run a Redis pod that you can use as a client: + + kubectl run {{ template "redis.fullname" . }}-client --rm --tty -i \ + {{ if .Values.usePassword }} --env REDIS_PASSWORD=$REDIS_PASSWORD{{ end }} + {{- if and (.Values.networkPolicy.enabled) (not .Values.networkPolicy.allowExternal) }}--labels="{{ template "redis.fullname" . }}-client=true" \{{- end }} + --image {{ .Values.image }} -- bash + +2. Connect using the Redis CLI: + + redis-cli -h {{ template "redis.fullname" . }}{{ if .Values.usePassword }} -a $REDIS_PASSWORD{{ end }} + +{{ if and (.Values.networkPolicy.enabled) (not .Values.networkPolicy.allowExternal) }} +Note: Since NetworkPolicy is enabled, only pods with label +{{ template "redis.fullname" . }}-client=true" +will be able to connect to redis. +{{- end }} + diff --git a/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/_helpers.tpl b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/_helpers.tpl new file mode 100644 index 000000000000..f96369929fa2 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/_helpers.tpl @@ -0,0 +1,27 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "redis.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). +*/}} +{{- define "redis.fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Return the appropriate apiVersion for networkpolicy. +*/}} +{{- define "networkPolicy.apiVersion" -}} +{{- if and (ge .Capabilities.KubeVersion.Minor "4") (le .Capabilities.KubeVersion.Minor "6") -}} +{{- print "extensions/v1beta1" -}} +{{- else if ge .Capabilities.KubeVersion.Minor "7" -}} +{{- print "networking.k8s.io/v1" -}} +{{- end -}} +{{- end -}} \ No newline at end of file diff --git a/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/deployment.yaml b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/deployment.yaml new file mode 100644 index 000000000000..cf272dab9dad --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/deployment.yaml @@ -0,0 +1,96 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: {{ template "redis.fullname" . }} + labels: + app: {{ template "redis.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +spec: + replicas: 1 + strategy: + rollingUpdate: + maxUnavailable: 0 + template: + metadata: + labels: + app: {{ template "redis.fullname" . }} + spec: + {{- if .Values.nodeSelector }} + nodeSelector: +{{ toYaml .Values.nodeSelector | indent 8 }} + {{- end }} + {{- if .Values.tolerations }} + tolerations: +{{ toYaml .Values.tolerations | indent 8 }} + {{- end }} + containers: + - name: {{ template "redis.fullname" . }} + image: "{{ .Values.image }}" + imagePullPolicy: {{ default "" .Values.imagePullPolicy | quote }} + {{- if .Values.args }} + args: +{{ toYaml .Values.args | indent 10 }} + {{- end }} + env: + {{- if .Values.usePassword }} + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "redis.fullname" . }} + key: redis-password + {{- else }} + - name: ALLOW_EMPTY_PASSWORD + value: "yes" + {{- end }} + ports: + - name: redis + containerPort: 6379 + livenessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 30 + timeoutSeconds: 5 + readinessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 5 + timeoutSeconds: 1 + resources: +{{ toYaml .Values.resources | indent 10 }} + volumeMounts: + - name: redis-data + mountPath: /bitnami/redis +{{- if .Values.metrics.enabled }} + - name: metrics + image: "{{ .Values.metrics.image }}:{{ .Values.metrics.imageTag }}" + imagePullPolicy: {{ .Values.metrics.imagePullPolicy | quote }} + env: + - name: REDIS_ALIAS + value: {{ template "redis.fullname" . }} + {{- if .Values.usePassword }} + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "redis.fullname" . }} + key: redis-password + {{- end }} + ports: + - name: metrics + containerPort: 9121 + resources: +{{ toYaml .Values.metrics.resources | indent 10 }} +{{- end }} + volumes: + - name: redis-data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ .Values.persistence.existingClaim | default (include "redis.fullname" .) }} + {{- else }} + emptyDir: {} + {{- end -}} diff --git a/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/networkpolicy.yaml b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/networkpolicy.yaml new file mode 100644 index 000000000000..eb6640f073ac --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/networkpolicy.yaml @@ -0,0 +1,30 @@ +{{- if .Values.networkPolicy.enabled }} +kind: NetworkPolicy +apiVersion: {{ template "networkPolicy.apiVersion" . }} +metadata: + name: "{{ template "redis.fullname" . }}" + labels: + app: {{ template "redis.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +spec: + podSelector: + matchLabels: + app: {{ template "redis.fullname" . }} + ingress: + # Allow inbound connections + - ports: + - port: 6379 + {{- if not .Values.networkPolicy.allowExternal }} + from: + - podSelector: + matchLabels: + {{ template "redis.fullname" . }}-client: "true" + {{- end }} + {{- if .Values.metrics.enabled }} + # Allow prometheus scrapes for metrics + - ports: + - port: 9121 + {{- end }} +{{- end }} diff --git a/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/pvc.yaml b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/pvc.yaml new file mode 100644 index 000000000000..27de14b46d64 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/pvc.yaml @@ -0,0 +1,24 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +kind: PersistentVolumeClaim +apiVersion: v1 +metadata: + name: {{ template "redis.fullname" . }} + labels: + app: {{ template "redis.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +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/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/secrets.yaml b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/secrets.yaml new file mode 100644 index 000000000000..6ab6b053e52c --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/secrets.yaml @@ -0,0 +1,18 @@ +{{- if .Values.usePassword -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ template "redis.fullname" . }} + labels: + app: {{ template "redis.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +type: Opaque +data: + {{- if .Values.redisPassword }} + redis-password: {{ .Values.redisPassword | b64enc | quote }} + {{- else }} + redis-password: {{ randAlphaNum 10 | b64enc | quote }} + {{- end }} +{{- end -}} diff --git a/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/svc.yaml b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/svc.yaml new file mode 100644 index 000000000000..5081e1ebae5b --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/svc.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ template "redis.fullname" . }} + labels: + app: {{ template "redis.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +{{- if .Values.metrics.enabled }} + annotations: +{{ toYaml .Values.metrics.annotations | indent 4 }} +{{- end }} +spec: + ports: + - name: redis + port: 6379 + targetPort: redis + selector: + app: {{ template "redis.fullname" . }} diff --git a/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/tests/test-redis-connection.yaml b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/tests/test-redis-connection.yaml new file mode 100644 index 000000000000..8f1da8c1108b --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/templates/tests/test-redis-connection.yaml @@ -0,0 +1,28 @@ +{{- $name := printf "test-connection-%s" .Release.Name | trunc 63 }} +{{- $host := printf "%s.%s.svc.cluster.local" (include "redis.fullname" .) .Release.Namespace }} +apiVersion: v1 +kind: Pod +metadata: + name: {{ $name }} + annotations: + "helm.sh/hook": test-success + labels: + "helm-chart-test": "true" +spec: + containers: + - name: {{ $name }} + image: "redis:3.2-alpine" + env: + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "redis.fullname" . }} + key: redis-password + command: ["redis-cli"] + args: [ + "-h", "{{ $host }}", + "-p", "6379", + "-a", "$(REDIS_PASSWORD)", + "ping" + ] + restartPolicy: Never diff --git a/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/values.yaml b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/values.yaml new file mode 100644 index 000000000000..1bc35d537907 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/chart/redis/values.yaml @@ -0,0 +1,86 @@ +## Bitnami Redis image version +## ref: https://hub.docker.com/r/bitnami/redis/tags/ +## +image: bitnami/redis:3.2.9-r2 + +## Specify a imagePullPolicy +## ref: http://kubernetes.io/docs/user-guide/images/#pre-pulling-images +## +imagePullPolicy: IfNotPresent + +## Use password authentication +usePassword: true + +## Redis password +## Defaults to a random 10-character alphanumeric string if not set and usePassword is true +## ref: https://github.com/bitnami/bitnami-docker-redis#setting-the-server-password-on-first-run +## +# redisPassword: + +## Redis command arguments +## +## Can be used to specify command line arguments, for example: +## +## args: +## - "redis-server" +## - "--maxmemory-policy volatile-ttl" +args: + +## Enable persistence using Persistent Volume Claims +## ref: http://kubernetes.io/docs/user-guide/persistent-volumes/ +## +persistence: + enabled: true + + ## A manually managed Persistent Volume and Claim + ## Requires persistence.enabled: true + ## If defined, PVC must be created manually before volume will be bound + # existingClaim: + + ## redis 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 + +metrics: + enabled: false + image: oliver006/redis_exporter + imageTag: v0.11 + imagePullPolicy: IfNotPresent + resources: {} + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9121" + +## Configure resource requests and limits +## ref: http://kubernetes.io/docs/user-guide/compute-resources/ +## +resources: + requests: + memory: 256Mi + cpu: 100m + +## Node labels and tolerations for pod assignment +## ref: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#nodeselector +## ref: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#taints-and-tolerations-beta-feature +nodeSelector: {} +tolerations: [] + +networkPolicy: + ## Enable creation of NetworkPolicy resources. + ## + enabled: false + + ## The Policy model to apply. When set to false, only pods with the correct + ## client label will have network access to the port PostgreSQL is listening + ## on. When true, Redis will accept connections from any source + ## (with the correct destination port). + ## + allowExternal: true + diff --git a/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/meta.yaml b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/meta.yaml new file mode 100644 index 000000000000..912a774cbaa3 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/meta.yaml @@ -0,0 +1,13 @@ +name: redis +version: 0.0.3 +id: a2257daa-0e26-4c61-a68d-8a7453c1b767 +description: "Redis package" +displayName: Redis + +tags: database, cache +providerDisplayName: bitnami +longDescription: Redis is an advanced key-value cache and store +documentationURL: https://github.com/bitnami/bitnami-docker-redis +supportURL: http://slack.oss.bitnami.com/ +imageURL: https://upload.wikimedia.org/wikipedia/en/6/6b/Redis_Logo.svg +bindable: true diff --git a/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/enterprise/bind.yaml b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/enterprise/bind.yaml new file mode 100644 index 000000000000..5e154ed2d68b --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/enterprise/bind.yaml @@ -0,0 +1,13 @@ +credential: + - name: HOST + value: {{ template "redis.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local + - name: PORT + valueFrom: + serviceRef: + name: {{ template "redis.fullname" . }} + jsonpath: '{ .spec.ports[?(@.name=="redis")].port }' + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "redis.fullname" . }} + key: redis-password diff --git a/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/enterprise/create-instance-schema.json b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/enterprise/create-instance-schema.json new file mode 100644 index 000000000000..0787ac7e8aa3 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/enterprise/create-instance-schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "redisPassword": { + "type": "string", + "description": "Redis password.", + "default": "Defaults to a random 10-character alphanumeric string" + }, + "imagePullPolicy": { + "type": "string", + "enum": ["Always", "IfNotPresent", "Never"], + "default": "IfNotPresent" + } + } +} \ No newline at end of file diff --git a/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/enterprise/meta.yaml b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/enterprise/meta.yaml new file mode 100644 index 000000000000..dbdc52981c77 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/enterprise/meta.yaml @@ -0,0 +1,6 @@ +name: enterprise +id: a6078798-70a1-4674-af90-aba364dd6a56 +description: "Enterprise plan" +displayName: Enterprise + +bindable: true \ No newline at end of file diff --git a/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/enterprise/values.yaml b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/enterprise/values.yaml new file mode 100644 index 000000000000..0815ef636f56 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/enterprise/values.yaml @@ -0,0 +1,7 @@ +## Configure resource requests and limits +## ref: http://kubernetes.io/docs/user-guide/compute-resources/ +## +resources: + requests: + memory: 512Mi + cpu: 200m diff --git a/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/micro/bind.yaml b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/micro/bind.yaml new file mode 100644 index 000000000000..21e64f5c010f --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/micro/bind.yaml @@ -0,0 +1,15 @@ +credential: + - name: HOST + value: {{ template "redis.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local + - name: PORT + valueFrom: + serviceRef: + name: {{ template "redis.fullname" . }} + jsonpath: '{ .spec.ports[?(@.name=="redis")].port }' +{{- if .Values.usePassword }} + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "redis.fullname" . }} + key: redis-password +{{- end }} diff --git a/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/micro/create-instance-schema.json b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/micro/create-instance-schema.json new file mode 100644 index 000000000000..0787ac7e8aa3 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/micro/create-instance-schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "redisPassword": { + "type": "string", + "description": "Redis password.", + "default": "Defaults to a random 10-character alphanumeric string" + }, + "imagePullPolicy": { + "type": "string", + "enum": ["Always", "IfNotPresent", "Never"], + "default": "IfNotPresent" + } + } +} \ No newline at end of file diff --git a/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/micro/meta.yaml b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/micro/meta.yaml new file mode 100644 index 000000000000..c459e43ebd09 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/micro/meta.yaml @@ -0,0 +1,6 @@ +name: micro +id: a6078798-70a1-4674-af94-ab9664d36a54 +description: "Micro plan" +displayName: Micro + +bindable: true \ No newline at end of file diff --git a/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/micro/values.yaml b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/micro/values.yaml new file mode 100644 index 000000000000..1a0694f7bd58 --- /dev/null +++ b/components/helm-broker/cmd/targz/testdata/input/redis-0.0.3/plans/micro/values.yaml @@ -0,0 +1,7 @@ +## Configure resource requests and limits +## ref: http://kubernetes.io/docs/user-guide/compute-resources/ +## +resources: + requests: + memory: 128Mi + cpu: 50m diff --git a/components/helm-broker/deploy/broker/Dockerfile b/components/helm-broker/deploy/broker/Dockerfile new file mode 100644 index 000000000000..69d67b47a6fd --- /dev/null +++ b/components/helm-broker/deploy/broker/Dockerfile @@ -0,0 +1,10 @@ +FROM alpine:3.7 + +RUN apk --no-cache add ca-certificates +RUN apk add --no-cache curl + +COPY ./helm-broker /root/helm-broker + +LABEL source=git@github.com:kyma-project/kyma.git + +ENTRYPOINT ["/root/helm-broker"] \ No newline at end of file diff --git a/components/helm-broker/deploy/reposerver/Dockerfile b/components/helm-broker/deploy/reposerver/Dockerfile new file mode 100644 index 000000000000..02228cb4aff1 --- /dev/null +++ b/components/helm-broker/deploy/reposerver/Dockerfile @@ -0,0 +1,14 @@ +FROM alpine:3.7 + +RUN apk --no-cache add ca-certificates +RUN apk add --no-cache curl + +RUN mkdir -p /data/repository /data/repository +COPY ./reposerver /root/reposerver + +ENV PORT 8080 +ENV CONTENT_PATH /data/repository + +LABEL source=git@github.com:kyma-project/kyma.git + +ENTRYPOINT ["/root/reposerver"] \ No newline at end of file diff --git a/components/helm-broker/deploy/tools/Dockerfile b/components/helm-broker/deploy/tools/Dockerfile new file mode 100644 index 000000000000..edafe4a411bd --- /dev/null +++ b/components/helm-broker/deploy/tools/Dockerfile @@ -0,0 +1,12 @@ +FROM alpine:3.7 + +RUN apk --no-cache add ca-certificates +RUN apk add --no-cache curl + +COPY ./targz /usr/local/bin/targz +COPY ./indexbuilder /usr/local/bin/indexbuilder + +LABEL source=git@github.com:kyma-project/kyma.git + +ENTRYPOINT ["tail", "-f", "/dev/null"] + diff --git a/components/helm-broker/internal/broker/automock/bind_template_renderer.go b/components/helm-broker/internal/broker/automock/bind_template_renderer.go new file mode 100644 index 000000000000..63b1610f6c2b --- /dev/null +++ b/components/helm-broker/internal/broker/automock/bind_template_renderer.go @@ -0,0 +1,35 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import services "k8s.io/helm/pkg/proto/hapi/services" +import ybind "github.com/kyma-project/kyma/components/helm-broker/internal/ybind" +import internal "github.com/kyma-project/kyma/components/helm-broker/internal" + +// BindTemplateRenderer is an autogenerated mock type for the BindTemplateRenderer type +type BindTemplateRenderer struct { + mock.Mock +} + +// Render provides a mock function with given fields: bindTemplate, resp +func (_m *BindTemplateRenderer) Render(bindTemplate internal.BundlePlanBindTemplate, resp *services.InstallReleaseResponse) (ybind.RenderedBindYAML, error) { + ret := _m.Called(bindTemplate, resp) + + var r0 ybind.RenderedBindYAML + if rf, ok := ret.Get(0).(func(internal.BundlePlanBindTemplate, *services.InstallReleaseResponse) ybind.RenderedBindYAML); ok { + r0 = rf(bindTemplate, resp) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(ybind.RenderedBindYAML) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(internal.BundlePlanBindTemplate, *services.InstallReleaseResponse) error); ok { + r1 = rf(bindTemplate, resp) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/helm-broker/internal/broker/automock/bind_template_resolver.go b/components/helm-broker/internal/broker/automock/bind_template_resolver.go new file mode 100644 index 000000000000..7da2d5fadc06 --- /dev/null +++ b/components/helm-broker/internal/broker/automock/bind_template_resolver.go @@ -0,0 +1,36 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import ( + "github.com/kyma-project/kyma/components/helm-broker/internal" + ybind "github.com/kyma-project/kyma/components/helm-broker/internal/ybind" +) + +// BindTemplateResolver is an autogenerated mock type for the BindTemplateResolver type +type BindTemplateResolver struct { + mock.Mock +} + +// Resolve provides a mock function with given fields: bindYAML, ns +func (_m *BindTemplateResolver) Resolve(bindYAML ybind.RenderedBindYAML, ns internal.Namespace) (*ybind.ResolveOutput, error) { + ret := _m.Called(bindYAML, ns) + + var r0 *ybind.ResolveOutput + if rf, ok := ret.Get(0).(func(ybind.RenderedBindYAML, internal.Namespace) *ybind.ResolveOutput); ok { + r0 = rf(bindYAML, ns) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*ybind.ResolveOutput) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(ybind.RenderedBindYAML, internal.Namespace) error); ok { + r1 = rf(bindYAML, ns) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/helm-broker/internal/broker/automock/bundle_storage.go b/components/helm-broker/internal/broker/automock/bundle_storage.go new file mode 100644 index 000000000000..dcd581b60317 --- /dev/null +++ b/components/helm-broker/internal/broker/automock/bundle_storage.go @@ -0,0 +1,55 @@ +package automock + +import "github.com/kyma-project/kyma/components/helm-broker/internal" +import "github.com/stretchr/testify/mock" + +// BundleStorage is an autogenerated mock type for the BundleStorage type +type BundleStorage struct { + mock.Mock +} + +// FindAll provides a mock function with given fields: +func (_m *BundleStorage) FindAll() ([]*internal.Bundle, error) { + ret := _m.Called() + + var r0 []*internal.Bundle + if rf, ok := ret.Get(0).(func() []*internal.Bundle); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*internal.Bundle) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetByID provides a mock function with given fields: id +func (_m *BundleStorage) GetByID(id internal.BundleID) (*internal.Bundle, error) { + ret := _m.Called(id) + + var r0 *internal.Bundle + if rf, ok := ret.Get(0).(func(internal.BundleID) *internal.Bundle); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*internal.Bundle) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(internal.BundleID) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/helm-broker/internal/broker/automock/chart_getter.go b/components/helm-broker/internal/broker/automock/chart_getter.go new file mode 100644 index 000000000000..efdd39673d68 --- /dev/null +++ b/components/helm-broker/internal/broker/automock/chart_getter.go @@ -0,0 +1,34 @@ +package automock + +import "k8s.io/helm/pkg/proto/hapi/chart" +import "github.com/kyma-project/kyma/components/helm-broker/internal" +import "github.com/stretchr/testify/mock" +import "github.com/Masterminds/semver" + +// ChartGetter is an autogenerated mock type for the ChartGetter type +type ChartGetter struct { + mock.Mock +} + +// Get provides a mock function with given fields: name, ver +func (_m *ChartGetter) Get(name internal.ChartName, ver semver.Version) (*chart.Chart, error) { + ret := _m.Called(name, ver) + + var r0 *chart.Chart + if rf, ok := ret.Get(0).(func(internal.ChartName, semver.Version) *chart.Chart); ok { + r0 = rf(name, ver) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*chart.Chart) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(internal.ChartName, semver.Version) error); ok { + r1 = rf(name, ver) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/helm-broker/internal/broker/automock/chart_storage.go b/components/helm-broker/internal/broker/automock/chart_storage.go new file mode 100644 index 000000000000..eba0dce27658 --- /dev/null +++ b/components/helm-broker/internal/broker/automock/chart_storage.go @@ -0,0 +1,34 @@ +package automock + +import "k8s.io/helm/pkg/proto/hapi/chart" +import "github.com/kyma-project/kyma/components/helm-broker/internal" +import "github.com/stretchr/testify/mock" +import "github.com/Masterminds/semver" + +// ChartStorage is an autogenerated mock type for the ChartStorage type +type ChartStorage struct { + mock.Mock +} + +// Get provides a mock function with given fields: name, ver +func (_m *ChartStorage) Get(name internal.ChartName, ver semver.Version) (*chart.Chart, error) { + ret := _m.Called(name, ver) + + var r0 *chart.Chart + if rf, ok := ret.Get(0).(func(internal.ChartName, semver.Version) *chart.Chart); ok { + r0 = rf(name, ver) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*chart.Chart) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(internal.ChartName, semver.Version) error); ok { + r1 = rf(name, ver) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/helm-broker/internal/broker/automock/converter.go b/components/helm-broker/internal/broker/automock/converter.go new file mode 100644 index 000000000000..49693a7c7fae --- /dev/null +++ b/components/helm-broker/internal/broker/automock/converter.go @@ -0,0 +1,31 @@ +package automock + +import "github.com/kyma-project/kyma/components/helm-broker/internal" +import "github.com/stretchr/testify/mock" +import "github.com/pmorie/go-open-service-broker-client/v2" + +// Converter is an autogenerated mock type for the Converter type +type Converter struct { + mock.Mock +} + +// Convert provides a mock function with given fields: b +func (_m *Converter) Convert(b *internal.Bundle) (v2.Service, error) { + ret := _m.Called(b) + + var r0 v2.Service + if rf, ok := ret.Get(0).(func(*internal.Bundle) v2.Service); ok { + r0 = rf(b) + } else { + r0 = ret.Get(0).(v2.Service) + } + + var r1 error + if rf, ok := ret.Get(1).(func(*internal.Bundle) error); ok { + r1 = rf(b) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/helm-broker/internal/broker/automock/extended.go b/components/helm-broker/internal/broker/automock/extended.go new file mode 100644 index 000000000000..cd529ece1e51 --- /dev/null +++ b/components/helm-broker/internal/broker/automock/extended.go @@ -0,0 +1,59 @@ +package automock + +import ( + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/stretchr/testify/mock" +) + +// InstanceStateGetter extensions +func (_m *InstanceStateGetter) ExpectOnIsDeprovisioned(iID internal.InstanceID, deprovisioned bool) *mock.Call { + return _m.On("IsDeprovisioned", iID).Return(deprovisioned, nil) +} + +func (_m *InstanceStateGetter) ExpectOnIsDeprovisioningInProgress(iID internal.InstanceID, optID internal.OperationID, inProgress bool) *mock.Call { + return _m.On("IsDeprovisioningInProgress", iID).Return(optID, inProgress, nil) +} + +func (_m *InstanceStateGetter) ExpectErrorOnIsDeprovisioningInProgress(iID internal.InstanceID, err error) *mock.Call { + return _m.On("IsDeprovisioningInProgress", iID).Return(internal.OperationID(""), false, err) +} + +func (_m *InstanceStateGetter) ExpectErrorIsDeprovisioned(iID internal.InstanceID, err error) *mock.Call { + return _m.On("IsDeprovisioned", iID).Return(false, err) +} + +// InstanceStorage extensions +func (_m *InstanceStorage) ExpectOnGet(iID internal.InstanceID, expInstance internal.Instance) *mock.Call { + return _m.On("Get", iID).Return(&expInstance, nil) +} + +func (_m *InstanceStorage) ExpectErrorOnGet(iID internal.InstanceID, err error) *mock.Call { + return _m.On("Get", iID).Return(nil, err) +} + +// OperationStorage extensions +func (_m *OperationStorage) ExpectOnInsert(op internal.InstanceOperation) *mock.Call { + return _m.On("Insert", &op).Return(nil) +} + +func (_m *OperationStorage) ExpectOnUpdateStateDesc(iID internal.InstanceID, opID internal.OperationID, state internal.OperationState, desc string) *mock.Call { + return _m.On("UpdateStateDesc", iID, opID, state, &desc).Return(nil) +} + +// HelmClient extensions +func (_m *HelmClient) ExpectOnDelete(rName internal.ReleaseName) *mock.Call { + return _m.On("Delete", rName).Return(nil) +} + +func (_m *HelmClient) ExpectErrorOnDelete(rName internal.ReleaseName, err error) *mock.Call { + return _m.On("Delete", rName).Return(err) +} + +// InstanceBindDataRemover extensions +func (_m *InstanceBindDataRemover) ExpectOnRemove(iID internal.InstanceID) *mock.Call { + return _m.On("Remove", iID).Return(nil) +} + +func (_m *InstanceBindDataRemover) ExpectErrorRemove(iID internal.InstanceID, err error) *mock.Call { + return _m.On("Remove", iID).Return(err) +} diff --git a/components/helm-broker/internal/broker/automock/helm_client.go b/components/helm-broker/internal/broker/automock/helm_client.go new file mode 100644 index 000000000000..6c08d36b0a36 --- /dev/null +++ b/components/helm-broker/internal/broker/automock/helm_client.go @@ -0,0 +1,49 @@ +// Code generated by mockery v1.0.0 +package automock + +import chart "k8s.io/helm/pkg/proto/hapi/chart" +import internal "github.com/kyma-project/kyma/components/helm-broker/internal" +import mock "github.com/stretchr/testify/mock" +import services "k8s.io/helm/pkg/proto/hapi/services" + +// HelmClient is an autogenerated mock type for the HelmClient type +type HelmClient struct { + mock.Mock +} + +// Delete provides a mock function with given fields: _a0 +func (_m *HelmClient) Delete(_a0 internal.ReleaseName) error { + ret := _m.Called(_a0) + + var r0 error + if rf, ok := ret.Get(0).(func(internal.ReleaseName) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Install provides a mock function with given fields: c, cv, releaseName, namespace +func (_m *HelmClient) Install(c *chart.Chart, cv internal.ChartValues, releaseName internal.ReleaseName, namespace internal.Namespace) (*services.InstallReleaseResponse, error) { + ret := _m.Called(c, cv, releaseName, namespace) + + var r0 *services.InstallReleaseResponse + if rf, ok := ret.Get(0).(func(*chart.Chart, internal.ChartValues, internal.ReleaseName, internal.Namespace) *services.InstallReleaseResponse); ok { + r0 = rf(c, cv, releaseName, namespace) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*services.InstallReleaseResponse) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*chart.Chart, internal.ChartValues, internal.ReleaseName, internal.Namespace) error); ok { + r1 = rf(c, cv, releaseName, namespace) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/helm-broker/internal/broker/automock/instance_bind_data_getter.go b/components/helm-broker/internal/broker/automock/instance_bind_data_getter.go new file mode 100644 index 000000000000..07a50ea50f9b --- /dev/null +++ b/components/helm-broker/internal/broker/automock/instance_bind_data_getter.go @@ -0,0 +1,33 @@ +// Code generated by mockery v1.0.0 +package automock + +import internal "github.com/kyma-project/kyma/components/helm-broker/internal" +import mock "github.com/stretchr/testify/mock" + +// InstanceBindDataGetter is an autogenerated mock type for the InstanceBindDataGetter type +type InstanceBindDataGetter struct { + mock.Mock +} + +// Get provides a mock function with given fields: iID +func (_m *InstanceBindDataGetter) Get(iID internal.InstanceID) (*internal.InstanceBindData, error) { + ret := _m.Called(iID) + + var r0 *internal.InstanceBindData + if rf, ok := ret.Get(0).(func(internal.InstanceID) *internal.InstanceBindData); ok { + r0 = rf(iID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*internal.InstanceBindData) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(internal.InstanceID) error); ok { + r1 = rf(iID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/helm-broker/internal/broker/automock/instance_bind_data_inserter.go b/components/helm-broker/internal/broker/automock/instance_bind_data_inserter.go new file mode 100644 index 000000000000..7c26749134db --- /dev/null +++ b/components/helm-broker/internal/broker/automock/instance_bind_data_inserter.go @@ -0,0 +1,24 @@ +// Code generated by mockery v1.0.0 +package automock + +import internal "github.com/kyma-project/kyma/components/helm-broker/internal" +import mock "github.com/stretchr/testify/mock" + +// InstanceBindDataInserter is an autogenerated mock type for the InstanceBindDataInserter type +type InstanceBindDataInserter struct { + mock.Mock +} + +// Insert provides a mock function with given fields: _a0 +func (_m *InstanceBindDataInserter) Insert(_a0 *internal.InstanceBindData) error { + ret := _m.Called(_a0) + + var r0 error + if rf, ok := ret.Get(0).(func(*internal.InstanceBindData) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/components/helm-broker/internal/broker/automock/instance_bind_data_remover.go b/components/helm-broker/internal/broker/automock/instance_bind_data_remover.go new file mode 100644 index 000000000000..666f419f2e7b --- /dev/null +++ b/components/helm-broker/internal/broker/automock/instance_bind_data_remover.go @@ -0,0 +1,24 @@ +// Code generated by mockery v1.0.0 +package automock + +import internal "github.com/kyma-project/kyma/components/helm-broker/internal" +import mock "github.com/stretchr/testify/mock" + +// InstanceBindDataRemover is an autogenerated mock type for the InstanceBindDataRemover type +type InstanceBindDataRemover struct { + mock.Mock +} + +// Remove provides a mock function with given fields: _a0 +func (_m *InstanceBindDataRemover) Remove(_a0 internal.InstanceID) error { + ret := _m.Called(_a0) + + var r0 error + if rf, ok := ret.Get(0).(func(internal.InstanceID) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/components/helm-broker/internal/broker/automock/instance_state_getter.go b/components/helm-broker/internal/broker/automock/instance_state_getter.go new file mode 100644 index 000000000000..1f7e7d06b688 --- /dev/null +++ b/components/helm-broker/internal/broker/automock/instance_state_getter.go @@ -0,0 +1,107 @@ +package automock + +import "github.com/kyma-project/kyma/components/helm-broker/internal" +import "github.com/stretchr/testify/mock" + +// InstanceStateGetter is an autogenerated mock type for the InstanceStateGetter type +type InstanceStateGetter struct { + mock.Mock +} + +// IsDeprovisioned provides a mock function with given fields: _a0 +func (_m *InstanceStateGetter) IsDeprovisioned(_a0 internal.InstanceID) (bool, error) { + ret := _m.Called(_a0) + + var r0 bool + if rf, ok := ret.Get(0).(func(internal.InstanceID) bool); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func(internal.InstanceID) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// IsDeprovisioningInProgress provides a mock function with given fields: _a0 +func (_m *InstanceStateGetter) IsDeprovisioningInProgress(_a0 internal.InstanceID) (internal.OperationID, bool, error) { + ret := _m.Called(_a0) + + var r0 internal.OperationID + if rf, ok := ret.Get(0).(func(internal.InstanceID) internal.OperationID); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(internal.OperationID) + } + + var r1 bool + if rf, ok := ret.Get(1).(func(internal.InstanceID) bool); ok { + r1 = rf(_a0) + } else { + r1 = ret.Get(1).(bool) + } + + var r2 error + if rf, ok := ret.Get(2).(func(internal.InstanceID) error); ok { + r2 = rf(_a0) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// IsProvisioned provides a mock function with given fields: _a0 +func (_m *InstanceStateGetter) IsProvisioned(_a0 internal.InstanceID) (bool, error) { + ret := _m.Called(_a0) + + var r0 bool + if rf, ok := ret.Get(0).(func(internal.InstanceID) bool); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func(internal.InstanceID) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// IsProvisioningInProgress provides a mock function with given fields: _a0 +func (_m *InstanceStateGetter) IsProvisioningInProgress(_a0 internal.InstanceID) (internal.OperationID, bool, error) { + ret := _m.Called(_a0) + + var r0 internal.OperationID + if rf, ok := ret.Get(0).(func(internal.InstanceID) internal.OperationID); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(internal.OperationID) + } + + var r1 bool + if rf, ok := ret.Get(1).(func(internal.InstanceID) bool); ok { + r1 = rf(_a0) + } else { + r1 = ret.Get(1).(bool) + } + + var r2 error + if rf, ok := ret.Get(2).(func(internal.InstanceID) error); ok { + r2 = rf(_a0) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} diff --git a/components/helm-broker/internal/broker/automock/instance_storage.go b/components/helm-broker/internal/broker/automock/instance_storage.go new file mode 100644 index 000000000000..67639b8dda1e --- /dev/null +++ b/components/helm-broker/internal/broker/automock/instance_storage.go @@ -0,0 +1,60 @@ +package automock + +import "github.com/kyma-project/kyma/components/helm-broker/internal" +import "github.com/stretchr/testify/mock" + +// InstanceStorage is an autogenerated mock type for the InstanceStorage type +type InstanceStorage struct { + mock.Mock +} + +// Get provides a mock function with given fields: id +func (_m *InstanceStorage) Get(id internal.InstanceID) (*internal.Instance, error) { + ret := _m.Called(id) + + var r0 *internal.Instance + if rf, ok := ret.Get(0).(func(internal.InstanceID) *internal.Instance); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*internal.Instance) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(internal.InstanceID) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Insert provides a mock function with given fields: i +func (_m *InstanceStorage) Insert(i *internal.Instance) error { + ret := _m.Called(i) + + var r0 error + if rf, ok := ret.Get(0).(func(*internal.Instance) error); ok { + r0 = rf(i) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Remove provides a mock function with given fields: id +func (_m *InstanceStorage) Remove(id internal.InstanceID) error { + ret := _m.Called(id) + + var r0 error + if rf, ok := ret.Get(0).(func(internal.InstanceID) error); ok { + r0 = rf(id) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/components/helm-broker/internal/broker/automock/operation_storage.go b/components/helm-broker/internal/broker/automock/operation_storage.go new file mode 100644 index 000000000000..a4570b26f8ca --- /dev/null +++ b/components/helm-broker/internal/broker/automock/operation_storage.go @@ -0,0 +1,111 @@ +package automock + +import "github.com/kyma-project/kyma/components/helm-broker/internal" +import "github.com/stretchr/testify/mock" + +// OperationStorage is an autogenerated mock type for the OperationStorage type +type OperationStorage struct { + mock.Mock +} + +// Get provides a mock function with given fields: iID, opID +func (_m *OperationStorage) Get(iID internal.InstanceID, opID internal.OperationID) (*internal.InstanceOperation, error) { + ret := _m.Called(iID, opID) + + var r0 *internal.InstanceOperation + if rf, ok := ret.Get(0).(func(internal.InstanceID, internal.OperationID) *internal.InstanceOperation); ok { + r0 = rf(iID, opID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*internal.InstanceOperation) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(internal.InstanceID, internal.OperationID) error); ok { + r1 = rf(iID, opID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetAll provides a mock function with given fields: iID +func (_m *OperationStorage) GetAll(iID internal.InstanceID) ([]*internal.InstanceOperation, error) { + ret := _m.Called(iID) + + var r0 []*internal.InstanceOperation + if rf, ok := ret.Get(0).(func(internal.InstanceID) []*internal.InstanceOperation); ok { + r0 = rf(iID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*internal.InstanceOperation) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(internal.InstanceID) error); ok { + r1 = rf(iID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Insert provides a mock function with given fields: io +func (_m *OperationStorage) Insert(io *internal.InstanceOperation) error { + ret := _m.Called(io) + + var r0 error + if rf, ok := ret.Get(0).(func(*internal.InstanceOperation) error); ok { + r0 = rf(io) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Remove provides a mock function with given fields: iID, opID +func (_m *OperationStorage) Remove(iID internal.InstanceID, opID internal.OperationID) error { + ret := _m.Called(iID, opID) + + var r0 error + if rf, ok := ret.Get(0).(func(internal.InstanceID, internal.OperationID) error); ok { + r0 = rf(iID, opID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdateState provides a mock function with given fields: iID, opID, state +func (_m *OperationStorage) UpdateState(iID internal.InstanceID, opID internal.OperationID, state internal.OperationState) error { + ret := _m.Called(iID, opID, state) + + var r0 error + if rf, ok := ret.Get(0).(func(internal.InstanceID, internal.OperationID, internal.OperationState) error); ok { + r0 = rf(iID, opID, state) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdateStateDesc provides a mock function with given fields: iID, opID, state, desc +func (_m *OperationStorage) UpdateStateDesc(iID internal.InstanceID, opID internal.OperationID, state internal.OperationState, desc *string) error { + ret := _m.Called(iID, opID, state, desc) + + var r0 error + if rf, ok := ret.Get(0).(func(internal.InstanceID, internal.OperationID, internal.OperationState, *string) error); ok { + r0 = rf(iID, opID, state, desc) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/components/helm-broker/internal/broker/bind.go b/components/helm-broker/internal/broker/bind.go new file mode 100644 index 000000000000..6ab165442436 --- /dev/null +++ b/components/helm-broker/internal/broker/bind.go @@ -0,0 +1,38 @@ +package broker + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + osb "github.com/pmorie/go-open-service-broker-client/v2" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +type bindService struct { + instanceBindDataGetter instanceBindDataGetter +} + +func (svc *bindService) Bind(ctx context.Context, osbCtx osbContext, req *osb.BindRequest) (*osb.BindResponse, error) { + if len(req.Parameters) > 0 { + return nil, fmt.Errorf("helm-broker does not support configuration options for the service binding") + } + + out, err := svc.instanceBindDataGetter.Get(internal.InstanceID(req.InstanceID)) + if err != nil { + return nil, errors.Wrapf(err, "while getting bind data from storage for instance id: %q", req.InstanceID) + } + + return &osb.BindResponse{ + Credentials: svc.dtoFromModel(out.Credentials), + }, nil +} + +func (*bindService) dtoFromModel(in internal.InstanceCredentials) map[string]interface{} { + dto := map[string]interface{}{} + for k, v := range in { + dto[k] = v + } + return dto +} diff --git a/components/helm-broker/internal/broker/bind_export_test.go b/components/helm-broker/internal/broker/bind_export_test.go new file mode 100644 index 000000000000..3e1748023c76 --- /dev/null +++ b/components/helm-broker/internal/broker/bind_export_test.go @@ -0,0 +1,7 @@ +package broker + +func NewBindService(i instanceBindDataGetter) *bindService { + return &bindService{ + instanceBindDataGetter: i, + } +} diff --git a/components/helm-broker/internal/broker/bind_test.go b/components/helm-broker/internal/broker/bind_test.go new file mode 100644 index 000000000000..ca08a3f81c40 --- /dev/null +++ b/components/helm-broker/internal/broker/bind_test.go @@ -0,0 +1,117 @@ +package broker_test + +import ( + "context" + "errors" + "fmt" + "testing" + + osb "github.com/pmorie/go-open-service-broker-client/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/kyma-project/kyma/components/helm-broker/internal/broker" + "github.com/kyma-project/kyma/components/helm-broker/internal/broker/automock" +) + +func TestBindServiceBindSuccess(t *testing.T) { + // given + tc := newBindTC() + defer tc.AssertExpectations(t) + fixID := tc.FixBindRequest().InstanceID + expCreds := map[string]string{ + "password": "secret", + } + tc.ExpectOnGet(fixID, expCreds) + + svc := broker.NewBindService(tc.InstanceBindDataGetter) + osbCtx := broker.NewOSBContext("not", "important") + + // when + resp, err := svc.Bind(context.Background(), *osbCtx, tc.FixBindRequest()) + + // then + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{ + "password": "secret", + }, resp.Credentials) + assert.Nil(t, resp.RouteServiceURL) + assert.Nil(t, resp.SyslogDrainURL) + assert.Nil(t, resp.VolumeMounts) +} + +func TestBindServiceBindFailure(t *testing.T) { + t.Run("On service Get", func(t *testing.T) { + // given + tc := newBindTC() + defer tc.AssertExpectations(t) + fixID := tc.FixBindRequest().InstanceID + fixErr := errors.New("Get ERR") + tc.ExpectOnGetError(fixID, fixErr) + + svc := broker.NewBindService(tc.InstanceBindDataGetter) + osbCtx := broker.NewOSBContext("not", "important") + + // when + resp, err := svc.Bind(context.Background(), *osbCtx, tc.FixBindRequest()) + + // then + require.EqualError(t, err, fmt.Sprintf("while getting bind data from storage for instance id: %q: %v", fixID, fixErr.Error())) + assert.Nil(t, resp) + }) + + t.Run("On unexpected req params", func(t *testing.T) { + // given + tc := newBindTC() + fixReq := tc.FixBindRequest() + fixReq.Parameters = map[string]interface{}{ + "some-key": "some-value", + } + + svc := broker.NewBindService(nil) + osbCtx := broker.NewOSBContext("not", "important") + + // when + resp, err := svc.Bind(context.Background(), *osbCtx, fixReq) + + // then + assert.EqualError(t, err, "helm-broker does not support configuration options for the service binding") + assert.Zero(t, resp) + }) +} + +func newBindTC() *bindServiceTestCase { + return &bindServiceTestCase{ + InstanceBindDataGetter: &automock.InstanceBindDataGetter{}, + } +} + +type bindServiceTestCase struct { + InstanceBindDataGetter *automock.InstanceBindDataGetter +} + +func (tc bindServiceTestCase) AssertExpectations(t *testing.T) { + tc.InstanceBindDataGetter.AssertExpectations(t) +} + +func (tc *bindServiceTestCase) ExpectOnGet(iID string, creds map[string]string) { + tc.InstanceBindDataGetter.On("Get", internal.InstanceID(iID)). + Return(&internal.InstanceBindData{ + InstanceID: internal.InstanceID(iID), + Credentials: internal.InstanceCredentials(creds), + }, nil).Once() +} + +func (tc *bindServiceTestCase) ExpectOnGetError(iID string, err error) { + tc.InstanceBindDataGetter.On("Get", internal.InstanceID(iID)). + Return(nil, err).Once() +} + +func (tc *bindServiceTestCase) FixBindRequest() *osb.BindRequest { + return &osb.BindRequest{ + InstanceID: "instance-id", + ServiceID: "service-id", + PlanID: "plan-id", + } +} diff --git a/components/helm-broker/internal/broker/broker.go b/components/helm-broker/internal/broker/broker.go new file mode 100644 index 000000000000..d5ae82c2421a --- /dev/null +++ b/components/helm-broker/internal/broker/broker.go @@ -0,0 +1,199 @@ +package broker + +import ( + "github.com/Masterminds/semver" + "github.com/sirupsen/logrus" + + "k8s.io/helm/pkg/proto/hapi/chart" + rls "k8s.io/helm/pkg/proto/hapi/services" + + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/kyma-project/kyma/components/helm-broker/internal/platform/idprovider" + "github.com/kyma-project/kyma/components/helm-broker/internal/ybind" +) + +// be aware that after regenerating mocks, manual steps are required +//go:generate mockery -name=bundleStorage -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=chartGetter -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=chartStorage -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=operationStorage -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=instanceStorage -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=helmClient -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=instanceStateGetter -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=instanceBindDataGetter -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=instanceBindDataRemover -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=instanceBindDataInserter -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=bindTemplateRenderer -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=bindTemplateResolver -output=automock -outpkg=automock -case=underscore + +type ( + bundleIDGetter interface { + GetByID(id internal.BundleID) (*internal.Bundle, error) + } + bundleFinder interface { + FindAll() ([]*internal.Bundle, error) + } + bundleStorage interface { + bundleIDGetter + bundleFinder + } + + chartGetter interface { + Get(name internal.ChartName, ver semver.Version) (*chart.Chart, error) + } + chartStorage interface { + chartGetter + } + + operationInserter interface { + Insert(io *internal.InstanceOperation) error + } + operationGetter interface { + Get(iID internal.InstanceID, opID internal.OperationID) (*internal.InstanceOperation, error) + } + operationCollectionGetter interface { + GetAll(iID internal.InstanceID) ([]*internal.InstanceOperation, error) + } + operationUpdater interface { + UpdateState(iID internal.InstanceID, opID internal.OperationID, state internal.OperationState) error + UpdateStateDesc(iID internal.InstanceID, opID internal.OperationID, state internal.OperationState, desc *string) error + } + operationRemover interface { + Remove(iID internal.InstanceID, opID internal.OperationID) error + } + operationStorage interface { + operationInserter + operationGetter + operationCollectionGetter + operationUpdater + operationRemover + } + + instanceInserter interface { + Insert(i *internal.Instance) error + } + instanceGetter interface { + Get(id internal.InstanceID) (*internal.Instance, error) + } + instanceRemover interface { + Remove(id internal.InstanceID) error + } + instanceStorage interface { + instanceInserter + instanceGetter + instanceRemover + } + + instanceStateProvisionGetter interface { + IsProvisioned(internal.InstanceID) (bool, error) + IsProvisioningInProgress(internal.InstanceID) (internal.OperationID, bool, error) + } + + instanceStateDeprovisionGetter interface { + IsDeprovisioned(internal.InstanceID) (bool, error) + IsDeprovisioningInProgress(internal.InstanceID) (internal.OperationID, bool, error) + } + + instanceStateGetter interface { + instanceStateProvisionGetter + instanceStateDeprovisionGetter + } + + helmInstaller interface { + Install(c *chart.Chart, cv internal.ChartValues, releaseName internal.ReleaseName, namespace internal.Namespace) (*rls.InstallReleaseResponse, error) + } + helmDeleter interface { + Delete(internal.ReleaseName) error + } + helmClient interface { + helmInstaller + helmDeleter + } + + instanceBindDataGetter interface { + Get(iID internal.InstanceID) (*internal.InstanceBindData, error) + } + + instanceBindDataInserter interface { + Insert(*internal.InstanceBindData) error + } + + instanceBindDataRemover interface { + Remove(internal.InstanceID) error + } + + instanceBindDataStorage interface { + instanceBindDataGetter + instanceBindDataInserter + instanceBindDataRemover + } + + bindTemplateRenderer interface { + Render(bindTemplate internal.BundlePlanBindTemplate, resp *rls.InstallReleaseResponse) (ybind.RenderedBindYAML, error) + } + + bindTemplateResolver interface { + Resolve(bindYAML ybind.RenderedBindYAML, ns internal.Namespace) (*ybind.ResolveOutput, error) + } +) + +// New creates instance of broker. +func New(bs bundleStorage, cs chartStorage, os operationStorage, is instanceStorage, ibd instanceBindDataStorage, + bindTmplRenderer bindTemplateRenderer, bindTmplResolver bindTemplateResolver, hc helmClient, log *logrus.Entry) *Server { + idpRaw := idprovider.New() + idp := func() (internal.OperationID, error) { + idRaw, err := idpRaw() + if err != nil { + return internal.OperationID(""), err + } + return internal.OperationID(idRaw), nil + } + + return newWithIDProvider(bs, cs, os, is, ibd, bindTmplRenderer, bindTmplResolver, hc, log, idp) +} + +func newWithIDProvider(bs bundleStorage, cs chartStorage, os operationStorage, is instanceStorage, ibd instanceBindDataStorage, + bindTmplRenderer bindTemplateRenderer, bindTmplResolver bindTemplateResolver, hc helmClient, + log *logrus.Entry, idp func() (internal.OperationID, error)) *Server { + return &Server{ + catalogGetter: &catalogService{ + finder: bs, + conv: &bundleToServiceConverter{}, + }, + provisioner: &provisionService{ + bundleIDGetter: bs, + chartGetter: cs, + instanceInserter: is, + instanceStateGetter: &instanceStateService{ + operationCollectionGetter: os, + }, + operationInserter: os, + operationUpdater: os, + operationIDProvider: idp, + helmInstaller: hc, + log: log.WithField("service", "provisioner"), + bindTemplateRenderer: bindTmplRenderer, + bindTemplateResolver: bindTmplResolver, + instanceBindDataInserter: ibd, + }, + deprovisioner: &deprovisionService{ + instanceGetter: is, + operationInserter: os, + instanceStateGetter: &instanceStateService{ + operationCollectionGetter: os, + }, + operationUpdater: os, + instanceBindDataRemover: ibd, + operationIDProvider: idp, + helmDeleter: hc, + }, + binder: &bindService{ + instanceBindDataGetter: ibd, + }, + unbinder: &unbindService{}, + lastOpGetter: &getLastOperationService{ + getter: os, + }, + logger: log.WithField("service", "server"), + } +} diff --git a/components/helm-broker/internal/broker/broker_export_test.go b/components/helm-broker/internal/broker/broker_export_test.go new file mode 100644 index 000000000000..47a4bdc1a201 --- /dev/null +++ b/components/helm-broker/internal/broker/broker_export_test.go @@ -0,0 +1,13 @@ +package broker + +import ( + "github.com/sirupsen/logrus" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +func NewWithIDProvider(bs bundleStorage, cs chartStorage, os operationStorage, is instanceStorage, ibd instanceBindDataStorage, + bindTmplRenderer bindTemplateRenderer, bindTmplResolver bindTemplateResolver, + hc helmClient, log *logrus.Entry, idp func() (internal.OperationID, error)) *Server { + return newWithIDProvider(bs, cs, os, is, ibd, bindTmplRenderer, bindTmplResolver, hc, log, idp) +} diff --git a/components/helm-broker/internal/broker/catalog.go b/components/helm-broker/internal/broker/catalog.go new file mode 100644 index 000000000000..2daed1dbf080 --- /dev/null +++ b/components/helm-broker/internal/broker/catalog.go @@ -0,0 +1,97 @@ +package broker + +import ( + "context" + + "github.com/pkg/errors" + + osb "github.com/pmorie/go-open-service-broker-client/v2" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +type catalogService struct { + finder bundleFinder + conv converter +} + +//go:generate mockery -name=converter -output=automock -outpkg=automock -case=underscore +type converter interface { + Convert(b *internal.Bundle) (osb.Service, error) +} + +// TODO: switch from osb.CatalogResponse to CatalogSuccessResponseDTO +func (svc *catalogService) GetCatalog(ctx context.Context, osbCtx osbContext) (*osb.CatalogResponse, error) { + bundles, err := svc.finder.FindAll() + if err != nil { + return nil, errors.Wrap(err, "while finding all bundles") + } + + resp := osb.CatalogResponse{} + resp.Services = make([]osb.Service, len(bundles)) + for idx, b := range bundles { + s, err := svc.conv.Convert(b) + if err != nil { + return nil, errors.Wrap(err, "while converting bundle to service") + } + resp.Services[idx] = s + } + return &resp, nil +} + +type bundleToServiceConverter struct{} + +func (f *bundleToServiceConverter) Convert(b *internal.Bundle) (osb.Service, error) { + sPlans := make([]osb.Plan, 0) + for _, bPlan := range b.Plans { + + sPlan := osb.Plan{ + ID: string(bPlan.ID), + Name: string(bPlan.Name), + Bindable: bPlan.Bindable, + Description: bPlan.Description, + ParameterSchemas: &osb.ParameterSchemas{ + ServiceInstances: &osb.ServiceInstanceSchema{ + Create: &osb.InputParameters{}, + Update: &osb.InputParameters{}, + }, + ServiceBindings: &osb.ServiceBindingSchema{ + Create: &osb.InputParameters{}, + }, + }, + Metadata: bPlan.Metadata.ToMap(), + } + if provisionSchema, exists := bPlan.Schemas[internal.SchemaTypeProvision]; exists { + sPlan.ParameterSchemas.ServiceInstances.Create = &osb.InputParameters{ + Parameters: provisionSchema, + } + } + if updateSchema, exists := bPlan.Schemas[internal.SchemaTypeUpdate]; exists { + sPlan.ParameterSchemas.ServiceInstances.Update = &osb.InputParameters{ + Parameters: updateSchema, + } + } + if bindSchema, exists := bPlan.Schemas[internal.SchemaTypeBind]; exists { + sPlan.ParameterSchemas.ServiceBindings.Create = &osb.InputParameters{ + Parameters: bindSchema, + } + } + + sPlans = append(sPlans, sPlan) + } + + var sTags []string + for _, tag := range b.Tags { + sTags = append(sTags, string(tag)) + } + + return osb.Service{ + ID: string(b.ID), + Name: string(b.Name), + Description: b.Description, + Bindable: b.Bindable, + Plans: sPlans, + Metadata: b.Metadata.ToMap(), + Tags: sTags, + }, nil +} diff --git a/components/helm-broker/internal/broker/catalog_export_test.go b/components/helm-broker/internal/broker/catalog_export_test.go new file mode 100644 index 000000000000..b26c5bf6f902 --- /dev/null +++ b/components/helm-broker/internal/broker/catalog_export_test.go @@ -0,0 +1,10 @@ +package broker + +func NewCatalogService(finder bundleFinder, conv converter) *catalogService { + return &catalogService{finder: finder, conv: conv} +} + +//noinspection GoExportedFuncWithUnexportedType +func NewConverter() bundleToServiceConverter { + return bundleToServiceConverter{} +} diff --git a/components/helm-broker/internal/broker/catalog_test.go b/components/helm-broker/internal/broker/catalog_test.go new file mode 100644 index 000000000000..a7cec70e5a74 --- /dev/null +++ b/components/helm-broker/internal/broker/catalog_test.go @@ -0,0 +1,211 @@ +package broker_test + +import ( + "context" + "fmt" + "testing" + + "github.com/Masterminds/semver" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + osb "github.com/pmorie/go-open-service-broker-client/v2" + + "github.com/kyma-project/kyma/components/helm-broker/platform/ptr" + + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/kyma-project/kyma/components/helm-broker/internal/broker" + "github.com/kyma-project/kyma/components/helm-broker/internal/broker/automock" +) + +func TestGetCatalog(t *testing.T) { + // GIVEN + tc := newCatalogTC() + defer tc.AssertExpectations(t) + tc.finderMock.On("FindAll").Return(tc.fixBundles(), nil).Once() + tc.converterMock.On("Convert", tc.fixBundle()).Return(tc.fixService(), nil) + + svc := broker.NewCatalogService(tc.finderMock, tc.converterMock) + osbCtx := broker.NewOSBContext("not", "important") + // WHEN + resp, err := svc.GetCatalog(context.Background(), *osbCtx) + // THEN + assert.Nil(t, err) + require.NotNil(t, resp) + require.Len(t, resp.Services, 1) + assert.Equal(t, tc.fixService(), resp.Services[0]) + +} + +func TestGetCatalogOnFindError(t *testing.T) { + // GIVEN + tc := newCatalogTC() + defer tc.AssertExpectations(t) + tc.finderMock.On("FindAll").Return(nil, tc.fixError()).Once() + svc := broker.NewCatalogService(tc.finderMock, nil) + osbCtx := broker.NewOSBContext("not", "important") + // WHEN + _, err := svc.GetCatalog(context.Background(), *osbCtx) + // THEN + assert.EqualError(t, err, fmt.Sprintf("while finding all bundles: %v", tc.fixError())) + +} + +func TestGetCatalogOnConversionError(t *testing.T) { + // GIVEN + tc := newCatalogTC() + defer tc.AssertExpectations(t) + + tc.finderMock.On("FindAll").Return(tc.fixBundles(), nil).Once() + tc.converterMock.On("Convert", tc.fixBundle()).Return(osb.Service{}, tc.fixError()) + + svc := broker.NewCatalogService(tc.finderMock, tc.converterMock) + osbCtx := broker.NewOSBContext("not", "important") + // WHEN + _, err := svc.GetCatalog(context.Background(), *osbCtx) + // THEN + assert.EqualError(t, err, fmt.Sprintf("while converting bundle to service: %v", tc.fixError())) + +} + +func TestBundleConversion(t *testing.T) { + // GIVEN + tc := newCatalogTC() + fixBundle := tc.fixBundle() + conv := broker.NewConverter() + // WHEN + s, err := conv.Convert(fixBundle) + // THEN + assert.NoError(t, err) + assert.Equal(t, "bundleID", s.ID) + assert.Equal(t, "bundleName", s.Name) + assert.Equal(t, "bundleDescription", s.Description) + assert.True(t, s.Bindable) + assert.Equal(t, map[string]interface{}{ + "displayName": fixBundle.Metadata.DisplayName, + "providerDisplayName": fixBundle.Metadata.ProviderDisplayName, + "longDescription": fixBundle.Metadata.LongDescription, + "documentationURL": fixBundle.Metadata.DocumentationURL, + "supportURL": fixBundle.Metadata.SupportURL, + "imageURL": fixBundle.Metadata.ImageURL, + }, s.Metadata) + + require.Len(t, s.Plans, 2) + + var p1, p2 osb.Plan + if s.Plans[0].ID == "planID1" { + p1 = s.Plans[0] + p2 = s.Plans[1] + } else { + p2 = s.Plans[0] + p1 = s.Plans[1] + } + + assert.Equal(t, "planID1", p1.ID) + assert.Equal(t, "plan1Description", p1.Description) + assert.Equal(t, "plan1Name", p1.Name) + require.NotNil(t, p1.Bindable) + assert.True(t, *p1.Bindable) + assert.Equal(t, tc.fixProvisionSchema(), p1.ParameterSchemas.ServiceInstances.Create.Parameters) + assert.Equal(t, tc.fixUpdateSchema(), p1.ParameterSchemas.ServiceInstances.Update.Parameters) + assert.Equal(t, tc.fixBindSchema(), p1.ParameterSchemas.ServiceBindings.Create.Parameters) + assert.Equal(t, map[string]interface{}{ + "displayName": fixBundle.Plans["planID1"].Metadata.DisplayName, + }, p1.Metadata) + + assert.Equal(t, "planID2", p2.ID) + assert.Equal(t, "plan2Description", p2.Description) + assert.Equal(t, "plan2Name", p2.Name) + require.NotNil(t, p2.Bindable) + assert.True(t, *p2.Bindable) + assert.Nil(t, p2.ParameterSchemas.ServiceInstances.Create.Parameters) + assert.Nil(t, p2.ParameterSchemas.ServiceInstances.Update.Parameters) + assert.Nil(t, p2.ParameterSchemas.ServiceBindings.Create.Parameters) + assert.Equal(t, map[string]interface{}{ + "displayName": fixBundle.Plans["planID2"].Metadata.DisplayName, + }, p2.Metadata) + assert.Equal(t, []string{"awesome-tag"}, s.Tags) +} + +type catalogTestCase struct { + finderMock *automock.BundleStorage + converterMock *automock.Converter +} + +func newCatalogTC() *catalogTestCase { + return &catalogTestCase{ + finderMock: &automock.BundleStorage{}, + converterMock: &automock.Converter{}, + } +} + +func (tc catalogTestCase) AssertExpectations(t *testing.T) { + tc.finderMock.AssertExpectations(t) + tc.converterMock.AssertExpectations(t) +} + +func (tc catalogTestCase) fixBundles() []*internal.Bundle { + return []*internal.Bundle{tc.fixBundle()} +} + +func (tc catalogTestCase) fixBundle() *internal.Bundle { + return &internal.Bundle{ + Name: "bundleName", + ID: "bundleID", + Description: "bundleDescription", + Bindable: true, + Version: *semver.MustParse("1.2.3"), + Metadata: internal.BundleMetadata{ + DisplayName: "DisplayName", + ProviderDisplayName: "ProviderDisplayName", + LongDescription: "LongDescription", + DocumentationURL: "DocumentationURL", + SupportURL: "SupportURL", + ImageURL: "ImageURL", + }, + Tags: []internal.BundleTag{"awesome-tag"}, + Plans: map[internal.BundlePlanID]internal.BundlePlan{ + "planID1": { + ID: "planID1", + Description: "plan1Description", + Name: "plan1Name", + Schemas: map[internal.PlanSchemaType]internal.PlanSchema{ + internal.SchemaTypeProvision: tc.fixProvisionSchema(), + internal.SchemaTypeUpdate: tc.fixUpdateSchema(), + internal.SchemaTypeBind: tc.fixBindSchema(), + }, + Metadata: internal.BundlePlanMetadata{ + DisplayName: "displayName-1", + }, + Bindable: ptr.Bool(true), + }, + "planID2": { + ID: "planID2", + Description: "plan2Description", + Name: "plan2Name", + Bindable: ptr.Bool(true), + }, + }, + } +} + +func (tc catalogTestCase) fixProvisionSchema() internal.PlanSchema { + return internal.PlanSchema{} +} + +func (tc catalogTestCase) fixUpdateSchema() internal.PlanSchema { + return internal.PlanSchema{} +} + +func (tc catalogTestCase) fixBindSchema() internal.PlanSchema { + return internal.PlanSchema{} +} + +func (tc catalogTestCase) fixService() osb.Service { + return osb.Service{ID: "bundleID"} +} + +func (tc catalogTestCase) fixError() error { + return errors.New("some error") +} diff --git a/components/helm-broker/internal/broker/ctx.go b/components/helm-broker/internal/broker/ctx.go new file mode 100644 index 000000000000..167d2d0cf7a9 --- /dev/null +++ b/components/helm-broker/internal/broker/ctx.go @@ -0,0 +1,18 @@ +package broker + +import "context" + +type contextKey int + +const ( + osbContextKey contextKey = 5001 +) + +func contextWithOSB(ctx context.Context, osbCtx osbContext) context.Context { + return context.WithValue(ctx, osbContextKey, osbCtx) +} + +func osbContextFromContext(ctx context.Context) (osbContext, bool) { + osbCtx, ok := ctx.Value(osbContextKey).(osbContext) + return osbCtx, ok +} diff --git a/components/helm-broker/internal/broker/deprovision.go b/components/helm-broker/internal/broker/deprovision.go new file mode 100644 index 000000000000..4cd8984ba10d --- /dev/null +++ b/components/helm-broker/internal/broker/deprovision.go @@ -0,0 +1,144 @@ +package broker + +import ( + "context" + "fmt" + "sync" + + "github.com/pkg/errors" + + osb "github.com/pmorie/go-open-service-broker-client/v2" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +type deprovisionService struct { + instanceGetter instanceGetter + instanceStateGetter instanceStateDeprovisionGetter + operationInserter operationInserter + operationUpdater operationUpdater + instanceBindDataRemover instanceBindDataRemover + operationIDProvider func() (internal.OperationID, error) + helmDeleter helmDeleter + + mu sync.Mutex + + testHookAsyncCalled func(internal.OperationID) +} + +func (svc *deprovisionService) Deprovision(ctx context.Context, osbCtx osbContext, req *osb.DeprovisionRequest) (*osb.DeprovisionResponse, error) { + if !req.AcceptsIncomplete { + return nil, errors.New("asynchronous operation mode required") + } + + // Single deprovisioning is supported concurrently. + // TODO: switch to lock per instanceID + svc.mu.Lock() + defer svc.mu.Unlock() + + iID := internal.InstanceID(req.InstanceID) + + switch state, err := svc.instanceStateGetter.IsDeprovisioned(iID); true { + case IsNotFoundError(err): + return nil, err + case err != nil: + return nil, errors.Wrap(err, "while checking if instance is already deprovisioned") + case state: + return &osb.DeprovisionResponse{Async: false}, nil + } + + switch opIDInProgress, inProgress, err := svc.instanceStateGetter.IsDeprovisioningInProgress(iID); true { + case IsNotFoundError(err): + return nil, err + case err != nil: + return nil, errors.Wrap(err, "while checking if instance is being deprovisioned") + case inProgress: + opKeyInProgress := osb.OperationKey(opIDInProgress) + return &osb.DeprovisionResponse{Async: true, OperationKey: &opKeyInProgress}, nil + } + + id, err := svc.operationIDProvider() + if err != nil { + return nil, errors.Wrap(err, "while generating ID for operation") + } + opID := internal.OperationID(id) + + i, err := svc.instanceGetter.Get(iID) + switch { + case IsNotFoundError(err): + return nil, err + case err != nil: + return nil, errors.Wrap(err, "while getting instance") + } + + // TODO: check if svcID/planID from request are matching the one from instance + //svcID := internal.ServiceID(req.ServiceID) + //svcPlanID := internal.ServicePlanID(req.PlanID) + + // TODO: add support for calculating ParamHash + paramHash := "TODO" + + op := internal.InstanceOperation{ + InstanceID: iID, + OperationID: opID, + Type: internal.OperationTypeRemove, + State: internal.OperationStateInProgress, + ParamsHash: paramHash, + } + + if err := svc.operationInserter.Insert(&op); err != nil { + return nil, errors.Wrap(err, "while inserting instance operation to storage") + } + + svc.doAsync(ctx, iID, opID, i.Namespace, i.ReleaseName) + + opKey := osb.OperationKey(op.OperationID) + resp := &osb.DeprovisionResponse{ + OperationKey: &opKey, + Async: true, + } + + return resp, nil +} + +func (svc *deprovisionService) doAsync(ctx context.Context, iID internal.InstanceID, opID internal.OperationID, namespace internal.Namespace, releaseName internal.ReleaseName) { + if svc.testHookAsyncCalled != nil { + svc.testHookAsyncCalled(opID) + } + go svc.do(ctx, iID, opID, namespace, releaseName) +} + +// do is called asynchronously +func (svc *deprovisionService) do(ctx context.Context, iID internal.InstanceID, opID internal.OperationID, namespace internal.Namespace, releaseName internal.ReleaseName) { + + fDo := func() error { + if err := svc.helmDeleter.Delete(releaseName); err != nil { + return errors.Wrap(err, "while deleting helm release") + } + + err := svc.instanceBindDataRemover.Remove(iID) + switch { + // we are not checking if instance was bindable and because of that NotFound error is also in happy path + // BEWARE: such solution can produce false positive errors e.g. + // 1. We are executing remove of data even if instance is not bindable (no data are stored) + // 2. We are getting error on connection to storage, so notFound error cannot be returned + // 3. Then deprovisioning is wrongly marked as failed + case err == nil, IsNotFoundError(err): + default: + return fmt.Errorf("cannot remove instance bind data from storage: %s", err.Error()) + } + + return nil + } + + opState := internal.OperationStateSucceeded + opDesc := "deprovisioning succeeded" + if err := fDo(); err != nil { + opState = internal.OperationStateFailed + opDesc = fmt.Sprintf("deprovisioning failed on error: %s", err.Error()) + } + + if err := svc.operationUpdater.UpdateStateDesc(iID, opID, opState, &opDesc); err != nil { + // TODO: create event from broker and log as we are not able to propagate failure to service catalog + } +} diff --git a/components/helm-broker/internal/broker/deprovision_export_test.go b/components/helm-broker/internal/broker/deprovision_export_test.go new file mode 100644 index 000000000000..8310cf259844 --- /dev/null +++ b/components/helm-broker/internal/broker/deprovision_export_test.go @@ -0,0 +1,20 @@ +package broker + +import "github.com/kyma-project/kyma/components/helm-broker/internal" + +func NewDeprovisionService(ig instanceGetter, oi operationInserter, ou operationUpdater, ibdr instanceBindDataRemover, hd helmDeleter, oIDProv func() (internal.OperationID, error), isg instanceStateGetter) *deprovisionService { + return &deprovisionService{ + instanceGetter: ig, + instanceStateGetter: isg, + operationInserter: oi, + operationUpdater: ou, + operationIDProvider: oIDProv, + instanceBindDataRemover: ibdr, + helmDeleter: hd, + } +} + +func (svc *deprovisionService) WithTestHookOnAsyncCalled(h func(internal.OperationID)) *deprovisionService { + svc.testHookAsyncCalled = h + return svc +} diff --git a/components/helm-broker/internal/broker/deprovision_test.go b/components/helm-broker/internal/broker/deprovision_test.go new file mode 100644 index 000000000000..c359426e3a97 --- /dev/null +++ b/components/helm-broker/internal/broker/deprovision_test.go @@ -0,0 +1,383 @@ +package broker_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + osb "github.com/pmorie/go-open-service-broker-client/v2" + + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/kyma-project/kyma/components/helm-broker/internal/broker" + "github.com/kyma-project/kyma/components/helm-broker/internal/broker/automock" +) + +func TestDeprovisionServiceDeprovisionSuccess(t *testing.T) { + // GIVEN + ts := newDeprovisionServiceTestSuite(t) + ts.SetUp() + + defer ts.AssertExpectations(t) + + ts.InstStateGetterMock.ExpectOnIsDeprovisioned(ts.Exp.InstanceID, false).Once() + ts.InstStateGetterMock.ExpectOnIsDeprovisioningInProgress(ts.Exp.InstanceID, internal.OperationID(""), false).Once() + + ts.InstStorageMock.ExpectOnGet(ts.Exp.InstanceID, ts.FixInstance()).Once() + + ts.OpStorageMock.ExpectOnInsert(ts.FixInstanceOperation()).Once() + ts.OpStorageMock.ExpectOnUpdateStateDesc(ts.Exp.InstanceID, ts.Exp.OperationID, internal.OperationStateSucceeded, "deprovisioning succeeded"). + Run(func(args mock.Arguments) { + close(ts.UpdateStateDescMethodCalled) + }).Once() + + ts.HelmClientMock.ExpectOnDelete(ts.Exp.ReleaseName).Once() + + ts.InstBindDataMock.ExpectOnRemove(ts.Exp.InstanceID).Once() + + ts.OpIDProviderFake = func() (internal.OperationID, error) { + return ts.Exp.OperationID, nil + } + + svc := broker.NewDeprovisionService(ts.GetAllMocks()) + + osbCtx := *broker.NewOSBContext("", "v1") + req := ts.FixDeprovisionRequest() + + // WHEN + resp, err := svc.Deprovision(context.Background(), osbCtx, &req) + + // THEN + assert.NoError(t, err) + assert.True(t, resp.Async) + assert.EqualValues(t, ts.Exp.OperationID, *resp.OperationKey) + + select { + case <-ts.UpdateStateDescMethodCalled: + case <-time.After(time.Millisecond * 100): + t.Fatal("timeout on operation succeeded") + } +} + +func TestDeprovisionServiceDeprovisionFailureAsync(t *testing.T) { + fixErr := errors.New("fake Err") + + for tn, testCaseSetExpectionOnMocks := range map[string]func(ts *deprovisionServiceTestSuite){ + "on Helm Delete": func(ts *deprovisionServiceTestSuite) { + ts.InstStateGetterMock.ExpectOnIsDeprovisioned(ts.Exp.InstanceID, false).Once() + ts.InstStateGetterMock.ExpectOnIsDeprovisioningInProgress(ts.Exp.InstanceID, internal.OperationID(""), false).Once() + + ts.InstStorageMock.ExpectOnGet(ts.Exp.InstanceID, ts.FixInstance()).Once() + + ts.OpStorageMock.ExpectOnInsert(ts.FixInstanceOperation()).Once() + expDesc := fmt.Sprintf("deprovisioning failed on error: while deleting helm release: %s", fixErr) + ts.OpStorageMock.ExpectOnUpdateStateDesc(ts.Exp.InstanceID, ts.Exp.OperationID, internal.OperationStateFailed, expDesc). + Run(func(args mock.Arguments) { + close(ts.UpdateStateDescMethodCalled) + }).Once() + + ts.HelmClientMock.ExpectErrorOnDelete(ts.Exp.ReleaseName, fixErr).Once() + }, + "on bind data Remove": func(ts *deprovisionServiceTestSuite) { + ts.InstStateGetterMock.ExpectOnIsDeprovisioned(ts.Exp.InstanceID, false).Once() + ts.InstStateGetterMock.ExpectOnIsDeprovisioningInProgress(ts.Exp.InstanceID, internal.OperationID(""), false).Once() + + ts.InstStorageMock.ExpectOnGet(ts.Exp.InstanceID, ts.FixInstance()).Once() + + ts.OpStorageMock.ExpectOnInsert(ts.FixInstanceOperation()).Once() + expDesc := fmt.Sprintf("deprovisioning failed on error: cannot remove instance bind data from storage: %s", fixErr) + ts.OpStorageMock.ExpectOnUpdateStateDesc(ts.Exp.InstanceID, ts.Exp.OperationID, internal.OperationStateFailed, expDesc). + Run(func(args mock.Arguments) { + close(ts.UpdateStateDescMethodCalled) + }).Once() + + ts.HelmClientMock.ExpectOnDelete(ts.Exp.ReleaseName).Once() + + ts.InstBindDataMock.ExpectErrorRemove(ts.Exp.InstanceID, fixErr).Once() + }, + } { + t.Run(tn, func(t *testing.T) { + // GIVEN + ts := newDeprovisionServiceTestSuite(t) + ts.SetUp() + + defer ts.AssertExpectations(t) + + testCaseSetExpectionOnMocks(ts) + + ts.OpIDProviderFake = func() (internal.OperationID, error) { + return ts.Exp.OperationID, nil + } + + svc := broker.NewDeprovisionService(ts.GetAllMocks()) + + osbCtx := *broker.NewOSBContext("", "v1") + req := ts.FixDeprovisionRequest() + + // WHEN + resp, err := svc.Deprovision(context.Background(), osbCtx, &req) + + // THEN + assert.NoError(t, err) + assert.True(t, resp.Async) + assert.EqualValues(t, ts.Exp.OperationID, *resp.OperationKey) + + select { + case <-ts.UpdateStateDescMethodCalled: + case <-time.After(time.Millisecond * 100): + t.Fatal("timeout on operation failed") + } + }) + } + +} + +func TestDeprovisionServiceDeprovisionSuccessOnAlreadyDeprovisionedInstance(t *testing.T) { + // GIVEN + ts := newDeprovisionServiceTestSuite(t) + ts.SetUp() + + defer ts.AssertExpectations(t) + + ts.InstStateGetterMock.ExpectOnIsDeprovisioned(ts.Exp.InstanceID, true).Once() + + ts.OpIDProviderFake = func() (internal.OperationID, error) { + t.Error("operation ID provider called when it should not be") + return ts.Exp.OperationID, nil + } + + testHookCalled := make(chan struct{}) + + svc := broker.NewDeprovisionService(ts.GetAllMocks()). + WithTestHookOnAsyncCalled(func(internal.OperationID) { close(testHookCalled) }) + + osbCtx := *broker.NewOSBContext("", "v1") + req := ts.FixDeprovisionRequest() + + // WHEN + resp, err := svc.Deprovision(context.Background(), osbCtx, &req) + + // THEN + assert.NoError(t, err) + assert.False(t, resp.Async) + assert.Nil(t, resp.OperationKey) + + select { + case <-testHookCalled: + t.Fatal("async test hook called") + default: + } +} + +func TestDeprovisionServiceDeprovisionSuccessOnDeprovisioningInProgressInstance(t *testing.T) { + // GIVEN + ts := newDeprovisionServiceTestSuite(t) + ts.SetUp() + + defer ts.AssertExpectations(t) + + ts.InstStateGetterMock.ExpectOnIsDeprovisioned(ts.Exp.InstanceID, false).Once() + ts.InstStateGetterMock.ExpectOnIsDeprovisioningInProgress(ts.Exp.InstanceID, ts.Exp.OperationID, true).Once() + + ts.OpIDProviderFake = func() (internal.OperationID, error) { + t.Error("operation ID provider called when it should not be") + return ts.Exp.OperationID, nil + } + + testHookCalled := make(chan struct{}) + + svc := broker.NewDeprovisionService(ts.GetAllMocks()). + WithTestHookOnAsyncCalled(func(internal.OperationID) { close(testHookCalled) }) + + osbCtx := *broker.NewOSBContext("", "v1") + req := ts.FixDeprovisionRequest() + + // WHEN + resp, err := svc.Deprovision(context.Background(), osbCtx, &req) + + // THEN + assert.NoError(t, err) + assert.True(t, resp.Async) + assert.EqualValues(t, ts.Exp.OperationID, *resp.OperationKey) + + select { + case <-testHookCalled: + t.Fatal("async test hook called") + default: + } +} + +func TestDeprovisionServiceDeprovisionFailureNotFoundOnIsDeprovisionedCheck(t *testing.T) { + // GIVEN + ts := newDeprovisionServiceTestSuite(t) + ts.SetUp() + + defer ts.AssertExpectations(t) + + ts.InstStateGetterMock.ExpectErrorIsDeprovisioned(ts.Exp.InstanceID, notFoundError{}).Once() + + ts.OpIDProviderFake = func() (internal.OperationID, error) { + t.Error("operation ID provider called when it should not be") + return ts.Exp.OperationID, nil + } + + testHookCalled := make(chan struct{}) + + svc := broker.NewDeprovisionService(ts.GetAllMocks()). + WithTestHookOnAsyncCalled(func(internal.OperationID) { close(testHookCalled) }) + + osbCtx := *broker.NewOSBContext("", "v1") + req := ts.FixDeprovisionRequest() + + // WHEN + _, err := svc.Deprovision(context.Background(), osbCtx, &req) + + // THEN + assert.True(t, broker.IsNotFoundError(err)) + + select { + case <-testHookCalled: + t.Fatal("async test hook called") + default: + } +} + +func TestDeprovisionServiceDeprovisionFailureNotFoundOnIsDeprovisioningInProgressCheck(t *testing.T) { + // GIVEN + ts := newDeprovisionServiceTestSuite(t) + ts.SetUp() + + defer ts.AssertExpectations(t) + + ts.InstStateGetterMock.ExpectOnIsDeprovisioned(ts.Exp.InstanceID, false).Once() + ts.InstStateGetterMock.ExpectErrorOnIsDeprovisioningInProgress(ts.Exp.InstanceID, notFoundError{}).Once() + + ts.OpIDProviderFake = func() (internal.OperationID, error) { + t.Error("operation ID provider called when it should not be") + return ts.Exp.OperationID, nil + } + + testHookCalled := make(chan struct{}) + + svc := broker.NewDeprovisionService(ts.GetAllMocks()). + WithTestHookOnAsyncCalled(func(internal.OperationID) { close(testHookCalled) }) + + osbCtx := *broker.NewOSBContext("", "v1") + req := ts.FixDeprovisionRequest() + + // WHEN + _, err := svc.Deprovision(context.Background(), osbCtx, &req) + + // THEN + assert.True(t, broker.IsNotFoundError(err)) + + select { + case <-testHookCalled: + t.Fatal("async test hook called") + default: + } +} + +func TestDeprovisionServiceDeprovisionFailureNotFoundOnGettingInstance(t *testing.T) { + // GIVEN + ts := newDeprovisionServiceTestSuite(t) + ts.SetUp() + + defer ts.AssertExpectations(t) + + ts.InstStateGetterMock.ExpectOnIsDeprovisioned(ts.Exp.InstanceID, false).Once() + ts.InstStateGetterMock.ExpectOnIsDeprovisioningInProgress(ts.Exp.InstanceID, internal.OperationID(""), false).Once() + + ts.InstStorageMock.ExpectErrorOnGet(ts.Exp.InstanceID, notFoundError{}) + + ts.OpIDProviderFake = func() (internal.OperationID, error) { + return ts.Exp.OperationID, nil + } + + testHookCalled := make(chan struct{}) + + svc := broker.NewDeprovisionService(ts.GetAllMocks()). + WithTestHookOnAsyncCalled(func(internal.OperationID) { close(testHookCalled) }) + + osbCtx := *broker.NewOSBContext("", "v1") + req := ts.FixDeprovisionRequest() + + // WHEN + _, err := svc.Deprovision(context.Background(), osbCtx, &req) + + // THEN + assert.True(t, broker.IsNotFoundError(err)) + + select { + case <-testHookCalled: + t.Fatal("async test hook called") + default: + } +} + +func newDeprovisionServiceTestSuite(t *testing.T) *deprovisionServiceTestSuite { + return &deprovisionServiceTestSuite{ + t: t, + InstStateGetterMock: &automock.InstanceStateGetter{}, + InstStorageMock: &automock.InstanceStorage{}, + InstBindDataMock: &automock.InstanceBindDataRemover{}, + OpStorageMock: &automock.OperationStorage{}, + HelmClientMock: &automock.HelmClient{}, + UpdateStateDescMethodCalled: make(chan struct{}), + } +} + +type deprovisionServiceTestSuite struct { + t *testing.T + + Exp expAll + + InstStateGetterMock *automock.InstanceStateGetter + InstStorageMock *automock.InstanceStorage + OpStorageMock *automock.OperationStorage + HelmClientMock *automock.HelmClient + InstBindDataMock *automock.InstanceBindDataRemover + OpIDProviderFake func() (internal.OperationID, error) + UpdateStateDescMethodCalled chan struct{} +} + +func (ts *deprovisionServiceTestSuite) AssertExpectations(t *testing.T) { + ts.InstStateGetterMock.AssertExpectations(t) + ts.InstStorageMock.AssertExpectations(t) + ts.InstBindDataMock.AssertExpectations(t) + ts.OpStorageMock.AssertExpectations(t) + ts.HelmClientMock.AssertExpectations(t) +} + +func (ts *deprovisionServiceTestSuite) GetAllMocks() (*automock.InstanceStorage, *automock.OperationStorage, *automock.OperationStorage, *automock.InstanceBindDataRemover, *automock.HelmClient, func() (internal.OperationID, error), *automock.InstanceStateGetter) { + return ts.InstStorageMock, ts.OpStorageMock, ts.OpStorageMock, ts.InstBindDataMock, ts.HelmClientMock, ts.OpIDProviderFake, ts.InstStateGetterMock +} + +func (ts *deprovisionServiceTestSuite) SetUp() { + ts.Exp.Populate() +} + +func (ts *deprovisionServiceTestSuite) FixBundle() internal.Bundle { + return *ts.Exp.NewBundle() +} + +func (ts *deprovisionServiceTestSuite) FixInstance() internal.Instance { + return *ts.Exp.NewInstance() +} + +func (ts *deprovisionServiceTestSuite) FixInstanceOperation() internal.InstanceOperation { + return *ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateInProgress) +} + +func (ts *deprovisionServiceTestSuite) FixDeprovisionRequest() osb.DeprovisionRequest { + return osb.DeprovisionRequest{ + InstanceID: string(ts.Exp.InstanceID), + ServiceID: string(ts.Exp.Service.ID), + PlanID: string(ts.Exp.ServicePlan.ID), + AcceptsIncomplete: true, + } +} diff --git a/components/helm-broker/internal/broker/dto.go b/components/helm-broker/internal/broker/dto.go new file mode 100644 index 000000000000..0bcd205840b7 --- /dev/null +++ b/components/helm-broker/internal/broker/dto.go @@ -0,0 +1,65 @@ +package broker + +import ( + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/pkg/errors" +) + +// DTOs for Open Service Broker v2.12 API + +type contextDTO struct { + Platform string `json:"platform"` + Namespace internal.Namespace `json:"namespace"` +} + +// ProvisionRequestDTO represents provision request +type ProvisionRequestDTO struct { + ServiceID internal.ServiceID `json:"service_id"` + PlanID internal.ServicePlanID `json:"plan_id"` + OrganizationGUID string `json:"organization_guid"` + SpaceGUID string `json:"space_guid"` + Parameters map[string]interface{} `json:"parameters,omitempty"` + Context contextDTO `json:"context,omitempty"` +} + +// ProvisionSuccessResponseDTO represents response after successful provisioning +type ProvisionSuccessResponseDTO struct { + DashboardURL *string `json:"dashboard_url"` + Operation *internal.OperationID `json:"operation,omitempty"` +} + +// DeprovisionSuccessResponseDTO represents response after successful deprovisioning +type DeprovisionSuccessResponseDTO struct { + Operation *internal.OperationID `json:"operation,omitempty"` +} + +// CatalogSuccessResponseDTO represents info about successful catalog response +// TODO: implement me based on osb.CatalogResponse +type CatalogSuccessResponseDTO struct{} + +// LastOperationSuccessResponseDTO represents info response about last successful operation +type LastOperationSuccessResponseDTO struct { + State internal.OperationState `json:"state"` + Description *string `json:"description,omitempty"` +} + +// BindSuccessResponseDTO represents response with credentials for service instance after successful binding +type BindSuccessResponseDTO struct { + // Credentials is a free-form hash of credentials that can be used by + // applications or users to access the service. + Credentials map[string]interface{} `json:"credentials,omitempty"` +} + +// BindParametersDTO contains parameters sent by Service Catalog in the body of bind request. +type BindParametersDTO struct { + ServiceID string `json:"service_id"` + PlanID string `json:"plan_id"` +} + +// Validate checks if bind parameters aren't empty +func (params *BindParametersDTO) Validate() error { + if params.PlanID == "" || params.ServiceID == "" { + return errors.New("bind parameters cannot be empty") + } + return nil +} diff --git a/components/helm-broker/internal/broker/error_test.go b/components/helm-broker/internal/broker/error_test.go new file mode 100644 index 000000000000..a0e6ce089ee8 --- /dev/null +++ b/components/helm-broker/internal/broker/error_test.go @@ -0,0 +1,6 @@ +package broker_test + +type notFoundError struct{} + +func (notFoundError) Error() string { return "element not found" } +func (notFoundError) NotFound() bool { return true } diff --git a/components/helm-broker/internal/broker/export_test.go b/components/helm-broker/internal/broker/export_test.go new file mode 100644 index 000000000000..ac053ba6aee1 --- /dev/null +++ b/components/helm-broker/internal/broker/export_test.go @@ -0,0 +1,8 @@ +package broker + +func NewOSBContext(originatingIdentity, apiVersion string) *osbContext { + return &osbContext{ + OriginatingIdentity: originatingIdentity, + APIVersion: apiVersion, + } +} diff --git a/components/helm-broker/internal/broker/fixture_test.go b/components/helm-broker/internal/broker/fixture_test.go new file mode 100644 index 000000000000..84b321977e8b --- /dev/null +++ b/components/helm-broker/internal/broker/fixture_test.go @@ -0,0 +1,119 @@ +package broker_test + +import ( + "fmt" + + "github.com/Masterminds/semver" + + "k8s.io/helm/pkg/proto/hapi/chart" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +type expAll struct { + InstanceID internal.InstanceID + OperationID internal.OperationID + Bundle struct { + ID internal.BundleID + Version semver.Version + Name internal.BundleName + Bindable bool + } + BundlePlan struct { + ID internal.BundlePlanID + Name internal.BundlePlanName + DisplayName string + BindTemplate internal.BundlePlanBindTemplate + } + Chart struct { + Name internal.ChartName + Version semver.Version + } + Service struct { + ID internal.ServiceID + } + ServicePlan struct { + ID internal.ServicePlanID + } + Namespace internal.Namespace + ReleaseName internal.ReleaseName + ParamsHash string +} + +func (exp *expAll) Populate() { + exp.InstanceID = internal.InstanceID("fix-I-ID") + exp.OperationID = internal.OperationID("fix-OP-ID") + + exp.Bundle.ID = internal.BundleID("fix-B-ID") + exp.Bundle.Version = *semver.MustParse("0.1.2") + exp.Bundle.Name = internal.BundleName("fix-B-Name") + exp.Bundle.Bindable = true + + exp.BundlePlan.ID = internal.BundlePlanID("fix-P-ID") + exp.BundlePlan.Name = internal.BundlePlanName("fix-P-Name") + exp.BundlePlan.DisplayName = "fix-P-DisplayName" + exp.BundlePlan.BindTemplate = internal.BundlePlanBindTemplate("template") + + exp.Chart.Name = internal.ChartName("fix-C-Name") + exp.Chart.Version = *semver.MustParse("1.2.3") + + exp.Service.ID = internal.ServiceID(exp.Bundle.ID) + exp.ServicePlan.ID = internal.ServicePlanID(exp.BundlePlan.ID) + + exp.Namespace = internal.Namespace("fix-namespace") + exp.ReleaseName = internal.ReleaseName(fmt.Sprintf("hb-%s-%s-%s", exp.Bundle.Name, exp.BundlePlan.Name, exp.InstanceID)) + exp.ParamsHash = "TODO" +} + +func (exp *expAll) NewChart() *chart.Chart { + return &chart.Chart{ + Metadata: &chart.Metadata{ + Name: string(exp.Chart.Name), + Version: exp.Chart.Version.String(), + }, + } +} + +func (exp *expAll) NewBundle() *internal.Bundle { + return &internal.Bundle{ + ID: exp.Bundle.ID, + Version: exp.Bundle.Version, + Name: exp.Bundle.Name, + Bindable: exp.Bundle.Bindable, + Plans: map[internal.BundlePlanID]internal.BundlePlan{ + exp.BundlePlan.ID: { + ID: exp.BundlePlan.ID, + Name: exp.BundlePlan.Name, + ChartRef: internal.ChartRef{ + Name: exp.Chart.Name, + Version: exp.Chart.Version, + }, + Metadata: internal.BundlePlanMetadata{ + DisplayName: exp.BundlePlan.DisplayName, + }, + BindTemplate: exp.BundlePlan.BindTemplate, + }, + }, + } +} + +func (exp *expAll) NewInstance() *internal.Instance { + return &internal.Instance{ + ID: exp.InstanceID, + ServiceID: exp.Service.ID, + ServicePlanID: exp.ServicePlan.ID, + ReleaseName: exp.ReleaseName, + Namespace: exp.Namespace, + ParamsHash: exp.ParamsHash, + } +} + +func (exp *expAll) NewInstanceOperation(tpe internal.OperationType, state internal.OperationState) *internal.InstanceOperation { + return &internal.InstanceOperation{ + InstanceID: exp.InstanceID, + OperationID: exp.OperationID, + Type: tpe, + State: state, + ParamsHash: exp.ParamsHash, + } +} diff --git a/components/helm-broker/internal/broker/lastop.go b/components/helm-broker/internal/broker/lastop.go new file mode 100644 index 000000000000..696e4b5b7b09 --- /dev/null +++ b/components/helm-broker/internal/broker/lastop.go @@ -0,0 +1,45 @@ +package broker + +import ( + "context" + + "github.com/pkg/errors" + + osb "github.com/pmorie/go-open-service-broker-client/v2" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +type getLastOperationService struct { + getter operationGetter +} + +func (svc *getLastOperationService) GetLastOperation(ctx context.Context, osbCtx osbContext, req *osb.LastOperationRequest) (*osb.LastOperationResponse, error) { + iID := internal.InstanceID(req.InstanceID) + + var opID internal.OperationID + if req.OperationKey != nil { + opID = internal.OperationID(*req.OperationKey) + } + + op, err := svc.getter.Get(iID, opID) + switch { + case IsNotFoundError(err): + return nil, err + case err != nil: + return nil, errors.Wrap(err, "while getting instance operation") + } + + var descPtr *string + if op.StateDescription != nil { + desc := *op.StateDescription + descPtr = &desc + } + + resp := osb.LastOperationResponse{ + State: osb.LastOperationState(op.State), + Description: descPtr, + } + + return &resp, nil +} diff --git a/components/helm-broker/internal/broker/middleware.go b/components/helm-broker/internal/broker/middleware.go new file mode 100644 index 000000000000..d472aa207a5c --- /dev/null +++ b/components/helm-broker/internal/broker/middleware.go @@ -0,0 +1,36 @@ +package broker + +import ( + "net/http" + + osb "github.com/pmorie/go-open-service-broker-client/v2" +) + +// OSBContextMiddleware implements Handler interface +type OSBContextMiddleware struct{} + +// ServeHTTP adds content of Open Service Broker Api headers to the requests +func (OSBContextMiddleware) ServeHTTP(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + osbCtx := osbContext{ + APIVersion: r.Header.Get(osb.APIVersionHeader), + OriginatingIdentity: r.Header.Get(osb.OriginatingIdentityHeader), + } + + r = r.WithContext(contextWithOSB(r.Context(), osbCtx)) + + next(rw, r) +} + +// RequireAsyncMiddleware asserts if request allows for asynchronous response +type RequireAsyncMiddleware struct{} + +// ServeHTTP handling asynchronous HTTP requests in Open Service Broker Api +func (RequireAsyncMiddleware) ServeHTTP(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + if r.URL.Query().Get("accepts_incomplete") != "true" { + // message and desc as defined in https://github.com/openservicebrokerapi/servicebroker/blob/master/spec.md#response-2 + writeErrorResponse(rw, http.StatusUnprocessableEntity, "AsyncRequired", "This service plan requires client support for asynchronous service operations.") + return + } + + next(rw, r) +} diff --git a/components/helm-broker/internal/broker/osbapi_test.go b/components/helm-broker/internal/broker/osbapi_test.go new file mode 100644 index 000000000000..725daf06b7c9 --- /dev/null +++ b/components/helm-broker/internal/broker/osbapi_test.go @@ -0,0 +1,527 @@ +package broker_test + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "sync" + "testing" + "time" + + "github.com/pborman/uuid" + osb "github.com/pmorie/go-open-service-broker-client/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + rls "k8s.io/helm/pkg/proto/hapi/services" + + "github.com/kyma-project/kyma/components/helm-broker/platform/ptr" + + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/kyma-project/kyma/components/helm-broker/internal/broker" + "github.com/kyma-project/kyma/components/helm-broker/internal/broker/automock" + "github.com/kyma-project/kyma/components/helm-broker/internal/platform/logger/spy" + "github.com/kyma-project/kyma/components/helm-broker/internal/storage" + "github.com/kyma-project/kyma/components/helm-broker/internal/ybind" +) + +func newOSBAPITestSuite(t *testing.T) *osbapiTestSuite { + logSink := spy.NewLogSink() + logSink.RawLogger.Out = ioutil.Discard + + sFact, err := storage.NewFactory(storage.NewConfigListAllMemory()) + require.NoError(t, err) + + ts := &osbapiTestSuite{ + t: t, + StorageFactory: sFact, + HelmClient: &automock.HelmClient{}, + LogSink: logSink, + } + + ts.Exp.Populate() + + ts.OperationIDProvider = func() (internal.OperationID, error) { + return ts.Exp.OperationID, nil + } + + ts.BrokerServer = broker.NewWithIDProvider( + sFact.Bundle(), + sFact.Chart(), + sFact.InstanceOperation(), + sFact.Instance(), + sFact.InstanceBindData(), + &fakeBindTmplRenderer{}, + &fakeBindTmplResolver{}, + ts.HelmClient, logSink.Logger, ts.OperationIDProvider) + + return ts +} + +type osbapiTestSuite struct { + t *testing.T + + BrokerServer *broker.Server + StorageFactory storage.Factory + HelmClient *automock.HelmClient + LogSink *spy.LogSink + OperationIDProvider func() (internal.OperationID, error) + + osbClient osb.Client + + serverWg sync.WaitGroup + serverCancel func() + ServerAddr string + + Exp expAll +} + +func (ts *osbapiTestSuite) ServerRun() { + ctx, cancel := context.WithCancel(context.Background()) + ts.serverWg.Add(1) + + go func() { + assert.Equal(ts.t, http.ErrServerClosed, ts.BrokerServer.Run(ctx, ":0")) + ts.serverWg.Done() + }() + + // TODO: wrap in timeout + ts.ServerAddr = ts.BrokerServer.Addr() + ts.serverCancel = cancel +} + +func (ts *osbapiTestSuite) ServerShutdown() { + ts.serverCancel() + ts.serverWg.Wait() +} + +func (ts *osbapiTestSuite) OSBClient() osb.Client { + if ts.osbClient == nil { + config := osb.DefaultClientConfiguration() + config.URL = fmt.Sprintf("http://%s", ts.ServerAddr) + + osbClient, err := osb.NewClient(config) + require.NoError(ts.t, err) + ts.osbClient = osbClient + } + + return ts.osbClient +} + +func (ts *osbapiTestSuite) AssertOperationState(exp internal.OperationState) bool { + + doCheck := func() bool { + op, err := ts.StorageFactory.InstanceOperation().Get(ts.Exp.InstanceID, ts.Exp.OperationID) + require.NoError(ts.t, err) + if op.State == exp { + return true + } + return false + } + + if doCheck() { + return true + } + + timeoutTotal := time.After(time.Second) +Polling: + for { + select { + case <-timeoutTotal: + ts.t.Error("timeout on instance operation state change") + break Polling + case <-time.After(time.Millisecond): + } + + if doCheck() { + return true + } + } + + return false +} + +func TestOSBAPIStatusSuccess(t *testing.T) { + // GIVEN + ts := newOSBAPITestSuite(t) + ts.ServerRun() + defer ts.ServerShutdown() + + // WHEN + resp, err := http.Get(fmt.Sprintf("http://%s/statusz", ts.ServerAddr)) + + // THEN + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestOSBAPICatalogSuccess(t *testing.T) { + // GIVEN + ts := newOSBAPITestSuite(t) + ts.ServerRun() + defer ts.ServerShutdown() + + fixBundle := ts.Exp.NewBundle() + ts.StorageFactory.Bundle().Upsert(fixBundle) + + // WHEN + resp, err := ts.OSBClient().GetCatalog() + + // THEN + require.NoError(t, err) + + require.Len(t, resp.Services, 1) + gotSvc := resp.Services[0] + // TODO: add generic assertion for resp.Service matching Exp + assert.EqualValues(t, ts.Exp.Service.ID, gotSvc.ID) +} + +func TestOSBAPIProvisionSuccess(t *testing.T) { + // GIVEN + ts := newOSBAPITestSuite(t) + + ts.HelmClient.On("Install", mock.Anything, mock.Anything, ts.Exp.ReleaseName, ts.Exp.Namespace).Return(&rls.InstallReleaseResponse{}, nil).Once() + defer ts.HelmClient.AssertExpectations(t) + + ts.ServerRun() + defer ts.ServerShutdown() + + fixBundle := ts.Exp.NewBundle() + ts.StorageFactory.Bundle().Upsert(fixBundle) + + fixChart := ts.Exp.NewChart() + ts.StorageFactory.Chart().Upsert(fixChart) + + nsUID := uuid.NewRandom().String() + req := &osb.ProvisionRequest{ + AcceptsIncomplete: true, + InstanceID: string(ts.Exp.InstanceID), + ServiceID: string(ts.Exp.Service.ID), + PlanID: string(ts.Exp.ServicePlan.ID), + Context: map[string]interface{}{ + "namespace": string(ts.Exp.Namespace), + }, + OrganizationGUID: nsUID, + SpaceGUID: nsUID, + } + + // WHEN + resp, err := ts.OSBClient().ProvisionInstance(req) + + // THEN + require.NoError(t, err) + + require.True(t, resp.Async) + assert.EqualValues(t, ts.Exp.OperationID, *resp.OperationKey) + + ts.AssertOperationState(internal.OperationStateSucceeded) +} + +func TestOSBAPIProvisionRepeatedOnAlreadyFullyProvisionedInstance(t *testing.T) { + // GIVEN + ts := newOSBAPITestSuite(t) + + fixInstance := ts.Exp.NewInstance() + ts.StorageFactory.Instance().Insert(fixInstance) + + fixOperation := ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateSucceeded) + ts.StorageFactory.InstanceOperation().Insert(fixOperation) + + ts.ServerRun() + defer ts.ServerShutdown() + + nsUID := uuid.NewRandom().String() + req := &osb.ProvisionRequest{ + AcceptsIncomplete: true, + InstanceID: string(ts.Exp.InstanceID), + ServiceID: string(ts.Exp.Service.ID), + PlanID: string(ts.Exp.ServicePlan.ID), + Context: map[string]interface{}{ + "namespace": string(ts.Exp.Namespace), + }, + OrganizationGUID: nsUID, + SpaceGUID: nsUID, + } + + // WHEN + resp, err := ts.OSBClient().ProvisionInstance(req) + + // THEN + require.NoError(t, err) + + assert.False(t, resp.Async) + assert.Nil(t, resp.OperationKey) + + // No activity on tiller should happen + defer ts.HelmClient.AssertExpectations(t) +} + +func TestOSBAPIProvisionRepeatedOnProvisioningInProgress(t *testing.T) { + // GIVEN + ts := newOSBAPITestSuite(t) + + fixInstance := ts.Exp.NewInstance() + ts.StorageFactory.Instance().Insert(fixInstance) + + fixOperation := ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateInProgress) + expOpID := internal.OperationID("fix-op-id") + fixOperation.OperationID = expOpID + ts.StorageFactory.InstanceOperation().Insert(fixOperation) + + ts.ServerRun() + defer ts.ServerShutdown() + + nsUID := uuid.NewRandom().String() + req := &osb.ProvisionRequest{ + AcceptsIncomplete: true, + InstanceID: string(ts.Exp.InstanceID), + ServiceID: string(ts.Exp.Service.ID), + PlanID: string(ts.Exp.ServicePlan.ID), + Context: map[string]interface{}{ + "namespace": string(ts.Exp.Namespace), + }, + OrganizationGUID: nsUID, + SpaceGUID: nsUID, + } + + // WHEN + resp, err := ts.OSBClient().ProvisionInstance(req) + + // THEN + require.NoError(t, err) + + assert.True(t, resp.Async) + assert.EqualValues(t, expOpID, *resp.OperationKey) + + // No activity on tiller should happen + defer ts.HelmClient.AssertExpectations(t) +} + +func TestOSBAPIDeprovisionOnAlreadyDeprovisionedInstance(t *testing.T) { + // GIVEN + ts := newOSBAPITestSuite(t) + + fixInstance := ts.Exp.NewInstance() + ts.StorageFactory.Instance().Insert(fixInstance) + + fixOperation := ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateSucceeded) + ts.StorageFactory.InstanceOperation().Insert(fixOperation) + + ts.ServerRun() + defer ts.ServerShutdown() + + req := &osb.DeprovisionRequest{ + AcceptsIncomplete: true, + InstanceID: string(ts.Exp.InstanceID), + ServiceID: string(ts.Exp.Service.ID), + PlanID: string(ts.Exp.ServicePlan.ID), + } + + // WHEN + resp, err := ts.OSBClient().DeprovisionInstance(req) + + // THEN + require.NoError(t, err) + + assert.False(t, resp.Async) + assert.Nil(t, resp.OperationKey) + + // No activity on tiller should happen + defer ts.HelmClient.AssertExpectations(t) +} + +func TestOSBAPIDeprovisionOnAlreadyDeprovisionedAndRemovedInstance(t *testing.T) { + // GIVEN + ts := newOSBAPITestSuite(t) + // storage does not contain any data + + ts.ServerRun() + defer ts.ServerShutdown() + + req := &osb.DeprovisionRequest{ + AcceptsIncomplete: true, + InstanceID: string(ts.Exp.InstanceID), + ServiceID: string(ts.Exp.Service.ID), + PlanID: string(ts.Exp.ServicePlan.ID), + } + + // WHEN + resp, err := ts.OSBClient().DeprovisionInstance(req) + + // THEN + require.NoError(t, err) + + assert.False(t, resp.Async) + assert.Nil(t, resp.OperationKey) + + // No activity on tiller should happen + defer ts.HelmClient.AssertExpectations(t) +} + +func TestOSBAPIDeprovisionRepeatedOnDeprovisioningInProgress(t *testing.T) { + // GIVEN + ts := newOSBAPITestSuite(t) + + fixInstance := ts.Exp.NewInstance() + ts.StorageFactory.Instance().Insert(fixInstance) + + fixOperation := ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateInProgress) + expOpID := internal.OperationID("fix-op-id") + fixOperation.OperationID = expOpID + ts.StorageFactory.InstanceOperation().Insert(fixOperation) + + ts.ServerRun() + defer ts.ServerShutdown() + + req := &osb.DeprovisionRequest{ + AcceptsIncomplete: true, + InstanceID: string(ts.Exp.InstanceID), + ServiceID: string(ts.Exp.Service.ID), + PlanID: string(ts.Exp.ServicePlan.ID), + } + + // WHEN + resp, err := ts.OSBClient().DeprovisionInstance(req) + + // THEN + require.NoError(t, err) + + assert.True(t, resp.Async) + assert.EqualValues(t, expOpID, *resp.OperationKey) + + // No activity on tiller should happen + defer ts.HelmClient.AssertExpectations(t) +} + +func TestOSBAPIDeprovisionSuccess(t *testing.T) { + // GIVEN + ts := newOSBAPITestSuite(t) + + fixOperation := ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateSucceeded) + expOpID := internal.OperationID("fix-op-id") + fixOperation.OperationID = expOpID + ts.StorageFactory.InstanceOperation().Insert(fixOperation) + + ts.HelmClient.On("Delete", ts.Exp.ReleaseName).Return(nil).Once() + defer ts.HelmClient.AssertExpectations(t) + + ts.ServerRun() + defer ts.ServerShutdown() + + fixInstance := ts.Exp.NewInstance() + ts.StorageFactory.Instance().Insert(fixInstance) + + req := &osb.DeprovisionRequest{ + AcceptsIncomplete: true, + InstanceID: string(ts.Exp.InstanceID), + ServiceID: string(ts.Exp.Service.ID), + PlanID: string(ts.Exp.ServicePlan.ID), + } + + // WHEN + resp, err := ts.OSBClient().DeprovisionInstance(req) + + // THEN + require.NoError(t, err) + + require.True(t, resp.Async) + assert.EqualValues(t, ts.Exp.OperationID, *resp.OperationKey) + + ts.AssertOperationState(internal.OperationStateSucceeded) +} + +func TestOSBAPILastOperationSuccess(t *testing.T) { + // GIVEN + ts := newOSBAPITestSuite(t) + ts.ServerRun() + defer ts.ServerShutdown() + + fixBundle := ts.Exp.NewBundle() + ts.StorageFactory.Bundle().Upsert(fixBundle) + + fixInstance := ts.Exp.NewInstance() + ts.StorageFactory.Instance().Insert(fixInstance) + + fixOperation := ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateInProgress) + ts.StorageFactory.InstanceOperation().Insert(fixOperation) + + // WHEN + opKey := osb.OperationKey(ts.Exp.OperationID) + req := &osb.LastOperationRequest{ + InstanceID: string(ts.Exp.InstanceID), + ServiceID: ptr.String(string(ts.Exp.Service.ID)), + PlanID: ptr.String(string(ts.Exp.ServicePlan.ID)), + OperationKey: &opKey, + } + resp, err := ts.OSBClient().PollLastOperation(req) + + // THEN + require.NoError(t, err) + assert.EqualValues(t, internal.OperationStateInProgress, resp.State) + // TODO: match desc +} + +func TestOSBAPILastOperationForNonExistingInstance(t *testing.T) { + // GIVEN + ts := newOSBAPITestSuite(t) + ts.ServerRun() + defer ts.ServerShutdown() + + fixBundle := ts.Exp.NewBundle() + ts.StorageFactory.Bundle().Upsert(fixBundle) + + // WHEN + opKey := osb.OperationKey(ts.Exp.OperationID) + req := &osb.LastOperationRequest{ + InstanceID: string(ts.Exp.InstanceID), + ServiceID: ptr.String(string(ts.Exp.Service.ID)), + PlanID: ptr.String(string(ts.Exp.ServicePlan.ID)), + OperationKey: &opKey, + } + _, err := ts.OSBClient().PollLastOperation(req) + + // THEN + assert.True(t, osb.IsGoneError(err)) +} + +func TestOSBAPIBindFailureWithDisallowedParametersFieldInReq(t *testing.T) { + // GIVEN + ts := newOSBAPITestSuite(t) + ts.ServerRun() + defer ts.ServerShutdown() + + fixBundle := ts.Exp.NewBundle() + ts.StorageFactory.Bundle().Upsert(fixBundle) + + // WHEN + req := &osb.BindRequest{ + BindingID: "bind-id", + InstanceID: "instance-id", + ServiceID: "svc-id", + PlanID: "bind-id", + Parameters: map[string]interface{}{ + "params": "set-but-not-allowed", + }, + } + _, err := ts.OSBClient().Bind(req) + + // THEN + require.Error(t, err) + castedErr, ok := osb.IsHTTPError(err) + require.True(t, ok) + assert.Equal(t, http.StatusBadRequest, castedErr.StatusCode) +} + +type fakeBindTmplRenderer struct{} + +func (fakeBindTmplRenderer) Render(bindTemplate internal.BundlePlanBindTemplate, resp *rls.InstallReleaseResponse) (ybind.RenderedBindYAML, error) { + return []byte(`fake`), nil +} + +type fakeBindTmplResolver struct{} + +func (fakeBindTmplResolver) Resolve(bindYAML ybind.RenderedBindYAML, ns internal.Namespace) (*ybind.ResolveOutput, error) { + return &ybind.ResolveOutput{}, nil +} diff --git a/components/helm-broker/internal/broker/provision.go b/components/helm-broker/internal/broker/provision.go new file mode 100644 index 000000000000..63a188a844a2 --- /dev/null +++ b/components/helm-broker/internal/broker/provision.go @@ -0,0 +1,275 @@ +package broker + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/pkg/errors" + osb "github.com/pmorie/go-open-service-broker-client/v2" + "github.com/sirupsen/logrus" + rls "k8s.io/helm/pkg/proto/hapi/services" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +type provisionService struct { + bundleIDGetter bundleIDGetter + chartGetter chartGetter + instanceInserter instanceInserter + instanceStateGetter instanceStateProvisionGetter + operationInserter operationInserter + operationUpdater operationUpdater + instanceBindDataInserter instanceBindDataInserter + operationIDProvider func() (internal.OperationID, error) + helmInstaller helmInstaller + bindTemplateRenderer bindTemplateRenderer + bindTemplateResolver bindTemplateResolver + mu sync.Mutex + + log *logrus.Entry + + testHookAsyncCalled func(internal.OperationID) +} + +func (svc *provisionService) Provision(ctx context.Context, osbCtx osbContext, req *osb.ProvisionRequest) (*osb.ProvisionResponse, error) { + if !req.AcceptsIncomplete { + return nil, errors.New("asynchronous operation mode required") + } + + // Single provisioning is supported concurrently. + // TODO: switch to lock per instanceID + svc.mu.Lock() + defer svc.mu.Unlock() + + iID := internal.InstanceID(req.InstanceID) + + switch state, err := svc.instanceStateGetter.IsProvisioned(iID); true { + case err != nil: + return nil, errors.Wrap(err, "while checking if instance is already provisioned") + case state: + return &osb.ProvisionResponse{Async: false}, nil + } + + switch opIDInProgress, inProgress, err := svc.instanceStateGetter.IsProvisioningInProgress(iID); true { + case err != nil: + return nil, errors.Wrap(err, "while checking if instance is being provisioned") + case inProgress: + opKeyInProgress := osb.OperationKey(opIDInProgress) + return &osb.ProvisionResponse{Async: true, OperationKey: &opKeyInProgress}, nil + } + + id, err := svc.operationIDProvider() + if err != nil { + return nil, errors.Wrap(err, "while generating ID for operation") + } + opID := internal.OperationID(id) + + namespace, err := getNamespaceFromContext(req.Context) + + if err != nil { + return nil, errors.Wrap(err, "while getting namespace from context") + } + + svcID := internal.ServiceID(req.ServiceID) + svcPlanID := internal.ServicePlanID(req.PlanID) + + // bundleID/planID is in 1:1 match with serviceID/servicePlanID (from service catalog) + bundleID := internal.BundleID(svcID) + bundlePlanID := internal.BundlePlanID(svcPlanID) + + bundle, err := svc.bundleIDGetter.GetByID(bundleID) + if err != nil { + return nil, errors.Wrap(err, "while getting bundle") + } + + bundlePlan, found := bundle.Plans[bundlePlanID] + if !found { + return nil, errors.Errorf("bundle does not contain requested plan (planID: %s)", bundlePlanID) + } + + releaseName := createReleaseName(bundle.Name, bundlePlan.Name, iID) + + // TODO: add support for calculating ParamHash + paramHash := "TODO" + + op := internal.InstanceOperation{ + InstanceID: iID, + OperationID: opID, + Type: internal.OperationTypeCreate, + State: internal.OperationStateInProgress, + ParamsHash: paramHash, + } + + if err := svc.operationInserter.Insert(&op); err != nil { + return nil, errors.Wrap(err, "while inserting instance operation to storage") + } + + i := internal.Instance{ + ID: iID, + Namespace: namespace, + ServiceID: svcID, + ServicePlanID: svcPlanID, + ReleaseName: releaseName, + ParamsHash: paramHash, + } + + if err = svc.instanceInserter.Insert(&i); err != nil { + return nil, errors.Wrap(err, "while inserting instance to storage") + } + + cvOver := internal.ChartValues(req.Parameters) + + svc.doAsync(ctx, iID, opID, namespace, releaseName, bundlePlan, bundle.Bindable, cvOver) + + opKey := osb.OperationKey(op.OperationID) + resp := &osb.ProvisionResponse{ + OperationKey: &opKey, + Async: true, + } + + return resp, nil +} + +func (svc *provisionService) doAsync(ctx context.Context, iID internal.InstanceID, opID internal.OperationID, namespace internal.Namespace, releaseName internal.ReleaseName, bundlePlan internal.BundlePlan, isBundleBindable bool, cvOver internal.ChartValues) { + if svc.testHookAsyncCalled != nil { + svc.testHookAsyncCalled(opID) + } + go svc.do(ctx, iID, opID, namespace, releaseName, bundlePlan, isBundleBindable, cvOver) +} + +// do is called asynchronously +func (svc *provisionService) do(ctx context.Context, iID internal.InstanceID, opID internal.OperationID, namespace internal.Namespace, releaseName internal.ReleaseName, bundlePlan internal.BundlePlan, isBundleBindable bool, cvOver internal.ChartValues) { + + fDo := func() (*rls.InstallReleaseResponse, error) { + c, err := svc.chartGetter.Get(bundlePlan.ChartRef.Name, bundlePlan.ChartRef.Version) + if err != nil { + return nil, errors.Wrap(err, "while getting chart from storage") + } + + out, err := deepCopy(map[string]interface{}(bundlePlan.ChartValues)) + if err != nil { + return nil, errors.Wrap(err, "while coping plan values") + } + + out = mergeValues(out, map[string]interface{}(cvOver)) + + svc.log.Infof("Merging values for operation [%s], releaseName [%s], namespace [%s], bundlePlan [%s]. Plan values are: [%v], overrides: [%v], merged: [%v] ", + opID, releaseName, namespace, bundlePlan.Name, bundlePlan.ChartValues, cvOver, out) + + resp, err := svc.helmInstaller.Install(c, internal.ChartValues(out), releaseName, namespace) + if err != nil { + return nil, errors.Wrap(err, "while installing helm release") + } + + return resp, nil + } + + opState := internal.OperationStateSucceeded + opDesc := "provisioning succeeded" + + resp, err := fDo() + if err != nil { + opState = internal.OperationStateFailed + opDesc = fmt.Sprintf("provisioning failed on error: %s", err.Error()) + } + + if err == nil && svc.isBindable(bundlePlan, isBundleBindable) { + if resolveErr := svc.resolveAndSaveBindData(iID, namespace, bundlePlan, resp); resolveErr != nil { + opState = internal.OperationStateFailed + opDesc = fmt.Sprintf("resolving bind data failed with error: %s", resolveErr.Error()) + } + } + + if err := svc.operationUpdater.UpdateStateDesc(iID, opID, opState, &opDesc); err != nil { + } +} + +func (*provisionService) isBindable(plan internal.BundlePlan, isBundleBindable bool) bool { + return (plan.Bindable != nil && *plan.Bindable) || // if bindable field is set on plan it's override bindalbe field on bundle + (plan.Bindable == nil && isBundleBindable) // if bindable field is NOT set on plan thet bindalbe field on bundle is important +} + +func (svc *provisionService) resolveAndSaveBindData(iID internal.InstanceID, namespace internal.Namespace, bundlePlan internal.BundlePlan, resp *rls.InstallReleaseResponse) error { + rendered, err := svc.bindTemplateRenderer.Render(bundlePlan.BindTemplate, resp) + if err != nil { + return errors.Wrap(err, "while rendering bind yaml template") + } + + out, err := svc.bindTemplateResolver.Resolve(rendered, namespace) + if err != nil { + return errors.Wrap(err, "while resolving bind yaml values") + } + + in := internal.InstanceBindData{ + InstanceID: iID, + Credentials: out.Credentials, + } + if err := svc.instanceBindDataInserter.Insert(&in); err != nil { + return errors.Wrap(err, "while inserting instance bind data into storage") + } + + return nil +} + +func getNamespaceFromContext(contextProfile map[string]interface{}) (internal.Namespace, error) { + return internal.Namespace(contextProfile["namespace"].(string)), nil +} + +func createReleaseName(name internal.BundleName, planName internal.BundlePlanName, iID internal.InstanceID) internal.ReleaseName { + maxLen := 53 + relName := fmt.Sprintf("hb-%s-%s-%s", name, planName, iID) + if len(relName) <= maxLen { + return internal.ReleaseName(relName) + } + return internal.ReleaseName(relName[:maxLen]) +} + +// to work correctly, https://github.com/ghodss/yaml has to be used +func mergeValues(dest map[string]interface{}, src map[string]interface{}) map[string]interface{} { + for k, v := range src { + // If the key doesn't exist already, then just set the key to that value + if _, exists := dest[k]; !exists { + dest[k] = v + continue + } + + nextMap, ok := v.(map[string]interface{}) + // If it isn't another map, overwrite the value + if !ok { + dest[k] = v + continue + } + // If the key doesn't exist already, then just set the key to that value + if _, exists := dest[k]; !exists { + dest[k] = nextMap + continue + } + // Edge case: If the key exists in the destination, but isn't a map + destMap, isMap := dest[k].(map[string]interface{}) + // If the source map has a map for this key, prefer it + if !isMap { + dest[k] = v + continue + } + // If we got to this point, it is a map in both, so merge them + dest[k] = mergeValues(destMap, nextMap) + } + return dest +} + +func deepCopy(in map[string]interface{}) (map[string]interface{}, error) { + out := map[string]interface{}{} + if in != nil { + b, err := json.Marshal(in) + if err != nil { + return nil, errors.Wrap(err, "while performing deep copy (marshal)") + } + + if err = json.Unmarshal(b, &out); err != nil { + return nil, errors.Wrap(err, "while performing deep copy (unmarshal)") + } + } + return out, nil +} diff --git a/components/helm-broker/internal/broker/provision_export_test.go b/components/helm-broker/internal/broker/provision_export_test.go new file mode 100644 index 000000000000..6c3bc139c638 --- /dev/null +++ b/components/helm-broker/internal/broker/provision_export_test.go @@ -0,0 +1,31 @@ +package broker + +import ( + "github.com/sirupsen/logrus" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +func NewProvisionService(bg bundleIDGetter, cg chartGetter, ii instanceInserter, isg instanceStateGetter, oi operationInserter, ou operationUpdater, + ibd instanceBindDataInserter, bindTmplRenderer bindTemplateRenderer, bindTmplResolver bindTemplateResolver, + hi helmInstaller, oIDProv func() (internal.OperationID, error), log *logrus.Entry) *provisionService { + return &provisionService{ + bundleIDGetter: bg, + chartGetter: cg, + instanceInserter: ii, + instanceStateGetter: isg, + operationInserter: oi, + operationUpdater: ou, + operationIDProvider: oIDProv, + helmInstaller: hi, + log: log, + instanceBindDataInserter: ibd, + bindTemplateRenderer: bindTmplRenderer, + bindTemplateResolver: bindTmplResolver, + } +} + +func (svc *provisionService) WithTestHookOnAsyncCalled(h func(internal.OperationID)) *provisionService { + svc.testHookAsyncCalled = h + return svc +} diff --git a/components/helm-broker/internal/broker/provision_test.go b/components/helm-broker/internal/broker/provision_test.go new file mode 100644 index 000000000000..244185b80d2c --- /dev/null +++ b/components/helm-broker/internal/broker/provision_test.go @@ -0,0 +1,337 @@ +package broker_test + +import ( + "context" + "errors" + "testing" + "time" + + osb "github.com/pmorie/go-open-service-broker-client/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "k8s.io/helm/pkg/proto/hapi/chart" + rls "k8s.io/helm/pkg/proto/hapi/services" + + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/kyma-project/kyma/components/helm-broker/internal/broker" + "github.com/kyma-project/kyma/components/helm-broker/internal/broker/automock" + "github.com/kyma-project/kyma/components/helm-broker/internal/ybind" + "github.com/kyma-project/kyma/components/helm-broker/platform/logger/spy" +) + +func newProvisionServiceTestSuite(t *testing.T) *provisionServiceTestSuite { + return &provisionServiceTestSuite{t: t} +} + +type provisionServiceTestSuite struct { + t *testing.T + Exp expAll +} + +func (ts *provisionServiceTestSuite) SetUp() { + ts.Exp.Populate() +} + +func (ts *provisionServiceTestSuite) FixBundle() internal.Bundle { + return *ts.Exp.NewBundle() +} + +func (ts *provisionServiceTestSuite) FixChart() chart.Chart { + return *ts.Exp.NewChart() +} + +func (ts *provisionServiceTestSuite) FixInstance() internal.Instance { + return *ts.Exp.NewInstance() +} + +func (ts *provisionServiceTestSuite) FixInstanceOperation() internal.InstanceOperation { + return *ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateInProgress) +} + +func (ts *provisionServiceTestSuite) FixProvisionRequest() osb.ProvisionRequest { + return osb.ProvisionRequest{ + InstanceID: string(ts.Exp.InstanceID), + ServiceID: string(ts.Exp.Service.ID), + PlanID: string(ts.Exp.ServicePlan.ID), + Parameters: make(internal.ChartValues), + Context: map[string]interface{}{ + "namespace": string(ts.Exp.Namespace), + }, + AcceptsIncomplete: true, + } +} + +func TestProvisionServiceProvisionSuccessAsyncInstall(t *testing.T) { + // GIVEN + ts := newProvisionServiceTestSuite(t) + ts.SetUp() + + isgMock := &automock.InstanceStateGetter{} + defer isgMock.AssertExpectations(t) + isgMock.On("IsProvisioned", ts.Exp.InstanceID).Return(false, nil).Once() + isgMock.On("IsProvisioningInProgress", ts.Exp.InstanceID).Return(internal.OperationID(""), false, nil).Once() + + bgMock := &automock.BundleStorage{} + defer bgMock.AssertExpectations(t) + expBundle := ts.FixBundle() + bgMock.On("GetByID", ts.Exp.Bundle.ID).Return(&expBundle, nil).Once() + + cgMock := &automock.ChartGetter{} + defer cgMock.AssertExpectations(t) + expChart := ts.FixChart() + cgMock.On("Get", ts.Exp.Chart.Name, ts.Exp.Chart.Version).Return(&expChart, nil).Once() + + iiMock := &automock.InstanceStorage{} + defer iiMock.AssertExpectations(t) + expInstance := ts.FixInstance() + iiMock.On("Insert", &expInstance).Return(nil).Once() + + ioMock := &automock.OperationStorage{} + defer ioMock.AssertExpectations(t) + expInstOp := ts.FixInstanceOperation() + ioMock.On("Insert", &expInstOp).Return(nil).Once() + operationSucceeded := make(chan struct{}) + ioMock.On("UpdateStateDesc", ts.Exp.InstanceID, ts.Exp.OperationID, internal.OperationStateSucceeded, mock.Anything).Return(nil).Once(). + Run(func(mock.Arguments) { close(operationSucceeded) }) + + hiMock := &automock.HelmClient{} + defer hiMock.AssertExpectations(t) + releaseResp := &rls.InstallReleaseResponse{} + hiMock.On("Install", &expChart, internal.ChartValues(map[string]interface{}{}), ts.Exp.ReleaseName, ts.Exp.Namespace).Return(releaseResp, nil).Once() + + renderedYAML := ybind.RenderedBindYAML(`rendered-template`) + rendererMock := &automock.BindTemplateRenderer{} + defer rendererMock.AssertExpectations(t) + rendererMock.On("Render", ts.Exp.BundlePlan.BindTemplate, releaseResp).Return(renderedYAML, nil) + + expCreds := internal.InstanceCredentials{ + "test-param": "test-value", + } + resolverMock := &automock.BindTemplateResolver{} + defer resolverMock.AssertExpectations(t) + resolverMock.On("Resolve", renderedYAML, ts.Exp.Namespace).Return(&ybind.ResolveOutput{ + Credentials: expCreds, + }, nil) + + expInsert := internal.InstanceBindData{InstanceID: ts.Exp.InstanceID, Credentials: expCreds} + ibdMock := &automock.InstanceBindDataInserter{} + defer ibdMock.AssertExpectations(t) + ibdMock.On("Insert", &expInsert).Return(nil) + + oipFake := func() (internal.OperationID, error) { + return ts.Exp.OperationID, nil + } + + testHookCalled := make(chan struct{}) + + svc := broker.NewProvisionService(bgMock, cgMock, iiMock, isgMock, ioMock, ioMock, ibdMock, rendererMock, resolverMock, hiMock, oipFake, spy.NewLogDummy()). + WithTestHookOnAsyncCalled(func(opID internal.OperationID) { + assert.Equal(t, ts.Exp.OperationID, opID) + close(testHookCalled) + }) + + ctx := context.Background() + osbCtx := *broker.NewOSBContext("", "v1") + req := ts.FixProvisionRequest() + + // WHEN + resp, err := svc.Provision(ctx, osbCtx, &req) + + // THEN + assert.NoError(t, err) + assert.True(t, resp.Async) + assert.EqualValues(t, ts.Exp.OperationID, *resp.OperationKey) + + select { + case <-operationSucceeded: + case <-time.After(time.Millisecond * 100): + t.Fatal("timeout on operation succeeded") + } + + select { + case <-testHookCalled: + default: + t.Fatal("async test hook not called") + } +} + +func TestProvisionServiceProvisionFailureAsync(t *testing.T) { + // GIVEN + ts := newProvisionServiceTestSuite(t) + ts.SetUp() + + isgMock := &automock.InstanceStateGetter{} + defer isgMock.AssertExpectations(t) + isgMock.On("IsProvisioned", ts.Exp.InstanceID).Return(false, nil).Once() + isgMock.On("IsProvisioningInProgress", ts.Exp.InstanceID).Return(internal.OperationID(""), false, nil).Once() + + bgMock := &automock.BundleStorage{} + defer bgMock.AssertExpectations(t) + expBundle := ts.FixBundle() + bgMock.On("GetByID", ts.Exp.Bundle.ID).Return(&expBundle, nil).Once() + iiMock := &automock.InstanceStorage{} + defer iiMock.AssertExpectations(t) + expInstance := ts.FixInstance() + iiMock.On("Insert", &expInstance).Return(nil).Once() + + ioMock := &automock.OperationStorage{} + defer ioMock.AssertExpectations(t) + expInstOp := ts.FixInstanceOperation() + ioMock.On("Insert", &expInstOp).Return(nil).Once() + + cgMock := &automock.ChartGetter{} + defer cgMock.AssertExpectations(t) + expChartError := errors.New("fake-chart-error") + cgMock.On("Get", ts.Exp.Chart.Name, ts.Exp.Chart.Version).Return(nil, expChartError).Once() + + operationFailed := make(chan struct{}) + ioMock.On("UpdateStateDesc", ts.Exp.InstanceID, ts.Exp.OperationID, internal.OperationStateFailed, mock.Anything).Return(nil).Once(). + Run(func(mock.Arguments) { close(operationFailed) }) + + hiMock := &automock.HelmClient{} + defer hiMock.AssertExpectations(t) + + oipFake := func() (internal.OperationID, error) { + return ts.Exp.OperationID, nil + } + + testHookCalled := make(chan struct{}) + + svc := broker.NewProvisionService(bgMock, cgMock, iiMock, isgMock, ioMock, ioMock, nil, nil, nil, hiMock, oipFake, spy.NewLogDummy()). + WithTestHookOnAsyncCalled(func(opID internal.OperationID) { + assert.Equal(t, ts.Exp.OperationID, opID) + close(testHookCalled) + }) + + ctx := context.Background() + osbCtx := *broker.NewOSBContext("", "v1") + req := ts.FixProvisionRequest() + + // WHEN + resp, err := svc.Provision(ctx, osbCtx, &req) + + // THEN + assert.NoError(t, err) + assert.True(t, resp.Async) + assert.EqualValues(t, ts.Exp.OperationID, *resp.OperationKey) + + select { + case <-operationFailed: + case <-time.After(time.Millisecond * 100): + t.Fatal("timeout on operation failed") + } + + select { + case <-testHookCalled: + default: + t.Fatal("async test hook not called") + } +} + +func TestProvisionServiceProvisionSuccessRepeatedOnAlreadyFullyProvisionedInstance(t *testing.T) { + // GIVEN + ts := newProvisionServiceTestSuite(t) + ts.SetUp() + + isgMock := &automock.InstanceStateGetter{} + defer isgMock.AssertExpectations(t) + isgMock.On("IsProvisioned", ts.Exp.InstanceID).Return(true, nil).Once() + + bgMock := &automock.BundleStorage{} + defer bgMock.AssertExpectations(t) + + cgMock := &automock.ChartGetter{} + defer cgMock.AssertExpectations(t) + + iiMock := &automock.InstanceStorage{} + defer iiMock.AssertExpectations(t) + + ioMock := &automock.OperationStorage{} + defer ioMock.AssertExpectations(t) + + hiMock := &automock.HelmClient{} + defer hiMock.AssertExpectations(t) + + oipFake := func() (internal.OperationID, error) { + t.Error("operation ID provider called when it should not be") + return ts.Exp.OperationID, nil + } + + testHookCalled := make(chan struct{}) + + svc := broker.NewProvisionService(bgMock, cgMock, iiMock, isgMock, ioMock, ioMock, nil, nil, nil, hiMock, oipFake, spy.NewLogDummy()). + WithTestHookOnAsyncCalled(func(internal.OperationID) { close(testHookCalled) }) + + ctx := context.Background() + osbCtx := *broker.NewOSBContext("", "v1") + req := ts.FixProvisionRequest() + + // WHEN + resp, err := svc.Provision(ctx, osbCtx, &req) + + // THEN + assert.NoError(t, err) + assert.False(t, resp.Async) + assert.Nil(t, resp.OperationKey) + + select { + case <-testHookCalled: + t.Fatal("async test hook called") + default: + } +} + +func TestProvisionServiceProvisionSuccessRepeatedOnProvisioningInProgress(t *testing.T) { + // GIVEN + ts := newProvisionServiceTestSuite(t) + ts.SetUp() + + isgMock := &automock.InstanceStateGetter{} + defer isgMock.AssertExpectations(t) + isgMock.On("IsProvisioned", ts.Exp.InstanceID).Return(false, nil).Once() + expOpID := internal.OperationID("exp-op-id") + isgMock.On("IsProvisioningInProgress", ts.Exp.InstanceID).Return(expOpID, true, nil).Once() + + bgMock := &automock.BundleStorage{} + defer bgMock.AssertExpectations(t) + + cgMock := &automock.ChartGetter{} + defer cgMock.AssertExpectations(t) + + iiMock := &automock.InstanceStorage{} + defer iiMock.AssertExpectations(t) + + ioMock := &automock.OperationStorage{} + defer ioMock.AssertExpectations(t) + + hiMock := &automock.HelmClient{} + defer hiMock.AssertExpectations(t) + + oipFake := func() (internal.OperationID, error) { + t.Error("operation ID provider called when it should not be") + return ts.Exp.OperationID, nil + } + + testHookCalled := make(chan struct{}) + + svc := broker.NewProvisionService(bgMock, cgMock, iiMock, isgMock, ioMock, ioMock, nil, nil, nil, hiMock, oipFake, spy.NewLogDummy()). + WithTestHookOnAsyncCalled(func(internal.OperationID) { close(testHookCalled) }) + + ctx := context.Background() + osbCtx := *broker.NewOSBContext("", "v1") + req := ts.FixProvisionRequest() + + // WHEN + resp, err := svc.Provision(ctx, osbCtx, &req) + + // THEN + assert.NoError(t, err) + assert.True(t, resp.Async) + assert.EqualValues(t, expOpID, *resp.OperationKey) + + select { + case <-testHookCalled: + t.Fatal("async test hook called") + default: + } +} diff --git a/components/helm-broker/internal/broker/server.go b/components/helm-broker/internal/broker/server.go new file mode 100644 index 000000000000..ed2e0139299d --- /dev/null +++ b/components/helm-broker/internal/broker/server.go @@ -0,0 +1,462 @@ +package broker + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net" + "net/http" + "time" + + "github.com/gorilla/mux" + "github.com/meatballhat/negroni-logrus" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "github.com/urfave/negroni" + + osb "github.com/pmorie/go-open-service-broker-client/v2" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +type ( + catalogGetter interface { + GetCatalog(ctx context.Context, osbCtx osbContext) (*osb.CatalogResponse, error) + } + + provisioner interface { + Provision(ctx context.Context, osbCtx osbContext, req *osb.ProvisionRequest) (*osb.ProvisionResponse, error) + } + + deprovisioner interface { + Deprovision(ctx context.Context, osbCtx osbContext, req *osb.DeprovisionRequest) (*osb.DeprovisionResponse, error) + } + + binder interface { + Bind(ctx context.Context, osbCtx osbContext, req *osb.BindRequest) (*osb.BindResponse, error) + } + + unbinder interface { + Unbind(ctx context.Context, osbCtx osbContext, req *osb.UnbindRequest) (*osb.UnbindResponse, error) + } + + lastOpGetter interface { + GetLastOperation(ctx context.Context, osbCtx osbContext, req *osb.LastOperationRequest) (*osb.LastOperationResponse, error) + } +) + +// Server implements HTTP server used to serve OSB API for helm broker. +type Server struct { + catalogGetter catalogGetter + provisioner provisioner + deprovisioner deprovisioner + binder binder + unbinder unbinder + lastOpGetter lastOpGetter + logger *logrus.Entry + addr string +} + +// Addr returns address server is listening on. +// Its use is targeted for cases when address is not known, e.g. tests. +func (srv *Server) Addr() string { + if srv.addr == "" { + timer := time.NewTicker(time.Millisecond) + waitLoop: + for { + <-timer.C + + if srv.addr != "" { + break waitLoop + } + } + } + + return srv.addr +} + +// Run is starting HTTP server +func (srv *Server) Run(ctx context.Context, addr string) error { + listenAndServe := func(httpSrv *http.Server) error { + ln, err := net.Listen("tcp", addr) + if err != nil { + return err + } + lnTCP := ln.(*net.TCPListener) + + srv.addr = lnTCP.Addr().String() + + // TODO: add support for tcpKeepAliveListener + return httpSrv.Serve(ln) + } + + return srv.run(ctx, addr, listenAndServe) +} + +// RunTLS is starting TLS server +func RunTLS(ctx context.Context, addr string, cert string, key string) error { + return errors.New("TLS is not yet implemented") +} + +// TODO: rewrite to go-sdk implementation with app and services +func (srv *Server) run(ctx context.Context, addr string, listenAndServe func(srv *http.Server) error) error { + httpSrv := &http.Server{ + Addr: addr, + Handler: srv.createHandler(), + } + go func() { + <-ctx.Done() + c, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + if httpSrv.Shutdown(c) != nil { + httpSrv.Close() + } + }() + return listenAndServe(httpSrv) +} + +func (srv *Server) createHandler() http.Handler { + var rtr = mux.NewRouter() + + // TODO: middleware: validate osbCtx.APIVersion that matches 2.12 + // TODO: middleware: add support for osbCtx.OriginatingIdentity + + rtr.HandleFunc("/statusz", func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "OK") + }).Methods("GET") + + // sync operations + rtr.HandleFunc("/v2/catalog", srv.catalogAction).Methods("GET") + rtr.HandleFunc("/v2/service_instances/{instance_id}/last_operation", srv.getServiceInstanceLastOperationAction).Methods("GET") + rtr.HandleFunc("/v2/service_instances/{instance_id}/service_bindings/{binding_id}", srv.bindAction).Methods("PUT") + rtr.HandleFunc("/v2/service_instances/{instance_id}/service_bindings/{binding_id}", srv.unBindAction).Methods("DELETE") + + // async operations + rtr.Path("/v2/service_instances/{instance_id}").Methods(http.MethodPut).Handler( + negroni.New(&RequireAsyncMiddleware{}, negroni.WrapFunc(srv.provisionAction)), + ) + rtr.Path("/v2/service_instances/{instance_id}").Methods(http.MethodDelete).Handler( + negroni.New(&RequireAsyncMiddleware{}, negroni.WrapFunc(srv.deprovisionAction)), + ) + + logMiddleware := negronilogrus.NewMiddlewareFromLogger(srv.logger.Logger, "") + logMiddleware.After = func(in *logrus.Entry, rw negroni.ResponseWriter, latency time.Duration, s string) *logrus.Entry { + return in.WithFields(logrus.Fields{ + "status": rw.Status(), + "took": latency, + "size": rw.Size(), + }) + } + + n := negroni.New(negroni.NewRecovery(), logMiddleware) + n.Use(&OSBContextMiddleware{}) + n.UseHandler(rtr) + return n +} + +func (srv *Server) catalogAction(w http.ResponseWriter, r *http.Request) { + osbCtx, _ := osbContextFromContext(r.Context()) + resp, err := srv.catalogGetter.GetCatalog(r.Context(), osbCtx) + if err != nil { + srv.writeErrorResponse(w, http.StatusBadRequest, err.Error(), "") + return + } + + if srv.logger != nil { + srv.logger.WithFields(logrus.Fields{ + "action": "catalog", + "resp:services:count": len(resp.Services), + }).Info("action response") + } + + srv.writeResponse(w, http.StatusOK, resp) +} + +func (srv *Server) provisionAction(w http.ResponseWriter, r *http.Request) { + osbCtx, _ := osbContextFromContext(r.Context()) + + var inDTO ProvisionRequestDTO + + if err := httpBodyToDTO(r, &inDTO); err != nil { + srv.writeErrorResponse(w, http.StatusBadRequest, err.Error(), "") + } + + instanceID := mux.Vars(r)["instance_id"] + + sReq := osb.ProvisionRequest{ + AcceptsIncomplete: true, + InstanceID: string(instanceID), + ServiceID: string(inDTO.ServiceID), + PlanID: string(inDTO.PlanID), + OrganizationGUID: inDTO.OrganizationGUID, + SpaceGUID: inDTO.SpaceGUID, + Parameters: inDTO.Parameters, + Context: map[string]interface{}{ + "namespace": string(inDTO.Context.Namespace), + }, + } + + sResp, err := srv.provisioner.Provision(r.Context(), osbCtx, &sReq) + if err != nil { + srv.writeErrorResponse(w, http.StatusBadRequest, err.Error(), "") + return + } + + logRespFields := logrus.Fields{ + "action": "provision", + "resp:async": sResp.Async, + } + logResp := func(fields logrus.Fields) { + if srv.logger != nil { + srv.logger.WithFields(fields).Info("action response") + } + } + + if !sResp.Async { + logResp(logRespFields) + srv.writeResponse(w, http.StatusOK, map[string]interface{}{}) + return + } + + opID := internal.OperationID(*sResp.OperationKey) + egDTO := ProvisionSuccessResponseDTO{ + Operation: &opID, + } + + logRespFields["resp:operation:id"] = opID + logResp(logRespFields) + + srv.writeResponse(w, http.StatusAccepted, egDTO) +} + +func (srv *Server) deprovisionAction(w http.ResponseWriter, r *http.Request) { + osbCtx, _ := osbContextFromContext(r.Context()) + + instanceID := mux.Vars(r)["instance_id"] + + q := r.URL.Query() + + svcIDRaw := q.Get("service_id") + planIDRaw := q.Get("plan_id") + sReq := osb.DeprovisionRequest{ + AcceptsIncomplete: true, + InstanceID: string(instanceID), + ServiceID: svcIDRaw, + PlanID: planIDRaw, + } + + sResp, err := srv.deprovisioner.Deprovision(r.Context(), osbCtx, &sReq) + switch { + case IsNotFoundError(err): + srv.writeResponse(w, http.StatusGone, map[string]interface{}{}) + return + case err != nil: + srv.writeErrorResponse(w, http.StatusBadRequest, err.Error(), "") + return + } + + logRespFields := logrus.Fields{ + "action": "deprovision", + "resp:async": sResp.Async, + } + logResp := func(fields logrus.Fields) { + if srv.logger != nil { + srv.logger.WithFields(fields).Info("action response") + } + } + + if !sResp.Async { + logResp(logRespFields) + srv.writeResponse(w, http.StatusGone, map[string]interface{}{}) + return + } + + opID := internal.OperationID(*sResp.OperationKey) + egDTO := ProvisionSuccessResponseDTO{ + Operation: &opID, + } + + logRespFields["resp:operation:id"] = opID + logResp(logRespFields) + + srv.writeResponse(w, http.StatusAccepted, egDTO) +} + +func (srv *Server) getServiceInstanceLastOperationAction(w http.ResponseWriter, r *http.Request) { + osbCtx, _ := osbContextFromContext(r.Context()) + + instanceID := mux.Vars(r)["instance_id"] + var operationID internal.OperationID + + q := r.URL.Query() + + sReq := osb.LastOperationRequest{ + InstanceID: string(instanceID), + } + if svcIDRaw := q.Get("service_id"); svcIDRaw != "" { + svcID := svcIDRaw + sReq.ServiceID = &svcID + } + if planIDRaw := q.Get("plan_id"); planIDRaw != "" { + planID := planIDRaw + sReq.PlanID = &planID + } + if opIDRaw := q.Get("operation"); opIDRaw != "" { + operationID = internal.OperationID(opIDRaw) + opKey := osb.OperationKey(opIDRaw) + sReq.OperationKey = &opKey + } + + sResp, err := srv.lastOpGetter.GetLastOperation(r.Context(), osbCtx, &sReq) + switch { + case IsNotFoundError(err): + srv.writeResponse(w, http.StatusGone, map[string]interface{}{}) + return + case err != nil: + srv.writeErrorResponse(w, http.StatusBadRequest, err.Error(), "") + return + } + + logRespFields := logrus.Fields{ + "action": "getLastOperation", + "instance:id": instanceID, + "operation:id": operationID, + "resp:operation:state": sResp.State, + "resp:operation:desc": nil, + } + + resp := LastOperationSuccessResponseDTO{ + State: internal.OperationState(sResp.State), + } + if sResp.Description != nil { + desc := string(*sResp.Description) + logRespFields["resp:operation:desc"] = desc + resp.Description = &desc + } + + if srv.logger != nil { + srv.logger.WithFields(logRespFields).Info("action response") + } + srv.writeResponse(w, http.StatusOK, resp) +} + +func (srv *Server) bindAction(w http.ResponseWriter, r *http.Request) { + osbCtx, _ := osbContextFromContext(r.Context()) + + instanceID := mux.Vars(r)["instance_id"] + + var params BindParametersDTO + err := httpBodyToDTO(r, ¶ms) + if err != nil { + srv.writeErrorResponse(w, http.StatusBadRequest, err.Error(), "cannot get bind parameters from request body") + } + + err = params.Validate() + if err != nil { + srv.writeErrorResponse(w, http.StatusBadRequest, err.Error(), "") + } + + q := r.URL.Query() + bindIDRaw := q.Get("binding_id") + + sReq := osb.BindRequest{ + InstanceID: instanceID, + ServiceID: params.ServiceID, + PlanID: params.PlanID, + BindingID: bindIDRaw, + } + sResp, err := srv.binder.Bind(r.Context(), osbCtx, &sReq) + if err != nil { + srv.writeErrorResponse(w, http.StatusBadRequest, err.Error(), "") + return + } + + if srv.logger != nil { + var keys []string + for k := range sResp.Credentials { + keys = append(keys, k) + } + logRespFields := logrus.Fields{ + "action": "bind", + "resp:async": false, + "resp:credentials:keys": keys, + } + srv.logger.WithFields(logRespFields).Info("action response") + } + + egDTO := BindSuccessResponseDTO{ + Credentials: sResp.Credentials, + } + srv.writeResponse(w, http.StatusCreated, egDTO) +} + +func (srv *Server) unBindAction(w http.ResponseWriter, r *http.Request) { + srv.writeResponse(w, http.StatusGone, map[string]interface{}{}) +} + +func (srv *Server) writeResponse(w http.ResponseWriter, code int, object interface{}) { + writeResponse(w, code, object) +} + +func writeResponse(w http.ResponseWriter, code int, object interface{}) { + data, err := json.Marshal(object) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + w.Write(data) +} + +func (srv *Server) writeErrorResponse(w http.ResponseWriter, code int, errorMsg, desc string) { + if srv.logger != nil { + srv.logger.Warnf("Server responds with error: [HTTP %d]: [%s] [%s]", code, errorMsg, desc) + } + writeErrorResponse(w, code, errorMsg, desc) +} + +// writeErrorResponse writes error response compatible with OpenServiceBroker API specification. +func writeErrorResponse(w http.ResponseWriter, code int, errorMsg, desc string) { + dto := struct { + // Error is a machine readable info on an error. + // As of 2.13 Open Broker API spec it's NOT passed to entity querying the catalog. + Error string `json:"error,optional"` + + // Desc is a meaningful error message explaining why the request failed. + // see: https://github.com/openservicebrokerapi/servicebroker/blob/v2.13/spec.md#broker-errors + Desc string `json:"description,optional"` + }{} + + if errorMsg != "" { + dto.Error = errorMsg + } + + if desc != "" { + dto.Desc = desc + } + writeResponse(w, code, &dto) +} + +type osbContext struct { + APIVersion string + OriginatingIdentity string +} + +func httpBodyToDTO(r *http.Request, object interface{}) error { + body, err := ioutil.ReadAll(r.Body) + if err != nil { + return err + } + defer r.Body.Close() + + err = json.Unmarshal(body, object) + if err != nil { + return err + } + + return nil +} diff --git a/components/helm-broker/internal/broker/state.go b/components/helm-broker/internal/broker/state.go new file mode 100644 index 000000000000..f50af17cf49a --- /dev/null +++ b/components/helm-broker/internal/broker/state.go @@ -0,0 +1,118 @@ +package broker + +import ( + "github.com/pkg/errors" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +type instanceStateService struct { + operationCollectionGetter operationCollectionGetter +} + +func (svc *instanceStateService) IsProvisioned(iID internal.InstanceID) (bool, error) { + result := false + + ops, err := svc.operationCollectionGetter.GetAll(iID) + switch { + case err == nil: + case IsNotFoundError(err): + return false, nil + default: + return false, errors.Wrap(err, "while getting operations from storage") + } + +OpsLoop: + for _, op := range ops { + if op.Type == internal.OperationTypeCreate && op.State == internal.OperationStateSucceeded { + result = true + } + if op.Type == internal.OperationTypeRemove && op.State == internal.OperationStateSucceeded { + result = false + break OpsLoop + } + } + + return result, nil +} + +func (svc *instanceStateService) IsProvisioningInProgress(iID internal.InstanceID) (internal.OperationID, bool, error) { + resultInProgress := false + var resultOpID internal.OperationID + + ops, err := svc.operationCollectionGetter.GetAll(iID) + switch { + case err == nil: + case IsNotFoundError(err): + return resultOpID, false, nil + default: + return resultOpID, false, errors.Wrap(err, "while getting operations from storage") + } + +OpsLoop: + for _, op := range ops { + if op.Type == internal.OperationTypeCreate && op.State == internal.OperationStateInProgress { + resultInProgress = true + resultOpID = op.OperationID + break OpsLoop + } + } + + return resultOpID, resultInProgress, nil +} + +func (svc *instanceStateService) IsDeprovisioned(iID internal.InstanceID) (bool, error) { + result := false + + ops, err := svc.operationCollectionGetter.GetAll(iID) + switch { + case err == nil: + case IsNotFoundError(err): + return false, err + default: + return false, errors.Wrap(err, "while getting operations from storage") + } + +OpsLoop: + for _, op := range ops { + if op.Type == internal.OperationTypeRemove && op.State == internal.OperationStateSucceeded { + result = true + break OpsLoop + } + } + + return result, nil +} + +func (svc *instanceStateService) IsDeprovisioningInProgress(iID internal.InstanceID) (internal.OperationID, bool, error) { + resultInProgress := false + var resultOpID internal.OperationID + + ops, err := svc.operationCollectionGetter.GetAll(iID) + switch { + case err == nil: + case IsNotFoundError(err): + return resultOpID, false, nil + default: + return resultOpID, false, errors.Wrap(err, "while getting operations from storage") + } + +OpsLoop: + for _, op := range ops { + if op.Type == internal.OperationTypeRemove && op.State == internal.OperationStateInProgress { + resultInProgress = true + resultOpID = op.OperationID + break OpsLoop + } + } + + return resultOpID, resultInProgress, nil +} + +// IsNotFoundError check if error is NotFound one. +func IsNotFoundError(err error) bool { + nfe, ok := err.(interface { + NotFound() bool + }) + return ok && nfe.NotFound() +} diff --git a/components/helm-broker/internal/broker/state_export_test.go b/components/helm-broker/internal/broker/state_export_test.go new file mode 100644 index 000000000000..d31a8e4de0eb --- /dev/null +++ b/components/helm-broker/internal/broker/state_export_test.go @@ -0,0 +1,7 @@ +package broker + +func NewInstanceStateService(ocg operationCollectionGetter) *instanceStateService { + return &instanceStateService{ + operationCollectionGetter: ocg, + } +} diff --git a/components/helm-broker/internal/broker/state_test.go b/components/helm-broker/internal/broker/state_test.go new file mode 100644 index 000000000000..bcabc980c49f --- /dev/null +++ b/components/helm-broker/internal/broker/state_test.go @@ -0,0 +1,406 @@ +package broker_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/kyma-project/kyma/components/helm-broker/internal/broker" + "github.com/kyma-project/kyma/components/helm-broker/internal/broker/automock" + "github.com/pkg/errors" +) + +func newInstanceStateServiceTestSuite(t *testing.T) *instanceStateServiceTestSuite { + return &instanceStateServiceTestSuite{t: t} +} + +type instanceStateServiceTestSuite struct { + t *testing.T + Exp expAll +} + +func (ts *instanceStateServiceTestSuite) SetUp() { + ts.Exp.Populate() +} + +func TestInstanceStateServiceIsProvisioned(t *testing.T) { + for sym, tc := range map[string]struct { + genOps func(ts *instanceStateServiceTestSuite) []*internal.InstanceOperation + exp bool + }{ + "true/singleCreateSucceeded": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + return append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateSucceeded)) + }, + exp: true, + }, + "true/CreateSucceededThanRemoveInProgress": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateSucceeded)) + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateInProgress)) + return out + }, + exp: true, + }, + "false/singleCreateInProgress": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + return append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateInProgress)) + }, + exp: false, + }, + "false/CreateSucceededThanRemoveSucceeded": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateSucceeded)) + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateSucceeded)) + return out + }, + exp: false, + }, + } { + t.Run(fmt.Sprintf("Success/%s", sym), func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(tc.genOps(ts), nil).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + got, err := svc.IsProvisioned(ts.Exp.InstanceID) + + // THEN + assert.NoError(t, err) + assert.Equal(t, tc.exp, got) + }) + } + + t.Run("Success/false/InstanceNotFound", func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(nil, notFoundError{}).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + got, err := svc.IsProvisioned(ts.Exp.InstanceID) + + // THEN + assert.NoError(t, err) + assert.False(t, got) + }) + + t.Run("Failure/GenericStorageError", func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + fixErr := errors.New("fix-storage-error") + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(nil, fixErr).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + got, err := svc.IsProvisioned(ts.Exp.InstanceID) + + // THEN + assert.EqualError(t, err, fmt.Sprintf("while getting operations from storage: %s", fixErr.Error())) + assert.False(t, got) + }) +} + +func TestInstanceStateServiceIsDeprovisioned(t *testing.T) { + for sym, tc := range map[string]struct { + genOps func(ts *instanceStateServiceTestSuite) []*internal.InstanceOperation + exp bool + }{ + "true/singleRemoveSucceeded": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + return append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateSucceeded)) + }, + exp: true, + }, + "true/CreateSucceededThanRemoveInProgress": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateSucceeded)) + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateInProgress)) + return out + }, + exp: false, + }, + "false/singleRemoveInProgress": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + return append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateInProgress)) + }, + exp: false, + }, + "false/CreateSucceededThanRemoveSucceeded": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateSucceeded)) + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateSucceeded)) + return out + }, + exp: true, + }, + } { + t.Run(fmt.Sprintf("Success/%s", sym), func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(tc.genOps(ts), nil).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + got, err := svc.IsDeprovisioned(ts.Exp.InstanceID) + + // THEN + assert.NoError(t, err) + assert.Equal(t, tc.exp, got) + }) + } + + t.Run("Success/false/InstanceNotFound", func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(nil, notFoundError{}).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + got, err := svc.IsDeprovisioned(ts.Exp.InstanceID) + + // THEN + assert.True(t, broker.IsNotFoundError(err)) + assert.False(t, got) + }) + + t.Run("Failure/GenericStorageError", func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + fixErr := errors.New("fix-storage-error") + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(nil, fixErr).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + got, err := svc.IsDeprovisioned(ts.Exp.InstanceID) + + // THEN + assert.EqualError(t, err, fmt.Sprintf("while getting operations from storage: %s", fixErr.Error())) + assert.False(t, got) + }) +} + +func TestInstanceStateServiceIsDeprovisioningInProgress(t *testing.T) { + for sym, tc := range map[string]struct { + genOps func(ts *instanceStateServiceTestSuite) []*internal.InstanceOperation + exp bool + }{ + "false/singleRemoveSucceeded": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + return append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateSucceeded)) + }, + exp: false, + }, + "true/CreateSucceededThanRemoveInProgress": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateSucceeded)) + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateInProgress)) + return out + }, + exp: true, + }, + "false/NoOp": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { return out }, + exp: false, + }, + "false/CreateSucceededThanRemoveSucceeded": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateSucceeded)) + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateSucceeded)) + return out + }, + exp: false, + }, + } { + t.Run(fmt.Sprintf("Success/%s", sym), func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(tc.genOps(ts), nil).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + gotOpID, gotInProgress, err := svc.IsDeprovisioningInProgress(ts.Exp.InstanceID) + + // THEN + assert.NoError(t, err) + assert.Equal(t, tc.exp, gotInProgress) + if tc.exp { + assert.Equal(t, ts.Exp.OperationID, gotOpID) + } + }) + } + + t.Run("Success/false/InstanceNotFound", func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(nil, notFoundError{}).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + gotOpID, got, err := svc.IsDeprovisioningInProgress(ts.Exp.InstanceID) + + // THEN + assert.NoError(t, err) + assert.False(t, got) + assert.Zero(t, gotOpID) + }) + + t.Run("Failure/GenericStorageError", func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + fixErr := errors.New("fix-storage-error") + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(nil, fixErr).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + gotOpID, got, err := svc.IsDeprovisioningInProgress(ts.Exp.InstanceID) + + // THEN + assert.EqualError(t, err, fmt.Sprintf("while getting operations from storage: %s", fixErr.Error())) + assert.False(t, got) + assert.Zero(t, gotOpID) + }) +} + +func TestInstanceStateServiceIsProvisioningInProgress(t *testing.T) { + for sym, tc := range map[string]struct { + genOps func(ts *instanceStateServiceTestSuite) []*internal.InstanceOperation + expInProgress bool + }{ + "true/singleCreateInProgress": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + return append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateInProgress)) + }, + expInProgress: true, + }, + "false/singleCreateSucceeded": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + return append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateSucceeded)) + }, + expInProgress: false, + }, + "false/NoOp": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { return out }, + expInProgress: false, + }, + "false/CreateSucceededThanRemoveInProgress": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateSucceeded)) + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateInProgress)) + return out + }, + expInProgress: false, + }, + } { + t.Run(fmt.Sprintf("Success/%s", sym), func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(tc.genOps(ts), nil).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + gotOpID, gotInProgress, err := svc.IsProvisioningInProgress(ts.Exp.InstanceID) + + // THEN + assert.NoError(t, err) + assert.Equal(t, tc.expInProgress, gotInProgress) + if tc.expInProgress { + assert.Equal(t, ts.Exp.OperationID, gotOpID) + } + }) + } + + t.Run("Success/false/InstanceNotFound", func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(nil, notFoundError{}).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + gotOpID, got, err := svc.IsProvisioningInProgress(ts.Exp.InstanceID) + + // THEN + assert.NoError(t, err) + assert.False(t, got) + assert.Zero(t, gotOpID) + }) + + t.Run("Failure/GenericStorageError", func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + fixErr := errors.New("fix-storage-error") + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(nil, fixErr).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + gotOpID, got, err := svc.IsProvisioningInProgress(ts.Exp.InstanceID) + + // THEN + assert.EqualError(t, err, fmt.Sprintf("while getting operations from storage: %s", fixErr.Error())) + assert.False(t, got) + assert.Zero(t, gotOpID) + }) +} diff --git a/components/helm-broker/internal/broker/unbind.go b/components/helm-broker/internal/broker/unbind.go new file mode 100644 index 000000000000..ef81dd8d78cb --- /dev/null +++ b/components/helm-broker/internal/broker/unbind.go @@ -0,0 +1,13 @@ +package broker + +import ( + "context" + + osb "github.com/pmorie/go-open-service-broker-client/v2" +) + +type unbindService struct{} + +func (svc *unbindService) Unbind(ctx context.Context, osbCtx osbContext, req *osb.UnbindRequest) (*osb.UnbindResponse, error) { + return nil, nil +} diff --git a/components/helm-broker/internal/config/config.go b/components/helm-broker/internal/config/config.go new file mode 100644 index 000000000000..c9d55937243f --- /dev/null +++ b/components/helm-broker/internal/config/config.go @@ -0,0 +1,84 @@ +package config + +import ( + "fmt" + "io/ioutil" + "os" + + "github.com/asaskevich/govalidator" + "github.com/ghodss/yaml" + "github.com/imdario/mergo" + "github.com/kyma-project/kyma/components/helm-broker/internal/helm" + "github.com/kyma-project/kyma/components/helm-broker/internal/storage" + "github.com/kyma-project/kyma/components/helm-broker/internal/ybundle" + "github.com/kyma-project/kyma/components/helm-broker/platform/logger" + "github.com/mcuadros/go-defaults" + "github.com/pkg/errors" + "github.com/vrischmann/envconfig" +) + +// Config provide helm broker configuration +// Supported tags: +// - json: github.com/ghodss/yaml +// - envconfig: github.com/vrischmann/envconfig +// - default: github.com/mcuadros/go-defaults +// - valid github.com/asaskevich/govalidator +// Example of valid tag: `valid:"alphanum,required"` +// Combining many tags: tags have to be separated by WHITESPACE: `json:"port" default:"8080" valid:"required"` +type Config struct { + Logger logger.Config + // TmpDir defines temporary directory path where bundles .tgz files will be extracted + TmpDir string + Port int `default:"8080"` + Storage []storage.Config `valid:"required"` + Repository ybundle.RepositoryConfig + Helm helm.Config `valid:"required"` +} + +// Load method has following strategy: +// 1. Check env variable 'APP_CONFIG_FILE_NAME', if exists load configuration from specified file +// 2. Read configuration from environment variables (will override configuration from file) +// 3. Apply defaults +// 4. Validate +func Load(verbose bool) (*Config, error) { + outCfg := Config{} + + cfgFile := os.Getenv("APP_CONFIG_FILE_NAME") + if cfgFile != "" { + b, err := ioutil.ReadFile(cfgFile) + if err != nil { + return nil, errors.Wrapf(err, "while opening config file [%s]", cfgFile) + } + fileConfig := Config{} + if err := yaml.Unmarshal(b, &fileConfig); err != nil { + return nil, errors.Wrap(err, "while unmarshalling config from file") + } + outCfg = fileConfig + // fmt.Printf used, because logger will be created after reading configuration + if verbose { + fmt.Printf("Config after applying values from file: %+v\n", outCfg) + } + } + + envConf := Config{} + if err := envconfig.InitWithOptions(&envConf, envconfig.Options{Prefix: "APP", AllOptional: true, AllowUnexported: true}); err != nil { + return nil, errors.Wrap(err, "while reading configuration from environment variables") + } + + if err := mergo.MergeWithOverwrite(&outCfg, &envConf); err != nil { + return nil, errors.Wrap(err, "while merging config from environment variables") + } + if verbose { + fmt.Printf("Config after applying values from environment variables: %+v\n", outCfg) + } + + defaults.SetDefaults(&outCfg) + + if verbose { + fmt.Printf("Config after applying defaults: %+v\n", outCfg) + } + if _, err := govalidator.ValidateStruct(outCfg); err != nil { + return nil, errors.Wrap(err, "while validating configuration object") + } + return &outCfg, nil +} diff --git a/components/helm-broker/internal/doc.go b/components/helm-broker/internal/doc.go new file mode 100644 index 000000000000..5bf0569ce8cb --- /dev/null +++ b/components/helm-broker/internal/doc.go @@ -0,0 +1 @@ +package internal diff --git a/components/helm-broker/internal/helm/automock/helm_delete_installer.go b/components/helm-broker/internal/helm/automock/helm_delete_installer.go new file mode 100644 index 000000000000..25a4e0d0d0d2 --- /dev/null +++ b/components/helm-broker/internal/helm/automock/helm_delete_installer.go @@ -0,0 +1,73 @@ +// Code generated by mockery v1.0.0 +package automock + +import chart "k8s.io/helm/pkg/proto/hapi/chart" +import helm "k8s.io/helm/pkg/helm" + +import mock "github.com/stretchr/testify/mock" +import services "k8s.io/helm/pkg/proto/hapi/services" + +// HelmDeleteInstaller is an autogenerated mock type for the HelmDeleteInstaller type +type HelmDeleteInstaller struct { + mock.Mock +} + +// DeleteRelease provides a mock function with given fields: rlsName, opts +func (_m *HelmDeleteInstaller) DeleteRelease(rlsName string, opts ...helm.DeleteOption) (*services.UninstallReleaseResponse, error) { + _va := make([]interface{}, len(opts)) + for _i := range opts { + _va[_i] = opts[_i] + } + var _ca []interface{} + _ca = append(_ca, rlsName) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *services.UninstallReleaseResponse + if rf, ok := ret.Get(0).(func(string, ...helm.DeleteOption) *services.UninstallReleaseResponse); ok { + r0 = rf(rlsName, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*services.UninstallReleaseResponse) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, ...helm.DeleteOption) error); ok { + r1 = rf(rlsName, opts...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// InstallReleaseFromChart provides a mock function with given fields: _a0, ns, opts +func (_m *HelmDeleteInstaller) InstallReleaseFromChart(_a0 *chart.Chart, ns string, opts ...helm.InstallOption) (*services.InstallReleaseResponse, error) { + _va := make([]interface{}, len(opts)) + for _i := range opts { + _va[_i] = opts[_i] + } + var _ca []interface{} + _ca = append(_ca, _a0, ns) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *services.InstallReleaseResponse + if rf, ok := ret.Get(0).(func(*chart.Chart, string, ...helm.InstallOption) *services.InstallReleaseResponse); ok { + r0 = rf(_a0, ns, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*services.InstallReleaseResponse) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*chart.Chart, string, ...helm.InstallOption) error); ok { + r1 = rf(_a0, ns, opts...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/helm-broker/internal/helm/client.go b/components/helm-broker/internal/helm/client.go new file mode 100644 index 000000000000..422e9cc80fc1 --- /dev/null +++ b/components/helm-broker/internal/helm/client.go @@ -0,0 +1,71 @@ +package helm + +import ( + "time" + + "github.com/ghodss/yaml" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "k8s.io/helm/pkg/helm" + "k8s.io/helm/pkg/proto/hapi/chart" + rls "k8s.io/helm/pkg/proto/hapi/services" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +const ( + installTimeout = time.Hour + deleteWithPurge = true +) + +// NewClient creates Tiller client +func NewClient(cfg Config, log *logrus.Entry) *Client { + return &Client{ + tillerHost: cfg.TillerHost, + tillerConnTimeout: int64(cfg.TillerConnectionTimeout), + log: log.WithField("service", "helm_client"), + } +} + +// Client provide communication with Tiller +type Client struct { + tillerHost string + tillerConnTimeout int64 + log *logrus.Entry +} + +// Install is installing chart release +func (cli *Client) Install(c *chart.Chart, values internal.ChartValues, releaseName internal.ReleaseName, namespace internal.Namespace) (*rls.InstallReleaseResponse, error) { + cli.log.Infof("Installing chart with release name [%s] in namespace [%s]", releaseName, namespace) + byteValues, err := yaml.Marshal(values) + + if err != nil { + return nil, errors.Wrapf(err, "while marshalling chart values: [%v]", values) + } + resp, err := cli.helmClient().InstallReleaseFromChart(c, string(namespace), + helm.InstallWait(true), + helm.InstallTimeout(int64(installTimeout.Seconds())), + helm.ValueOverrides(byteValues), + helm.ReleaseName(string(releaseName))) + if err != nil { + return nil, errors.Wrapf(err, "while installing release from chart with name [%s] in namespace [%s]", releaseName, namespace) + } + return resp, nil +} + +// Delete is deleting release of the chart +func (cli *Client) Delete(releaseName internal.ReleaseName) error { + cli.log.WithField("purge", deleteWithPurge).Infof("Deleting chart with release name [%s]", releaseName) + if _, err := cli.helmClient().DeleteRelease(string(releaseName), helm.DeletePurge(deleteWithPurge)); err != nil { + return errors.Wrapf(err, "while deleting release name: [%s]", releaseName) + } + return nil +} + +func (cli *Client) helmClient() helmDeleteInstaller { + // helm client is not thread safe - + // + // helm.ConnectTimeout option is REQUIRED, because of this issue: + // https://github.com/kubernetes/helm/issues/3658 + return helm.NewClient(helm.Host(cli.tillerHost), helm.ConnectTimeout(cli.tillerConnTimeout)) +} diff --git a/components/helm-broker/internal/helm/client_test.go b/components/helm-broker/internal/helm/client_test.go new file mode 100644 index 000000000000..8ceba1f19aec --- /dev/null +++ b/components/helm-broker/internal/helm/client_test.go @@ -0,0 +1,144 @@ +package helm_test + +import ( + "context" + "net" + "testing" + "time" + + "github.com/ghodss/yaml" + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/kyma-project/kyma/components/helm-broker/internal/helm" + "github.com/kyma-project/kyma/components/helm-broker/platform/logger/spy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "k8s.io/helm/pkg/proto/hapi/chart" + hapi_release5 "k8s.io/helm/pkg/proto/hapi/release" + "k8s.io/helm/pkg/proto/hapi/services" +) + +func TestClientInstallSuccess(t *testing.T) { + // given + fakeTiller := &fakeTillerSvc{} + fakeTiller.SetUp(t) + + cVals := internal.ChartValues{ + "test-param": "value-test", + } + + hClient := helm.NewClient(helm.Config{ + TillerHost: fakeTiller.Host, + TillerConnectionTimeout: time.Second, + }, spy.NewLogDummy()) + + // when + _, err := hClient.Install(fixChart(), cVals, "r-name", "ns-name") + + // then + assert.NoError(t, err) + + require.NotNil(t, fakeTiller.GotInstReleaseReq) + assert.True(t, fakeTiller.GotInstReleaseReq.Wait) + assert.Equal(t, fakeTiller.GotInstReleaseReq.Timeout, int64(time.Hour.Seconds())) + assert.False(t, fakeTiller.GotInstReleaseReq.DryRun) + assert.False(t, fakeTiller.GotInstReleaseReq.ReuseName) + assert.False(t, fakeTiller.GotInstReleaseReq.DisableHooks) + assert.Equal(t, fakeTiller.GotInstReleaseReq.Name, "r-name") + assert.Equal(t, fakeTiller.GotInstReleaseReq.Namespace, "ns-name") + assert.Equal(t, fakeTiller.GotInstReleaseReq.Chart, fixChart()) + + b, err := yaml.Marshal(cVals) + require.NoError(t, err) + assert.Equal(t, fakeTiller.GotInstReleaseReq.Values, &chart.Config{Raw: string(b)}) + + // Clean-up + fakeTiller.TearDown(t) +} + +func TestClientDeleteSuccess(t *testing.T) { + // given + fakeTiller := &fakeTillerSvc{} + fakeTiller.SetUp(t) + + hClient := helm.NewClient(helm.Config{ + TillerHost: fakeTiller.Host, + TillerConnectionTimeout: time.Second, + }, spy.NewLogDummy()) + + // when + err := hClient.Delete("r-name") + + // then + assert.NoError(t, err) + + assert.NotNil(t, fakeTiller.GotDelReleaseReq) + assert.Equal(t, fakeTiller.GotDelReleaseReq.Name, "r-name") + + // Clean-up + fakeTiller.TearDown(t) +} + +type fakeTillerSvc struct { + services.ReleaseServiceServer + GotInstReleaseReq *services.InstallReleaseRequest + GotDelReleaseReq *services.UninstallReleaseRequest + + grpcSvc *grpc.Server + Host string + serverErr error + serverClosed chan struct{} +} + +func (s *fakeTillerSvc) SetUp(t *testing.T) { + s.serverClosed = make(chan struct{}, 1) + lis, err := net.Listen("tcp", ":0") + require.NoError(t, err) + + s.Host = lis.Addr().String() + + s.grpcSvc = grpc.NewServer() + services.RegisterReleaseServiceServer(s.grpcSvc, s) + + go func() { + s.serverErr = s.grpcSvc.Serve(lis) + close(s.serverClosed) + }() +} + +func (s *fakeTillerSvc) TearDown(t *testing.T) { + s.grpcSvc.GracefulStop() + + select { + case <-s.serverClosed: + case <-time.After(time.Second): + t.Errorf("Timeout [%v] occured when wainting to server shudown. ", time.Second) + } +} + +func (s *fakeTillerSvc) InstallRelease(ctx context.Context, instReleaseReq *services.InstallReleaseRequest) (*services.InstallReleaseResponse, error) { + s.GotInstReleaseReq = instReleaseReq + return &services.InstallReleaseResponse{ + Release: &hapi_release5.Release{ + Name: "Fake-Test-Release", + }, + }, nil +} + +func (s *fakeTillerSvc) UninstallRelease(ctx context.Context, delReleaseReq *services.UninstallReleaseRequest) (*services.UninstallReleaseResponse, error) { + s.GotDelReleaseReq = delReleaseReq + return &services.UninstallReleaseResponse{ + Release: &hapi_release5.Release{ + Name: "Fake-Test-Release", + }, + }, nil +} + +func fixChart() *chart.Chart { + return &chart.Chart{ + Metadata: &chart.Metadata{ + Name: string("Fix-chart"), + Version: "1.0.0", + }, + } +} diff --git a/components/helm-broker/internal/helm/config.go b/components/helm-broker/internal/helm/config.go new file mode 100644 index 000000000000..e5bbe37dadc3 --- /dev/null +++ b/components/helm-broker/internal/helm/config.go @@ -0,0 +1,9 @@ +package helm + +import "time" + +// Config holds configuration for helm client +type Config struct { + TillerHost string + TillerConnectionTimeout time.Duration `envconfig:"default=5s"` +} diff --git a/components/helm-broker/internal/helm/dep.go b/components/helm-broker/internal/helm/dep.go new file mode 100644 index 000000000000..3261a5735172 --- /dev/null +++ b/components/helm-broker/internal/helm/dep.go @@ -0,0 +1,12 @@ +package helm + +import ( + "k8s.io/helm/pkg/helm" + "k8s.io/helm/pkg/proto/hapi/chart" + rls "k8s.io/helm/pkg/proto/hapi/services" +) + +type helmDeleteInstaller interface { + InstallReleaseFromChart(chart *chart.Chart, ns string, opts ...helm.InstallOption) (*rls.InstallReleaseResponse, error) + DeleteRelease(rlsName string, opts ...helm.DeleteOption) (*rls.UninstallReleaseResponse, error) +} diff --git a/components/helm-broker/internal/model.go b/components/helm-broker/internal/model.go new file mode 100644 index 000000000000..4e625daeaa17 --- /dev/null +++ b/components/helm-broker/internal/model.go @@ -0,0 +1,254 @@ +package internal + +import ( + "bytes" + "encoding/gob" + "time" + + "github.com/Masterminds/semver" + "github.com/alecthomas/jsonschema" + "github.com/fatih/structs" + "github.com/pkg/errors" +) + +// BundleID is a Bundle identifier as defined by Open Service Broker API. +type BundleID string + +// BundleName is a Bundle name as defined by Open Service Broker API. +type BundleName string + +// BundlePlanID is an identifier of Bundle plan as defined by Open Service Broker API. +type BundlePlanID string + +// BundlePlanName is the name of the Bundle plan as defined by Open Service Broker API +type BundlePlanName string + +// PlanSchemaType describes type of the schema file. +type PlanSchemaType string + +// PlanSchema is schema definition used for creating parameters +type PlanSchema jsonschema.Schema + +const ( + // SchemaTypeBind represents 'bind' schema plan + SchemaTypeBind PlanSchemaType = "bind" + // SchemaTypeProvision represents 'provision' schema plan + SchemaTypeProvision PlanSchemaType = "provision" + // SchemaTypeUpdate represents 'update' schema plan + SchemaTypeUpdate PlanSchemaType = "update" +) + +// ChartName is a type expressing name of the chart +type ChartName string + +// ChartRef provide reference to bundle's chart +type ChartRef struct { + Name ChartName + Version semver.Version +} + +// GobDecode is decoding chart info +func (cr *ChartRef) GobDecode(in []byte) error { + var dto struct { + Name ChartName + Version string + } + + buf := bytes.NewReader(in) + dec := gob.NewDecoder(buf) + if err := dec.Decode(&dto); err != nil { + return errors.Wrap(err, "while decoding") + } + + cr.Name = dto.Name + + ver, _ := semver.NewVersion(dto.Version) + cr.Version = *ver + + return nil +} + +// GobEncode implements GobEncoder for custom encoding +func (cr ChartRef) GobEncode() ([]byte, error) { + dto := struct { + Name ChartName + Version string + }{ + Name: cr.Name, + Version: cr.Version.String(), + } + + buf := bytes.Buffer{} + enc := gob.NewEncoder(&buf) + if err := enc.Encode(&dto); err != nil { + return []byte{}, errors.Wrap(err, "while encoding") + } + + return buf.Bytes(), nil +} + +// ChartValues are used as container for chart's values. +// It's currently populated from yaml file or request parameters. +// TODO: switch to more concrete type +type ChartValues map[string]interface{} + +// BundlePlanBindTemplate represents template used for helm chart installation +type BundlePlanBindTemplate []byte + +// BundlePlan is a container for whole data of bundle plan. +// Each bundle needs to have at least one plan. +type BundlePlan struct { + ID BundlePlanID + Name BundlePlanName + Description string + Schemas map[PlanSchemaType]PlanSchema + ChartRef ChartRef + ChartValues ChartValues + Metadata BundlePlanMetadata + Bindable *bool + BindTemplate BundlePlanBindTemplate +} + +// BundlePlanMetadata provides metadata of the bundle. +type BundlePlanMetadata struct { + DisplayName string +} + +// ToMap function is converting Metadata to format compatible with YAML encoder. +func (b BundlePlanMetadata) ToMap() map[string]interface{} { + type mapped struct { + DisplayName string `structs:"displayName"` + } + + return structs.Map(mapped(b)) +} + +// BundleTag is a Tag attached to Bundle. +type BundleTag string + +// Bundle represents bundle as defined by OSB API. +type Bundle struct { + ID BundleID + Name BundleName + Version semver.Version + Description string + Plans map[BundlePlanID]BundlePlan + Metadata BundleMetadata + Tags []BundleTag + Bindable bool +} + +// BundleMetadata holds bundle metadata as defined by OSB API. +type BundleMetadata struct { + DisplayName string + ProviderDisplayName string + LongDescription string + DocumentationURL string + SupportURL string + // ImageURL is graphical representation of the bundle. + // Currently SVG is required. + ImageURL string +} + +// ToMap collect data from BundleMetadata to format compatible with YAML encoder. +func (b BundleMetadata) ToMap() map[string]interface{} { + type mapped struct { + DisplayName string `structs:"displayName"` + ProviderDisplayName string `structs:"providerDisplayName"` + LongDescription string `structs:"longDescription"` + DocumentationURL string `structs:"documentationURL"` + SupportURL string `structs:"supportURL"` + ImageURL string `structs:"imageURL"` + } + return structs.Map(mapped(b)) +} + +// InstanceID is a service instance identifier. +type InstanceID string + +// IsZero checks if InstanceID equals zero. +func (id InstanceID) IsZero() bool { return id == InstanceID("") } + +// OperationID is used as binding operation identifier. +type OperationID string + +// IsZero checks if OperationID equals zero +func (id OperationID) IsZero() bool { return id == OperationID("") } + +// InstanceOperation represents single operation. +type InstanceOperation struct { + InstanceID InstanceID + OperationID OperationID + Type OperationType + State OperationState + StateDescription *string + + // ParamsHash is an immutable hash for operation parameters + // used to match requests. + ParamsHash string + + // CreatedAt points to creation time of the operation. + // Field should be treated as immutable and is responsibility of storage implementation. + // It should be set by storage Insert method. + CreatedAt time.Time +} + +// ReleaseName is the name of the Helm (Tiller) release. +type ReleaseName string + +// ServiceID is an ID of the Service exposed via Service Catalog. +type ServiceID string + +// ServicePlanID is an ID of the Plan of Service exposed via Service Catalog. +type ServicePlanID string + +// Namespace is the name of namespace in k8s +type Namespace string + +// Instance contains info about Service exposed via Service Catalog. +type Instance struct { + ID InstanceID + ServiceID ServiceID + ServicePlanID ServicePlanID + ReleaseName ReleaseName + Namespace Namespace + ParamsHash string +} + +// InstanceCredentials are created when we bind a service instance. +type InstanceCredentials map[string]string + +// InstanceBindData contains data about service instance and it's credentials. +type InstanceBindData struct { + InstanceID InstanceID + Credentials InstanceCredentials +} + +// OperationState defines the possible states of an asynchronous request to a broker. +type OperationState string + +// String returns state of the operation. +func (os OperationState) String() string { + return string(os) +} + +const ( + // OperationStateInProgress means that operation is in progress + OperationStateInProgress OperationState = "in progress" + // OperationStateSucceeded means that request succeeded + OperationStateSucceeded OperationState = "succeeded" + // OperationStateFailed means that request failed + OperationStateFailed OperationState = "failed" +) + +// OperationType defines the possible types of an asynchronous operation to a broker. +type OperationType string + +const ( + // OperationTypeCreate means creating OperationType + OperationTypeCreate OperationType = "create" + // OperationTypeRemove means removing OperationType + OperationTypeRemove OperationType = "remove" + // OperationTypeUndefined means undefined OperationType + OperationTypeUndefined OperationType = "" +) diff --git a/components/helm-broker/internal/model_test.go b/components/helm-broker/internal/model_test.go new file mode 100644 index 000000000000..7b8a9c0a15db --- /dev/null +++ b/components/helm-broker/internal/model_test.go @@ -0,0 +1,40 @@ +package internal_test + +import ( + "bytes" + "encoding/gob" + "testing" + + "github.com/Masterminds/semver" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +func TestChartRefGobEncodeDecode(t *testing.T) { + for sym, exp := range map[string]internal.ChartRef{ + "A": {Name: "NameA", Version: *semver.MustParse("0.0.1")}, + "empty/name": {Name: "NameA"}, + "empty/all": {}, + } { + t.Run(sym, func(t *testing.T) { + // GIVEN: + buf := bytes.Buffer{} + enc := gob.NewEncoder(&buf) + dec := gob.NewDecoder(&buf) + var got internal.ChartRef + + // WHEN: + err := enc.Encode(&exp) + require.NoError(t, err) + + err = dec.Decode(&got) + require.NoError(t, err) + + // THEN: + assert.Equal(t, exp.Name, got.Name) + assert.Equal(t, exp.Version.String(), got.Version.String()) + }) + } +} diff --git a/components/helm-broker/internal/platform/idprovider/id_provider.go b/components/helm-broker/internal/platform/idprovider/id_provider.go new file mode 100644 index 000000000000..2f5ecd93b756 --- /dev/null +++ b/components/helm-broker/internal/platform/idprovider/id_provider.go @@ -0,0 +1,23 @@ +package idprovider + +//noinspection SpellCheckingInspection +import ( + crand "crypto/rand" + "time" + + "github.com/oklog/ulid" + "github.com/pkg/errors" +) + +// New returns function which generates ULID ids. +// Reader from crypto/rand is used as a entropy source. It is safe for concurrent use. +func New() func() (string, error) { + return func() (string, error) { + ulidGen, err := ulid.New(ulid.Timestamp(time.Now()), crand.Reader) + if err != nil { + // not covered directly by tests due to quite difficult trigger scenario. + return "", errors.Wrap(err, "while generating ID") + } + return ulidGen.String(), nil + } +} diff --git a/components/helm-broker/internal/platform/logger/spy/formatter.go b/components/helm-broker/internal/platform/logger/spy/formatter.go new file mode 100644 index 000000000000..14494b7c4913 --- /dev/null +++ b/components/helm-broker/internal/platform/logger/spy/formatter.go @@ -0,0 +1,56 @@ +package spy + +import ( + "sync" + + "github.com/sirupsen/logrus" +) + +// EntryAssertFormatter is a log formatter, which gather all logged entries +type EntryAssertFormatter struct { + entries []logrus.Entry + Underlying logrus.Formatter + mu sync.RWMutex +} + +// Format appends each entry to entries slice +func (f *EntryAssertFormatter) Format(entry *logrus.Entry) ([]byte, error) { + f.appendThreadSafe(*entry) + return f.Underlying.Format(entry) +} + +// AnyMatches iterates over all stored entries and execute given matcher on it. +// Return true if any entries was successful matched +func (f *EntryAssertFormatter) AnyMatches(matcher func(entry logrus.Entry) bool) bool { + f.mu.RLock() + copyOfEntries := make([]logrus.Entry, len(f.entries)) + copy(copyOfEntries, f.entries) + f.mu.RUnlock() + for _, entry := range copyOfEntries { + if matcher(entry) { + return true + } + } + return false +} + +// AllEntriesMatches iterates over all stored entries and execute given matcher on it. +// Return true only if all entries was successful matched +func (f *EntryAssertFormatter) AllEntriesMatches(matcher func(entry logrus.Entry) bool) bool { + f.mu.RLock() + copyOfEntries := make([]logrus.Entry, len(f.entries)) + copy(copyOfEntries, f.entries) + f.mu.RUnlock() + for _, entry := range copyOfEntries { + if !matcher(entry) { + return false + } + } + return true +} + +func (f *EntryAssertFormatter) appendThreadSafe(entry logrus.Entry) { + f.mu.Lock() + defer f.mu.Unlock() + f.entries = append(f.entries, entry) +} diff --git a/components/helm-broker/internal/platform/logger/spy/logger.go b/components/helm-broker/internal/platform/logger/spy/logger.go new file mode 100644 index 000000000000..45ed1f468737 --- /dev/null +++ b/components/helm-broker/internal/platform/logger/spy/logger.go @@ -0,0 +1,133 @@ +// Package spy provides an implementation of go-sdk.logger that helps test logging. +package spy + +import ( + "bufio" + "bytes" + "encoding/json" + "io/ioutil" + "strings" + "testing" + + "github.com/sirupsen/logrus" +) + +// LogSink is a helper construct for testing logging in unit tests. +// Beware: all methods are working on copies of of original messages buffer and are safe for multiple uses. +type LogSink struct { + buffer *bytes.Buffer + RawLogger *logrus.Logger + Logger *logrus.Entry +} + +// NewLogSink is a factory for LogSink +func NewLogSink() *LogSink { + buffer := bytes.NewBuffer([]byte("")) + + rawLgr := &logrus.Logger{ + Out: buffer, + // standard json formatter is used to ease testing + Formatter: new(logrus.JSONFormatter), + Hooks: make(logrus.LevelHooks), + Level: logrus.DebugLevel, + } + + lgr := rawLgr.WithField("testing", true) + + return &LogSink{ + buffer: buffer, + RawLogger: rawLgr, + Logger: lgr, + } +} + +// AssertErrorLogged checks whatever a specific string was logged as error. +// +// Compared elements: level, message +// +// Wrapped errors are supported as long as original error message ends up in resulting one. +func (s *LogSink) AssertErrorLogged(t *testing.T, errorExpected error) { + if !s.wasLogged(t, logrus.ErrorLevel, errorExpected.Error()) { + t.Errorf("error was not logged, expected: \"%s\"", errorExpected.Error()) + } +} + +// AssertLogged checks whatever a specific string was logged at a specific level. +// +// Compared elements: level, message +// +// Beware: we are checking for sub-strings and not for the exact match. +func (s *LogSink) AssertLogged(t *testing.T, level logrus.Level, message string) { + if !s.wasLogged(t, level, message) { + t.Errorf("message was not logged, message: \"%s\", level: %s", message, level) + } +} + +// AssertNotLogged checks whatever a specific string was not logged at a specific level. +// +// Compared elements: level, message +// +// Beware: we are checking for sub-strings and not for the exact match. +func (s *LogSink) AssertNotLogged(t *testing.T, level logrus.Level, message string) { + if s.wasLogged(t, level, message) { + t.Errorf("message was logged, message: \"%s\", level: %s", message, level) + } +} + +// wasLogged checks whatever a message was logged. +// +// Compared elements: level, message +func (s *LogSink) wasLogged(t *testing.T, level logrus.Level, message string) bool { + // new reader is created so we are safe for multiple reads + buf := bytes.NewReader(s.buffer.Bytes()) + scanner := bufio.NewScanner(buf) + var entryPartial struct { + Level string `json:"level"` + Msg string `json:"msg"` + } + + for scanner.Scan() { + line := scanner.Text() + + err := json.Unmarshal([]byte(line), &entryPartial) + if err != nil { + t.Fatalf("unexpected error on log line unmarshalling, line: %s", line) + } + + levelMatches := entryPartial.Level == level.String() + + // We are looking only if expected is contained (as opposed to exact match check), + // so that e.g. errors wrapping is supported. + containsMessage := strings.Contains(entryPartial.Msg, message) + + if levelMatches && containsMessage { + return true + } + } + + return false +} + +// DumpAll returns all logged messages. +func (s *LogSink) DumpAll() []string { + // new reader is created so we are safe for multiple reads + buf := bytes.NewReader(s.buffer.Bytes()) + scanner := bufio.NewScanner(buf) + + out := []string{} + for scanner.Scan() { + out = append(out, scanner.Text()) + } + + return out +} + +// NewLogDummy returns dummy logger which discards logged messages on the fly. +// Useful when logger is required as dependency in unit testing. +func NewLogDummy() *logrus.Entry { + rawLgr := logrus.New() + rawLgr.Out = ioutil.Discard + lgr := rawLgr.WithField("testing", true) + + return lgr +} diff --git a/components/helm-broker/internal/storage/config_test.go b/components/helm-broker/internal/storage/config_test.go new file mode 100644 index 000000000000..03bc24fe8f8d --- /dev/null +++ b/components/helm-broker/internal/storage/config_test.go @@ -0,0 +1,26 @@ +package storage_test + +import ( + "io/ioutil" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kyma-project/kyma/components/helm-broker/internal/storage" + "github.com/kyma-project/kyma/components/helm-broker/internal/storage/testdata" +) + +func TestConfigParse(t *testing.T) { + // GIVEN: + in, err := ioutil.ReadFile("testdata/ConfigAllMemory.input.yaml") + require.NoError(t, err) + + exp := testdata.GoldenConfigMemorySingleAll() + + // WHEN: + got, err := storage.ConfigParse(in) + + // THEN: + assert.EqualValues(t, exp, *got) +} diff --git a/components/helm-broker/internal/storage/driver/etcd/client.go b/components/helm-broker/internal/storage/driver/etcd/client.go new file mode 100644 index 000000000000..89c937827001 --- /dev/null +++ b/components/helm-broker/internal/storage/driver/etcd/client.go @@ -0,0 +1,22 @@ +package etcd + +import "github.com/coreos/etcd/clientv3" + +// Client wraps etcd client for testing purposes. +type Client interface { + clientv3.KV +} + +// NewClient produces new, configured etcd client. +func NewClient(cfg Config) (Client, error) { + + etcdCfg := clientv3.Config{ + Endpoints: cfg.Endpoints, + Username: cfg.Username, + Password: cfg.Password, + } + + cli, err := clientv3.New(etcdCfg) + + return cli, err +} diff --git a/components/helm-broker/internal/storage/driver/etcd/driver.go b/components/helm-broker/internal/storage/driver/etcd/driver.go new file mode 100644 index 000000000000..dd47e4cfed1d --- /dev/null +++ b/components/helm-broker/internal/storage/driver/etcd/driver.go @@ -0,0 +1,38 @@ +package etcd + +import "github.com/coreos/etcd/clientv3" + +// TODO list: +// - Use etcd lease for garbage collection of removed elements. +// Create lease on element delete and attach it to each object which should be deleted. + +const ( + entityNamespaceSeparator = "/" + + entityNamespaceBundle = "bundle" + entityNamespaceBundleMappingID = "id" + entityNamespaceBundleMappingNV = "nv" + + entityNamespaceChart = "chart" + entityNamespaceInstance = "instance" + entityNamespaceInstanceOperation = "instanceOperation" + entityNamespaceInstanceBindData = "instanceBindData" +) + +// Config holds configuration for etcd access in storage. +type Config struct { + Endpoints []string `json:"endpoints"` + Username string `json:"username"` + Password string `json:"password"` + + ForceClient *clientv3.Client +} + +func entityNamespacePrefixParts() []string { + return []string{"helm-broker", "entity"} +} + +// generic is a foundation for all drivers using etcd as storage. +type generic struct { + kv clientv3.KV +} diff --git a/components/helm-broker/internal/storage/driver/etcd/entity_bundle.go b/components/helm-broker/internal/storage/driver/etcd/entity_bundle.go new file mode 100644 index 000000000000..c19e7d9b68ae --- /dev/null +++ b/components/helm-broker/internal/storage/driver/etcd/entity_bundle.go @@ -0,0 +1,381 @@ +package etcd + +import ( + "bytes" + "context" + "encoding/gob" + "encoding/json" + "fmt" + "strings" + + "github.com/Masterminds/semver" + "github.com/coreos/etcd/clientv3" + "github.com/coreos/etcd/clientv3/namespace" + "github.com/pkg/errors" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +// NewBundle creates new storage for Bundles +func NewBundle(cli clientv3.KV) (*Bundle, error) { + + prefixParts := append(entityNamespacePrefixParts(), string(entityNamespaceBundle)) + kv := namespace.NewKV(cli, strings.Join(prefixParts, entityNamespaceSeparator)) + + d := &Bundle{ + generic: generic{ + kv: kv, + }, + } + + return d, nil +} + +// Bundle implements etcd storage for Bundle entities. +type Bundle struct { + generic +} + +// Upsert persists object in storage. +// +// If bundle already exists in storage than full replace is performed. +// +// True is returned if object already existed in storage and was replaced. +func (s *Bundle) Upsert(b *internal.Bundle) (bool, error) { + nv, err := s.nameVersionFromBundle(b) + if err != nil { + return false, err + } + + dso, err := s.encodeDMToDSO(b) + if err != nil { + return false, err + } + + // TODO: switch to transaction wrapping writes to both spaces + idSpaceResp, err := s.kv.Put(context.TODO(), s.idKey(b.ID), dso, clientv3.WithPrevKV()) + if err != nil { + return false, errors.Wrap(err, "while calling database in ID space") + } + + // Bundle is immutable so for simplicity we are duplicating write into Name/Version namespace + if _, err := s.kv.Put(context.TODO(), s.nameVersionKey(nv), dso, clientv3.WithPrevKV()); err != nil { + return false, errors.Wrap(err, "while calling database in NameVersion space") + } + + if idSpaceResp.PrevKv != nil { + return true, nil + } + + return false, nil +} + +// Get returns object from storage. +func (s *Bundle) Get(name internal.BundleName, ver semver.Version) (*internal.Bundle, error) { + nv, err := s.nameVersion(name, ver) + if err != nil { + return nil, err + } + + resp, err := s.kv.Get(context.TODO(), s.nameVersionKey(nv)) + if err != nil { + return nil, errors.Wrap(err, "while calling database") + } + + return s.handleGetResp(resp) +} + +// GetByID returns object by primary ID from storage. +func (s *Bundle) GetByID(id internal.BundleID) (*internal.Bundle, error) { + resp, err := s.kv.Get(context.TODO(), s.idKey(id)) + if err != nil { + return nil, errors.Wrap(err, "while calling database") + } + + return s.handleGetResp(resp) +} + +func (s *Bundle) handleGetResp(resp *clientv3.GetResponse) (*internal.Bundle, error) { + switch resp.Count { + case 1: + case 0: + return nil, notFoundError{} + default: + return nil, errors.New("more than one element matching requested id, should never happen") + } + + return s.decodeDSOToDM(resp.Kvs[0].Value) +} + +func (s *Bundle) encodeDMToDSO(dm *internal.Bundle) (string, error) { + buf := bytes.Buffer{} + dso, err := newBundleDSO(dm) + if err != nil { + return "", errors.Wrap(err, "while encoding Bundle to DSO") + } + enc := gob.NewEncoder(&buf) + if err := enc.Encode(dso); err != nil { + return "", errors.Wrap(err, "while encoding entity") + } + return buf.String(), nil +} + +func (*Bundle) decodeDSOToDM(dsoEnc []byte) (*internal.Bundle, error) { + dec := gob.NewDecoder(bytes.NewReader(dsoEnc)) + var dso bundleDSO + if err := dec.Decode(&dso); err != nil { + return nil, errors.Wrap(err, "while decoding DSO") + } + + b, err := dso.NewModel() + if err != nil { + return nil, errors.Wrap(err, "while creating model from DSO") + } + return b, nil +} + +// FindAll returns all objects from storage. +func (s *Bundle) FindAll() ([]*internal.Bundle, error) { + out := []*internal.Bundle{} + + resp, err := s.kv.Get(context.TODO(), entityNamespaceBundleMappingID, clientv3.WithPrefix()) + if err != nil { + return nil, errors.Wrap(err, "while calling database") + } + + for _, kv := range resp.Kvs { + b, err := s.decodeDSOToDM(kv.Value) + if err != nil { + return nil, errors.Wrap(err, "while decoding returned entities") + } + out = append(out, b) + } + + return out, nil +} + +// Remove removes object from storage. +func (s *Bundle) Remove(name internal.BundleName, ver semver.Version) error { + nv, err := s.nameVersion(name, ver) + if err != nil { + return errors.Wrap(err, "while getting nameVersion from deleted entity") + } + + resp, err := s.kv.Delete(context.TODO(), s.nameVersionKey(nv), clientv3.WithPrevKV()) + if err != nil { + return errors.Wrap(err, "while calling database on NV namespace") + } + + b, err := s.handleDeleteResp(resp) + if err != nil { + return err + } + + if _, err := s.kv.Delete(context.TODO(), s.idKey(b.ID)); err != nil { + return errors.Wrap(err, "while calling database on ID namespace") + } + + return nil +} + +// RemoveByID is removing object by primary ID from storage. +func (s *Bundle) RemoveByID(id internal.BundleID) error { + resp, err := s.kv.Delete(context.TODO(), s.idKey(id), clientv3.WithPrevKV()) + if err != nil { + return errors.Wrap(err, "while calling database on ID namespace") + } + + b, err := s.handleDeleteResp(resp) + if err != nil { + return err + } + + nv, err := s.nameVersionFromBundle(b) + if err != nil { + return errors.Wrap(err, "while getting nameVersion from deleted entity") + } + + if _, err := s.kv.Delete(context.TODO(), s.nameVersionKey(nv)); err != nil { + return errors.Wrap(err, "while calling database on NV namespace") + } + + return nil +} + +func (s *Bundle) handleDeleteResp(resp *clientv3.DeleteResponse) (*internal.Bundle, error) { + switch resp.Deleted { + case 1: + case 0: + return nil, notFoundError{} + default: + return nil, errors.New("more than one element matching requested id, should never happen") + } + + kv := resp.PrevKvs[0] + + b, err := s.decodeDSOToDM(kv.Value) + if err != nil { + return nil, err + } + + return b, nil +} + +type bundleNameVersion string + +func (s *Bundle) nameVersionFromBundle(b *internal.Bundle) (k bundleNameVersion, err error) { + if b == nil { + return k, errors.New("entity may not be nil") + } + + if b.Name == "" || b.Version.Original() == "" { + return k, errors.New("both name and version must be set") + } + + return s.nameVersion(b.Name, b.Version) +} + +func (*Bundle) nameVersion(name internal.BundleName, ver semver.Version) (k bundleNameVersion, err error) { + if name == "" || ver.Original() == "" { + return k, errors.New("both name and version must be set") + } + + return bundleNameVersion(fmt.Sprintf("%s|%s", name, ver.String())), nil +} + +func (*Bundle) idKey(id internal.BundleID) string { + return strings.Join([]string{entityNamespaceBundleMappingID, string(id)}, entityNamespaceSeparator) +} + +func (*Bundle) nameVersionKey(nv bundleNameVersion) string { + return strings.Join([]string{entityNamespaceBundleMappingNV, string(nv)}, entityNamespaceSeparator) +} + +func newBundleDSO(in *internal.Bundle) (*bundleDSO, error) { + dsoPlans := map[internal.BundlePlanID]bundlePlanDSO{} + for k, v := range in.Plans { + var err error + if dsoPlans[k], err = newBundlePlanDSO(v); err != nil { + return nil, errors.Wrap(err, "while converting Bundle to DSO") + } + } + return &bundleDSO{ + ID: in.ID, + Name: in.Name, + Version: in.Version.String(), + Description: in.Description, + Plans: dsoPlans, + Metadata: in.Metadata, + Tags: in.Tags, + Bindable: in.Bindable, + }, nil +} + +type bundleDSO struct { + ID internal.BundleID + Name internal.BundleName + Version string + Description string + Plans map[internal.BundlePlanID]bundlePlanDSO + Metadata internal.BundleMetadata + Tags []internal.BundleTag + Bindable bool +} + +func newBundlePlanDSO(plan internal.BundlePlan) (bundlePlanDSO, error) { + chartValuesDSO, err := newChartValuesDSO(plan.ChartValues) + if err != nil { + return bundlePlanDSO{}, errors.Wrap(err, "while converting BundlePlan to DSO") + } + + return bundlePlanDSO{ + Schemas: plan.Schemas, + Name: plan.Name, + ChartRef: plan.ChartRef, + Bindable: plan.Bindable, + ChartValues: chartValuesDSO, + ID: plan.ID, + Description: plan.Description, + Metadata: plan.Metadata, + BindTemplate: plan.BindTemplate, + }, nil +} + +type bundlePlanDSO struct { + ID internal.BundlePlanID + Name internal.BundlePlanName + Description string + Schemas map[internal.PlanSchemaType]internal.PlanSchema + ChartRef internal.ChartRef + ChartValues chartValuesDSO + Metadata internal.BundlePlanMetadata + Bindable *bool + BindTemplate internal.BundlePlanBindTemplate +} + +func (dso *bundlePlanDSO) ToModel() (internal.BundlePlan, error) { + chValues, err := dso.ChartValues.ToModel() + if err != nil { + return internal.BundlePlan{}, errors.Wrap(err, "while converting BundlePlanDSO to model") + } + return internal.BundlePlan{ + ID: dso.ID, + BindTemplate: dso.BindTemplate, + Metadata: dso.Metadata, + Description: dso.Description, + Bindable: dso.Bindable, + ChartRef: dso.ChartRef, + Name: dso.Name, + Schemas: dso.Schemas, + ChartValues: chValues, + }, nil +} + +func newChartValuesDSO(values internal.ChartValues) (chartValuesDSO, error) { + b, err := json.Marshal(values) + if err != nil { + return chartValuesDSO{}, errors.Wrap(err, "while converting ChartValues to DSO") + } + return chartValuesDSO(b), nil +} + +type chartValuesDSO json.RawMessage + +func (dso chartValuesDSO) ToModel() (internal.ChartValues, error) { + out := map[string]interface{}{} + if err := json.Unmarshal(dso, &out); err != nil { + return internal.ChartValues{}, errors.Wrap(err, "while converting ChartValuesDSO to model") + } + return out, nil +} + +func (dto *bundleDSO) NewModel() (*internal.Bundle, error) { + // TODO: do deep copy so that we are completely separated from PB entity + + plans := map[internal.BundlePlanID]internal.BundlePlan{} + for k, v := range dto.Plans { + var err error + if plans[k], err = v.ToModel(); err != nil { + return nil, errors.Wrap(err, "while converting BundleDSO to model") + } + } + + out := internal.Bundle{ + ID: dto.ID, + Name: dto.Name, + Description: dto.Description, + Plans: plans, + Metadata: dto.Metadata, + Tags: dto.Tags, + Bindable: dto.Bindable, + } + + ver, err := semver.NewVersion(dto.Version) + if err != nil { + return nil, errors.Wrap(err, "while decoding version") + } + + out.Version = *ver + + return &out, nil +} diff --git a/components/helm-broker/internal/storage/driver/etcd/entity_chart.go b/components/helm-broker/internal/storage/driver/etcd/entity_chart.go new file mode 100644 index 000000000000..18bff678ebc1 --- /dev/null +++ b/components/helm-broker/internal/storage/driver/etcd/entity_chart.go @@ -0,0 +1,150 @@ +package etcd + +import ( + "context" + "fmt" + "strings" + + "github.com/Masterminds/semver" + "github.com/coreos/etcd/clientv3" + "github.com/coreos/etcd/clientv3/namespace" + "github.com/golang/protobuf/proto" + "github.com/pkg/errors" + "k8s.io/helm/pkg/proto/hapi/chart" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +// NewChart creates new storage for Charts +func NewChart(cli clientv3.KV) (*Chart, error) { + + prefixParts := append(entityNamespacePrefixParts(), string(entityNamespaceChart)) + kv := namespace.NewKV(cli, strings.Join(prefixParts, entityNamespaceSeparator)) + + d := &Chart{ + generic: generic{ + kv: kv, + }, + } + + return d, nil +} + +// Chart provides storage operations on Chart entity +type Chart struct { + generic +} + +// Upsert persists Chart in memory. +// +// If chart already exists in storage than full replace is performed. +// +// Replace is set to true if chart already existed in storage and was replaced. +func (s *Chart) Upsert(c *chart.Chart) (replaced bool, err error) { + nv, err := s.nameVersionFromChart(c) + if err != nil { + return false, err + } + + data, err := proto.Marshal(c) + if err != nil { + return false, errors.Wrap(err, "while encoding DSO") + } + + resp, err := s.kv.Put(context.TODO(), s.key(nv), string(data), clientv3.WithPrevKV()) + if err != nil { + return false, errors.Wrap(err, "while calling database") + } + + if resp.PrevKv != nil { + return true, nil + } + + return false, nil +} + +// Get returns chart with given name and version from storage +func (s *Chart) Get(name internal.ChartName, ver semver.Version) (*chart.Chart, error) { + nv, err := s.nameVersion(name, ver) + if err != nil { + return nil, err + } + + resp, err := s.kv.Get(context.TODO(), s.key(nv)) + if err != nil { + return nil, errors.Wrap(err, "while calling database") + } + + switch resp.Count { + case 1: + case 0: + return nil, notFoundError{} + default: + return nil, errors.New("more than one element matching requested id, should never happen") + } + + var c chart.Chart + if err := proto.Unmarshal(resp.Kvs[0].Value, &c); err != nil { + return nil, errors.Wrap(err, "while decoding DSO") + } + + return &c, nil +} + +// Remove is removing chart with given name and version from storage +func (s *Chart) Remove(name internal.ChartName, ver semver.Version) error { + nv, err := s.nameVersion(name, ver) + if err != nil { + return errors.Wrap(err, "while getting nameVersion from deleted entity") + } + + resp, err := s.kv.Delete(context.TODO(), s.key(nv)) + if err != nil { + return errors.Wrap(err, "while calling database") + } + + switch resp.Deleted { + case 1: + case 0: + return notFoundError{} + default: + return errors.New("more than one element matching requested id, should never happen") + } + + return nil +} + +type chartNameVersion string + +func (s *Chart) nameVersionFromChart(c *chart.Chart) (k chartNameVersion, err error) { + if c == nil { + return k, errors.New("entity may not be nil") + } + + if c.Metadata == nil { + return k, errors.New("entity metadata may not be nil") + } + + if c.Metadata.Name == "" || c.Metadata.Version == "" { + return k, errors.New("both name and version must be set") + } + + ver, err := semver.NewVersion(c.Metadata.Version) + if err != nil { + return k, errors.Wrap(err, "while parsing version") + } + + return s.nameVersion(internal.ChartName(c.Metadata.Name), *ver) +} + +func (*Chart) nameVersion(name internal.ChartName, ver semver.Version) (k chartNameVersion, err error) { + if name == "" || ver.Original() == "" { + return k, errors.New("both name and version must be set") + } + + return chartNameVersion(fmt.Sprintf("%s|%s", name, ver.String())), nil +} + +func (*Chart) key(nv chartNameVersion) string { + return string(nv) +} diff --git a/components/helm-broker/internal/storage/driver/etcd/entity_instance.go b/components/helm-broker/internal/storage/driver/etcd/entity_instance.go new file mode 100644 index 000000000000..d6fa6dc63063 --- /dev/null +++ b/components/helm-broker/internal/storage/driver/etcd/entity_instance.go @@ -0,0 +1,111 @@ +package etcd + +import ( + "bytes" + "context" + "encoding/gob" + "strings" + + "github.com/coreos/etcd/clientv3" + "github.com/coreos/etcd/clientv3/namespace" + "github.com/pkg/errors" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +// NewInstance creates new Instances storage +func NewInstance(cli clientv3.KV) (*Instance, error) { + + prefixParts := append(entityNamespacePrefixParts(), string(entityNamespaceInstance)) + kv := namespace.NewKV(cli, strings.Join(prefixParts, entityNamespaceSeparator)) + + d := &Instance{ + generic: generic{ + kv: kv, + }, + } + + return d, nil +} + +// Instance implements etcd based storage for Instance entities. +type Instance struct { + generic +} + +// Insert inserts object to storage. +func (s *Instance) Insert(i *internal.Instance) error { + if i == nil { + return errors.New("entity may not be nil") + } + + if i.ID.IsZero() { + return errors.New("instance id must be set") + } + + buf := bytes.Buffer{} + enc := gob.NewEncoder(&buf) + if err := enc.Encode(i); err != nil { + return errors.Wrap(err, "while encoding entity") + } + + respGet, err := s.kv.Get(context.TODO(), s.key(i.ID)) + if err != nil { + return errors.Wrap(err, "while calling database on get") + } + if respGet.Count > 0 { + return alreadyExistsError{} + } + + if _, err := s.kv.Put(context.TODO(), s.key(i.ID), buf.String()); err != nil { + return errors.Wrap(err, "while calling database on put") + } + + return nil +} + +// Get returns object from storage. +func (s *Instance) Get(id internal.InstanceID) (*internal.Instance, error) { + resp, err := s.kv.Get(context.TODO(), s.key(id)) + if err != nil { + return nil, errors.Wrap(err, "while calling database") + } + + switch resp.Count { + case 1: + case 0: + return nil, notFoundError{} + default: + return nil, errors.New("more than one element matching requested id, should never happen") + } + + dec := gob.NewDecoder(bytes.NewReader(resp.Kvs[0].Value)) + var i internal.Instance + if err := dec.Decode(&i); err != nil { + return nil, errors.Wrap(err, "while decoding DSO") + } + + return &i, nil +} + +// Remove removing object from storage. +func (s *Instance) Remove(id internal.InstanceID) error { + resp, err := s.kv.Delete(context.TODO(), s.key(id)) + if err != nil { + return errors.Wrap(err, "while calling database") + } + + switch resp.Deleted { + case 1: + case 0: + return notFoundError{} + default: + return errors.New("more than one element matching requested id, should never happen") + } + + return nil +} + +func (*Instance) key(id internal.InstanceID) string { + return string(id) +} diff --git a/components/helm-broker/internal/storage/driver/etcd/entity_instance_bind_data.go b/components/helm-broker/internal/storage/driver/etcd/entity_instance_bind_data.go new file mode 100644 index 000000000000..6af3dede8168 --- /dev/null +++ b/components/helm-broker/internal/storage/driver/etcd/entity_instance_bind_data.go @@ -0,0 +1,131 @@ +package etcd + +import ( + "bytes" + "context" + "encoding/gob" + "fmt" + "strings" + + "github.com/coreos/etcd/clientv3" + "github.com/coreos/etcd/clientv3/namespace" + "github.com/pkg/errors" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +// NewInstanceBindData returns new instance of BindData storage. +func NewInstanceBindData(cli clientv3.KV) (*InstanceBindData, error) { + prefixParts := append(entityNamespacePrefixParts(), string(entityNamespaceInstanceBindData)) + kv := namespace.NewKV(cli, strings.Join(prefixParts, entityNamespaceSeparator)) + + d := &InstanceBindData{ + generic: generic{ + kv: kv, + }, + } + + return d, nil +} + +// InstanceBindData implements etcd based storage for BindData. +type InstanceBindData struct { + generic +} + +// Insert inserts object into storage. +func (s *InstanceBindData) Insert(ibd *internal.InstanceBindData) error { + if ibd == nil { + return errors.New("entity may not be nil") + } + + if ibd.InstanceID.IsZero() { + return errors.New("instance id must be set") + } + + opKey := s.key(ibd.InstanceID) + + respGet, err := s.kv.Get(context.TODO(), opKey) + if err != nil { + return errors.Wrap(err, "while calling database on get") + } + if respGet.Count > 0 { + return alreadyExistsError{} + } + + dso, err := s.encodeDMToDSO(ibd) + if err != nil { + return err + } + + if _, err := s.kv.Put(context.TODO(), opKey, dso); err != nil { + return errors.Wrap(err, "while calling database on put") + } + + return nil +} + +// Get returns object from storage. +func (s *InstanceBindData) Get(iID internal.InstanceID) (*internal.InstanceBindData, error) { + if iID.IsZero() { + return nil, errors.New("both instance and operation id must be set") + } + + resp, err := s.kv.Get(context.TODO(), s.key(iID)) + if err != nil { + return nil, errors.Wrap(err, "while calling database") + } + + switch resp.Count { + case 1: + case 0: + return nil, notFoundError{} + default: + return nil, errors.New("more than one element matching requested id, should never happen") + } + + return s.decodeDSOToDM(resp.Kvs[0].Value) +} + +// Remove removes object from storage. +func (s *InstanceBindData) Remove(iID internal.InstanceID) error { + resp, err := s.kv.Delete(context.TODO(), s.key(iID)) + if err != nil { + return errors.Wrap(err, "while calling database") + } + + switch resp.Deleted { + case 1: + case 0: + return notFoundError{} + default: + return fmt.Errorf("was removed more than one element matching requested id %q, should never happen", iID) + } + + return nil +} + +func (s *InstanceBindData) encodeDMToDSO(dm *internal.InstanceBindData) (string, error) { + buf := bytes.Buffer{} + enc := gob.NewEncoder(&buf) + if err := enc.Encode(dm); err != nil { + return "", errors.Wrap(err, "while encoding entity") + } + + return buf.String(), nil +} + +func (s *InstanceBindData) decodeDSOToDM(dsoEnc []byte) (*internal.InstanceBindData, error) { + dec := gob.NewDecoder(bytes.NewReader(dsoEnc)) + var ibd internal.InstanceBindData + if err := dec.Decode(&ibd); err != nil { + return nil, errors.Wrap(err, "while decoding DSO") + } + + return &ibd, nil +} + +// key returns key for the specific operation in a instance space +func (s *InstanceBindData) key(iID internal.InstanceID) string { + return string(iID) +} diff --git a/components/helm-broker/internal/storage/driver/etcd/entity_operation.go b/components/helm-broker/internal/storage/driver/etcd/entity_operation.go new file mode 100644 index 000000000000..025a3067dfd5 --- /dev/null +++ b/components/helm-broker/internal/storage/driver/etcd/entity_operation.go @@ -0,0 +1,242 @@ +package etcd + +import ( + "bytes" + "context" + "encoding/gob" + "strings" + "time" + + "github.com/coreos/etcd/clientv3" + "github.com/coreos/etcd/clientv3/namespace" + "github.com/pkg/errors" + + "github.com/kyma-project/kyma/components/helm-broker/platform/ptr" + yTime "github.com/kyma-project/kyma/components/helm-broker/platform/time" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +// NewInstanceOperation returns new instance of InstanceOperation storage. +func NewInstanceOperation(cli clientv3.KV) (*InstanceOperation, error) { + prefixParts := append(entityNamespacePrefixParts(), string(entityNamespaceInstanceOperation)) + kv := namespace.NewKV(cli, strings.Join(prefixParts, entityNamespaceSeparator)) + + d := &InstanceOperation{ + generic: generic{ + kv: kv, + }, + } + + return d, nil +} + +// InstanceOperation implements etcd based storage InstanceOperation. +type InstanceOperation struct { + generic + nowProvider yTime.NowProvider +} + +// WithTimeProvider allows for passing custom time provider. +// Used mostly in testing. +func (s *InstanceOperation) WithTimeProvider(nowProvider func() time.Time) *InstanceOperation { + s.nowProvider = nowProvider + return s +} + +// Insert inserts object into storage. +func (s *InstanceOperation) Insert(io *internal.InstanceOperation) error { + if io == nil { + return errors.New("entity may not be nil") + } + + if io.InstanceID.IsZero() || io.OperationID.IsZero() { + return errors.New("both instance and operation id must be set") + } + + opKey := s.key(io.InstanceID, io.OperationID) + + respGet, err := s.kv.Get(context.TODO(), opKey) + if err != nil { + return errors.Wrap(err, "while calling database on get") + } + if respGet.Count > 0 { + return alreadyExistsError{} + } + + opInProgress, err := s.isOperationInProgress(io.InstanceID) + if err != nil { + return errors.Wrap(err, "while checking if there are operations in progress") + } + if *opInProgress { + return activeOperationInProgressError{} + } + + io.CreatedAt = s.nowProvider.Now() + + dso, err := s.encodeDMToDSO(io) + if err != nil { + return err + } + + if _, err := s.kv.Put(context.TODO(), opKey, dso); err != nil { + return errors.Wrap(err, "while calling database on put") + } + + return nil +} + +func (s *InstanceOperation) isOperationInProgress(iID internal.InstanceID) (*bool, error) { + resp, err := s.kv.Get(context.TODO(), s.instanceKeyPrefix(iID), clientv3.WithPrefix()) + if err != nil { + return nil, s.handleGetError(err) + } + + for _, kv := range resp.Kvs { + io, err := s.decodeDSOToDM(kv.Value) + if err != nil { + return nil, errors.Wrap(err, "while decoding returned entities") + } + + if io.State == internal.OperationStateInProgress { + return ptr.Bool(true), nil + } + } + + return ptr.Bool(false), nil +} + +// Get returns object from storage. +func (s *InstanceOperation) Get(iID internal.InstanceID, opID internal.OperationID) (*internal.InstanceOperation, error) { + return s.get(iID, opID) +} + +func (s *InstanceOperation) get(iID internal.InstanceID, opID internal.OperationID) (*internal.InstanceOperation, error) { + if iID.IsZero() || opID.IsZero() { + return nil, errors.New("both instance and operation id must be set") + } + + resp, err := s.kv.Get(context.TODO(), s.key(iID, opID)) + if err != nil { + return nil, s.handleGetError(err) + } + + switch resp.Count { + case 1: + case 0: + return nil, notFoundError{} + default: + return nil, errors.New("more than one element matching requested id, should never happen") + } + + return s.decodeDSOToDM(resp.Kvs[0].Value) +} + +// GetAll returns all objects from storage. +func (s *InstanceOperation) GetAll(iID internal.InstanceID) ([]*internal.InstanceOperation, error) { + out := []*internal.InstanceOperation{} + + resp, err := s.kv.Get(context.TODO(), s.instanceKeyPrefix(iID), clientv3.WithPrefix()) + if err != nil { + return nil, s.handleGetError(err) + } + + if resp.Count == 0 { + return nil, notFoundError{} + } + + for _, kv := range resp.Kvs { + io, err := s.decodeDSOToDM(kv.Value) + if err != nil { + return nil, errors.Wrap(err, "while decoding returned entities") + } + out = append(out, io) + } + + return out, nil +} + +func (*InstanceOperation) handleGetError(errIn error) error { + return errors.Wrap(errIn, "while calling database") +} + +func (s *InstanceOperation) encodeDMToDSO(dm *internal.InstanceOperation) (string, error) { + buf := bytes.Buffer{} + enc := gob.NewEncoder(&buf) + if err := enc.Encode(dm); err != nil { + return "", errors.Wrap(err, "while encoding entity") + } + + return buf.String(), nil +} + +func (s *InstanceOperation) decodeDSOToDM(dsoEnc []byte) (*internal.InstanceOperation, error) { + dec := gob.NewDecoder(bytes.NewReader(dsoEnc)) + var io internal.InstanceOperation + if err := dec.Decode(&io); err != nil { + return nil, errors.Wrap(err, "while decoding DSO") + } + + return &io, nil +} + +// UpdateState modifies state on object in storage. +func (s *InstanceOperation) UpdateState(iID internal.InstanceID, opID internal.OperationID, state internal.OperationState) error { + return s.updateStateDesc(iID, opID, state, nil) +} + +// UpdateStateDesc modifies state and description on object in storage. +// If desc is nil than description will be removed. +func (s *InstanceOperation) UpdateStateDesc(iID internal.InstanceID, opID internal.OperationID, state internal.OperationState, desc *string) error { + return s.updateStateDesc(iID, opID, state, desc) +} + +func (s *InstanceOperation) updateStateDesc(iID internal.InstanceID, opID internal.OperationID, state internal.OperationState, desc *string) error { + io, err := s.get(iID, opID) + if err != nil { + return err + } + + io.State = state + io.StateDescription = desc + + dso, err := s.encodeDMToDSO(io) + if err != nil { + return err + } + + if _, err := s.kv.Put(context.TODO(), s.key(iID, opID), dso); err != nil { + return errors.Wrap(err, "while calling database on put") + } + + return nil +} + +// Remove removes object from storage. +func (s *InstanceOperation) Remove(iID internal.InstanceID, opID internal.OperationID) error { + resp, err := s.kv.Delete(context.TODO(), s.key(iID, opID)) + if err != nil { + return errors.Wrap(err, "while calling database") + } + + switch resp.Deleted { + case 1: + case 0: + return notFoundError{} + default: + return errors.New("more than one element matching requested id, should never happen") + } + + return nil +} + +// key returns key for the specific operation in a instance space +func (s *InstanceOperation) key(iID internal.InstanceID, opID internal.OperationID) string { + return s.instanceKeyPrefix(iID) + string(opID) +} + +// instanceKeyPrefix returns prefix for all operation keys in single instance namespace +// Trailing separator is appended. +func (*InstanceOperation) instanceKeyPrefix(id internal.InstanceID) string { + return string(id) + entityNamespaceSeparator +} diff --git a/components/helm-broker/internal/storage/driver/etcd/error.go b/components/helm-broker/internal/storage/driver/etcd/error.go new file mode 100644 index 000000000000..15d3428704d4 --- /dev/null +++ b/components/helm-broker/internal/storage/driver/etcd/error.go @@ -0,0 +1,18 @@ +package etcd + +type notFoundError struct{} + +func (notFoundError) Error() string { return "element not found" } +func (notFoundError) NotFound() bool { return true } + +type alreadyExistsError struct{} + +func (alreadyExistsError) Error() string { return "element already exists" } +func (alreadyExistsError) AlreadyExists() bool { return true } + +type activeOperationInProgressError struct{} + +func (activeOperationInProgressError) Error() string { + return "there is an active operation in progres for instance" +} +func (activeOperationInProgressError) ActiveOperationInProgress() bool { return true } diff --git a/components/helm-broker/internal/storage/driver/memory/driver.go b/components/helm-broker/internal/storage/driver/memory/driver.go new file mode 100644 index 000000000000..ca6dcb667c96 --- /dev/null +++ b/components/helm-broker/internal/storage/driver/memory/driver.go @@ -0,0 +1,6 @@ +package memory + +// Config provide config for storage +type Config struct { + MaxKeys int64 `json:"max-keys"` +} diff --git a/components/helm-broker/internal/storage/driver/memory/entity_bundle.go b/components/helm-broker/internal/storage/driver/memory/entity_bundle.go new file mode 100644 index 000000000000..4fa2730f5868 --- /dev/null +++ b/components/helm-broker/internal/storage/driver/memory/entity_bundle.go @@ -0,0 +1,164 @@ +package memory + +import ( + "fmt" + + "github.com/Masterminds/semver" + "github.com/pkg/errors" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +type bundleNameVersion string + +// NewBundle creates new storage for Bundles +func NewBundle() *Bundle { + return &Bundle{ + nameVerToID: make(map[bundleNameVersion]internal.BundleID), + storage: make(map[internal.BundleID]*internal.Bundle), + } +} + +// Bundle implements in-memory storage for Bundle entities. +type Bundle struct { + threadSafeStorage + nameVerToID map[bundleNameVersion]internal.BundleID + storage map[internal.BundleID]*internal.Bundle +} + +// Upsert persists object in storage. +// +// If bundle already exists in storage than full replace is performed. +// +// True is returned if object already existed in storage and was replaced. +func (s *Bundle) Upsert(b *internal.Bundle) (replaced bool, err error) { + defer unlock(s.lockW()) + + if b == nil { + return replaced, errors.New("entity may not be nil") + } + + nvk, err := s.keyFromBundle(b) + if err != nil { + return replaced, err + } + replaced = true + + if _, found := s.nameVerToID[nvk]; !found { + s.nameVerToID[nvk] = b.ID + replaced = false + } + + s.storage[b.ID] = b + return replaced, nil +} + +// Get returns object from storage. +func (s *Bundle) Get(name internal.BundleName, ver semver.Version) (*internal.Bundle, error) { + defer unlock(s.lockR()) + + id, err := s.id(name, ver) + if err != nil { + return nil, err + } + + b, found := s.storage[id] + // storage consistency failed - serious internal error + if !found { + // attempt to self-heal storage by removal of mapping + if nvk, err := s.key(name, ver); err == nil { + delete(s.nameVerToID, nvk) + } + return nil, notFoundError{} + } + + return b, nil +} + +// GetByID returns object by primary ID from storage. +func (s *Bundle) GetByID(id internal.BundleID) (*internal.Bundle, error) { + defer unlock(s.lockR()) + + b, found := s.storage[id] + // storage consistency failed - serious internal error + if !found { + return nil, notFoundError{} + } + return b, nil +} + +// FindAll returns all objects from storage. +func (s *Bundle) FindAll() ([]*internal.Bundle, error) { + defer unlock(s.lockR()) + + out := []*internal.Bundle{} + + for id := range s.storage { + out = append(out, s.storage[id]) + } + + return out, nil +} + +// Remove removes object from storage. +func (s *Bundle) Remove(name internal.BundleName, ver semver.Version) error { + defer unlock(s.lockW()) + + id, err := s.id(name, ver) + if err != nil { + return err + } + + return s.removeByID(id) +} + +// RemoveByID is removing object by primary ID from storage. +func (s *Bundle) RemoveByID(id internal.BundleID) error { + defer unlock(s.lockW()) + + return s.removeByID(id) +} + +func (s *Bundle) removeByID(id internal.BundleID) error { + if _, found := s.storage[id]; !found { + return notFoundError{} + } + + delete(s.storage, id) + + return nil +} + +func (s *Bundle) keyFromBundle(b *internal.Bundle) (k bundleNameVersion, err error) { + if b == nil { + return k, errors.New("entity may not be nil") + } + + if b.Name == "" || b.Version.Original() == "" { + return k, errors.New("both name and version must be set") + } + + return s.key(b.Name, b.Version) +} + +func (*Bundle) key(name internal.BundleName, ver semver.Version) (k bundleNameVersion, err error) { + if name == "" || ver.Original() == "" { + return k, errors.New("both name and version must be set") + } + + return bundleNameVersion(fmt.Sprintf("%s|%s", name, ver.String())), nil +} + +func (s *Bundle) id(name internal.BundleName, ver semver.Version) (id internal.BundleID, err error) { + nvk, err := s.key(name, ver) + if err != nil { + return id, err + } + + id, found := s.nameVerToID[nvk] + if !found { + return id, notFoundError{} + } + + return id, nil +} diff --git a/components/helm-broker/internal/storage/driver/memory/entity_chart.go b/components/helm-broker/internal/storage/driver/memory/entity_chart.go new file mode 100644 index 000000000000..4bbf4240e40d --- /dev/null +++ b/components/helm-broker/internal/storage/driver/memory/entity_chart.go @@ -0,0 +1,112 @@ +package memory + +import ( + "fmt" + + "github.com/Masterminds/semver" + "github.com/pkg/errors" + + "k8s.io/helm/pkg/proto/hapi/chart" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +type chartNameVersion string + +// NewChart creates new storage for Charts +func NewChart() *Chart { + return &Chart{ + storage: make(map[chartNameVersion]*chart.Chart), + } +} + +// Chart entity +type Chart struct { + threadSafeStorage + storage map[chartNameVersion]*chart.Chart +} + +// Upsert persists Chart in memory. +// +// If chart already exists in storage than full replace is performed. +// +// True is returned if chart already existed in storage and was replaced. +func (s *Chart) Upsert(c *chart.Chart) (replaced bool, err error) { + defer unlock(s.lockW()) + + if c == nil { + return replaced, errors.New("entity may not be nil") + } + + nvk, err := s.keyFromChart(c) + if err != nil { + return replaced, err + } + replaced = true + + if _, found := s.storage[nvk]; !found { + replaced = false + } + s.storage[nvk] = c + + return replaced, nil +} + +// Get returns from memory Chart with given name and version +func (s *Chart) Get(name internal.ChartName, ver semver.Version) (*chart.Chart, error) { + defer unlock(s.lockR()) + + nkv, err := s.key(name, ver) + if err != nil { + return nil, err + } + + c, found := s.storage[nkv] + if !found { + return nil, notFoundError{} + } + + return c, nil +} + +// Remove removes from memory Chart with given name and version +func (s *Chart) Remove(name internal.ChartName, ver semver.Version) error { + defer unlock(s.lockW()) + + nkv, err := s.key(name, ver) + if err != nil { + return err + } + + if _, found := s.storage[nkv]; !found { + return notFoundError{} + } + + delete(s.storage, nkv) + + return nil +} + +func (s *Chart) keyFromChart(c *chart.Chart) (k chartNameVersion, err error) { + if c == nil { + return k, errors.New("entity may not be nil") + } + + if c.Metadata == nil { + return k, errors.New("entity metadata may not be nil") + } + + if c.Metadata.Name == "" || c.Metadata.Version == "" { + return k, errors.New("both name and version must be set") + } + + return chartNameVersion(fmt.Sprintf("%s|%s", c.Metadata.Name, c.Metadata.Version)), nil +} + +func (*Chart) key(name internal.ChartName, ver semver.Version) (k chartNameVersion, err error) { + if name == "" || ver.Original() == "" { + return k, errors.New("both name and version must be set") + } + + return chartNameVersion(fmt.Sprintf("%s|%s", name, ver.String())), nil +} diff --git a/components/helm-broker/internal/storage/driver/memory/entity_instance.go b/components/helm-broker/internal/storage/driver/memory/entity_instance.go new file mode 100644 index 000000000000..116b5b7cfe78 --- /dev/null +++ b/components/helm-broker/internal/storage/driver/memory/entity_instance.go @@ -0,0 +1,67 @@ +package memory + +import ( + "github.com/pkg/errors" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +// NewInstance creates new Instances storage +func NewInstance() *Instance { + return &Instance{ + storage: make(map[internal.InstanceID]*internal.Instance), + } +} + +// Instance implements in-memory storage for Instance entities. +type Instance struct { + threadSafeStorage + storage map[internal.InstanceID]*internal.Instance +} + +// Insert inserts object to storage. +func (s *Instance) Insert(i *internal.Instance) error { + defer unlock(s.lockW()) + + if i == nil { + return errors.New("entity may not be nil") + } + + if i.ID.IsZero() { + return errors.New("instance id must be set") + } + + if _, found := s.storage[i.ID]; found { + return alreadyExistsError{} + } + + s.storage[i.ID] = i + + return nil +} + +// Get returns object from storage. +func (s *Instance) Get(id internal.InstanceID) (*internal.Instance, error) { + defer unlock(s.lockR()) + + i, found := s.storage[id] + if !found { + return nil, notFoundError{} + } + + return i, nil +} + +// Remove removing object from storage. +func (s *Instance) Remove(id internal.InstanceID) error { + defer unlock(s.lockW()) + + _, found := s.storage[id] + if !found { + return notFoundError{} + } + + delete(s.storage, id) + + return nil +} diff --git a/components/helm-broker/internal/storage/driver/memory/entity_instance_bind_data.go b/components/helm-broker/internal/storage/driver/memory/entity_instance_bind_data.go new file mode 100644 index 000000000000..b3b0171fe0cf --- /dev/null +++ b/components/helm-broker/internal/storage/driver/memory/entity_instance_bind_data.go @@ -0,0 +1,68 @@ +package memory + +import ( + "errors" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +// NewInstanceBindData returns new instance of BindData storage. +func NewInstanceBindData() *InstanceBindData { + return &InstanceBindData{ + storage: make(map[internal.InstanceID]*internal.InstanceBindData), + } +} + +// InstanceBindData implements in-memory based storage for BindData. +type InstanceBindData struct { + threadSafeStorage + storage map[internal.InstanceID]*internal.InstanceBindData +} + +// Insert inserts object into storage. +func (s *InstanceBindData) Insert(ibd *internal.InstanceBindData) error { + defer unlock(s.lockW()) + + if ibd == nil { + return errors.New("entity may not be nil") + } + + if ibd.InstanceID.IsZero() { + return errors.New("instance id must be set") + } + + if _, found := s.storage[ibd.InstanceID]; found { + return alreadyExistsError{} + } + + // TODO switch to deep copy? + cpy := *ibd + s.storage[ibd.InstanceID] = &cpy + + return nil +} + +// Get returns object from storage. +func (s *InstanceBindData) Get(iID internal.InstanceID) (*internal.InstanceBindData, error) { + defer unlock(s.lockR()) + + i, found := s.storage[iID] + if !found { + return nil, notFoundError{} + } + + return i, nil +} + +// Remove removes object from storage. +func (s *InstanceBindData) Remove(iID internal.InstanceID) error { + defer unlock(s.lockW()) + + if _, found := s.storage[iID]; !found { + return notFoundError{} + } + + delete(s.storage, iID) + + return nil +} diff --git a/components/helm-broker/internal/storage/driver/memory/entity_operation.go b/components/helm-broker/internal/storage/driver/memory/entity_operation.go new file mode 100644 index 000000000000..a7c5b0ac97be --- /dev/null +++ b/components/helm-broker/internal/storage/driver/memory/entity_operation.go @@ -0,0 +1,168 @@ +package memory + +import ( + "time" + + "github.com/pkg/errors" + + yTime "github.com/kyma-project/kyma/components/helm-broker/platform/time" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +// NewInstanceOperation returns new instance of InstanceOperation storage. +func NewInstanceOperation() *InstanceOperation { + return &InstanceOperation{ + storage: make(map[internal.InstanceID]map[internal.OperationID]*internal.InstanceOperation), + } +} + +// InstanceOperation implements in-memory storage InstanceOperation. +type InstanceOperation struct { + threadSafeStorage + storage map[internal.InstanceID]map[internal.OperationID]*internal.InstanceOperation + nowProvider yTime.NowProvider +} + +// WithTimeProvider allows for passing custom time provider. +// Used mostly in testing. +func (s *InstanceOperation) WithTimeProvider(nowProvider func() time.Time) *InstanceOperation { + s.nowProvider = nowProvider + return s +} + +// Insert inserts object into storage. +func (s *InstanceOperation) Insert(io *internal.InstanceOperation) error { + defer unlock(s.lockW()) + + if io == nil { + return errors.New("entity may not be nil") + } + + if io.InstanceID.IsZero() || io.OperationID.IsZero() { + return errors.New("both instance and operation id must be set") + } + + if _, found := s.storage[io.InstanceID]; !found { + s.storage[io.InstanceID] = make(map[internal.OperationID]*internal.InstanceOperation) + } + + if _, found := s.storage[io.InstanceID][io.OperationID]; found { + return alreadyExistsError{} + } + + for oID := range s.storage[io.InstanceID] { + if s.storage[io.InstanceID][oID].State == internal.OperationStateInProgress { + return activeOperationInProgressError{} + } + } + + io.CreatedAt = s.nowProvider.Now() + + s.storage[io.InstanceID][io.OperationID] = io + + return nil +} + +// Get returns object from storage. +func (s *InstanceOperation) Get(iID internal.InstanceID, opID internal.OperationID) (*internal.InstanceOperation, error) { + defer unlock(s.lockR()) + + return s.get(iID, opID) +} + +func (s *InstanceOperation) get(iID internal.InstanceID, opID internal.OperationID) (*internal.InstanceOperation, error) { + if iID.IsZero() || opID.IsZero() { + return nil, errors.New("both instance and operation id must be set") + } + + if _, found := s.storage[iID]; !found { + return nil, notFoundError{} + } + + io, found := s.storage[iID][opID] + if !found { + return nil, notFoundError{} + } + + return io, nil +} + +// GetAll returns all objects from storage. +func (s *InstanceOperation) GetAll(iID internal.InstanceID) ([]*internal.InstanceOperation, error) { + defer unlock(s.lockR()) + + out := []*internal.InstanceOperation{} + + opsForInstance, found := s.storage[iID] + if !found { + return nil, notFoundError{} + } + + for i := range opsForInstance { + out = append(out, opsForInstance[i]) + } + + return out, nil +} + +// UpdateState modifies state on object in storage. +func (s *InstanceOperation) UpdateState(iID internal.InstanceID, opID internal.OperationID, state internal.OperationState) error { + defer unlock(s.lockW()) + + op, err := s.get(iID, opID) + if err != nil { + return err + } + + op.State = state + op.StateDescription = nil + + //s.logStateChange(iID, opID, state, nil) + return nil +} + +// UpdateStateDesc updates both state and description for single operation. +// If desc is nil than description will be removed. +func (s *InstanceOperation) UpdateStateDesc(iID internal.InstanceID, opID internal.OperationID, state internal.OperationState, desc *string) error { + defer unlock(s.lockW()) + + op, err := s.get(iID, opID) + if err != nil { + return err + } + + op.State = state + op.StateDescription = desc + + //s.logStateChange(iID, opID, state, desc) + return nil +} + +// TODO: move to middleware on generic InstanceOperation storage +//func (s *InstanceOperation) logStateChange(iID internal.InstanceID, opID internal.OperationID, state internal.OperationState, desc *string) { +// if state == internal.OperationStateFailed { +// if desc != nil { +// s.log.Warnf("For instance [%s] operation [%s] ended with state: [%s]. Details: [%s]", iID, opID, state, *desc) +// +// } else { +// s.log.Warnf("For instance [%s] operation [%s] ended with state: [%s]", iID, opID, state) +// } +// } +//} + +// Remove removes object from storage. +func (s *InstanceOperation) Remove(iID internal.InstanceID, opID internal.OperationID) error { + defer unlock(s.lockW()) + + if _, err := s.get(iID, opID); err != nil { + return err + } + + delete(s.storage[iID], opID) + if len(s.storage[iID]) == 0 { + delete(s.storage, iID) + } + + return nil +} diff --git a/components/helm-broker/internal/storage/driver/memory/error.go b/components/helm-broker/internal/storage/driver/memory/error.go new file mode 100644 index 000000000000..c3cca58db468 --- /dev/null +++ b/components/helm-broker/internal/storage/driver/memory/error.go @@ -0,0 +1,18 @@ +package memory + +type notFoundError struct{} + +func (notFoundError) Error() string { return "element not found" } +func (notFoundError) NotFound() bool { return true } + +type alreadyExistsError struct{} + +func (alreadyExistsError) Error() string { return "element already exists" } +func (alreadyExistsError) AlreadyExists() bool { return true } + +type activeOperationInProgressError struct{} + +func (activeOperationInProgressError) Error() string { + return "there is an active operation in progres for instance" +} +func (activeOperationInProgressError) ActiveOperationInProgress() bool { return true } diff --git a/components/helm-broker/internal/storage/driver/memory/sync.go b/components/helm-broker/internal/storage/driver/memory/sync.go new file mode 100644 index 000000000000..a8b4b3ec90f2 --- /dev/null +++ b/components/helm-broker/internal/storage/driver/memory/sync.go @@ -0,0 +1,29 @@ +/* +Code in this file is based on code from Kubernetes Helm. +Original code was licensed under the Apache License, Version 2.0 with copyright assigned to "2016 The Kubernetes Authors All rights reserved". +*/ + +package memory + +import "sync" + +type threadSafeStorage struct { + sync.RWMutex +} + +// lockW locks storage for writing +func (s *threadSafeStorage) lockW() func() { + s.Lock() + return func() { s.Unlock() } +} + +// lockR locks storage for reading +func (s *threadSafeStorage) lockR() func() { + s.RLock() + return func() { s.RUnlock() } +} + +// unlock calls fn which reverses a lockR or lockW. e.g: +// ```defer unlock(s.lockR())```, locks mem for reading at the +// call point of defer and unlocks upon exiting the block. +func unlock(fn func()) { fn() } diff --git a/components/helm-broker/internal/storage/error.go b/components/helm-broker/internal/storage/error.go new file mode 100644 index 000000000000..510f7fdebc3d --- /dev/null +++ b/components/helm-broker/internal/storage/error.go @@ -0,0 +1,18 @@ +package storage + +type notFoundError struct{} + +func (notFoundError) Error() string { return "element not found" } +func (notFoundError) NotFound() bool { return true } + +type alreadyExistsError struct{} + +func (alreadyExistsError) Error() string { return "element already exists" } +func (alreadyExistsError) AlreadyExists() bool { return true } + +type activeOperationInProgressError struct{} + +func (activeOperationInProgressError) Error() string { + return "there is an active operation in progres for instance" +} +func (activeOperationInProgressError) ActiveOperationInProgress() bool { return true } diff --git a/components/helm-broker/internal/storage/ext.go b/components/helm-broker/internal/storage/ext.go new file mode 100644 index 000000000000..13aa23e4c2f9 --- /dev/null +++ b/components/helm-broker/internal/storage/ext.go @@ -0,0 +1,76 @@ +package storage + +import ( + "github.com/Masterminds/semver" + + "k8s.io/helm/pkg/proto/hapi/chart" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +// Bundle is an interface that describe storage layer operations for Bundles +type Bundle interface { + Upsert(*internal.Bundle) (replace bool, err error) + Get(internal.BundleName, semver.Version) (*internal.Bundle, error) + GetByID(internal.BundleID) (*internal.Bundle, error) + Remove(internal.BundleName, semver.Version) error + RemoveByID(internal.BundleID) error + FindAll() ([]*internal.Bundle, error) +} + +// Chart is an interface that describe storage layer operations for Charts +type Chart interface { + Upsert(*chart.Chart) (replace bool, err error) + Get(name internal.ChartName, ver semver.Version) (*chart.Chart, error) + Remove(name internal.ChartName, version semver.Version) error +} + +// Instance is an interface that describe storage layer operations for Instances +type Instance interface { + Insert(*internal.Instance) error + Get(id internal.InstanceID) (*internal.Instance, error) + Remove(id internal.InstanceID) error +} + +// InstanceOperation is an interface that describe storage layer operations for InstanceOperations +type InstanceOperation interface { + // Insert is inserting object into storage. + // Object is modified by setting CreatedAt. + Insert(*internal.InstanceOperation) error + Get(internal.InstanceID, internal.OperationID) (*internal.InstanceOperation, error) + GetAll(internal.InstanceID) ([]*internal.InstanceOperation, error) + UpdateState(internal.InstanceID, internal.OperationID, internal.OperationState) error + UpdateStateDesc(internal.InstanceID, internal.OperationID, internal.OperationState, *string) error + Remove(internal.InstanceID, internal.OperationID) error +} + +// InstanceBindData is an interface that describe storage layer operations for InstanceBindData entities +type InstanceBindData interface { + Insert(*internal.InstanceBindData) error + Get(internal.InstanceID) (*internal.InstanceBindData, error) + Remove(internal.InstanceID) error +} + +// IsNotFoundError checks if given error is NotFound error +func IsNotFoundError(err error) bool { + nfe, ok := err.(interface { + NotFound() bool + }) + return ok && nfe.NotFound() +} + +// IsAlreadyExistsError checks if given error is AlreadyExist error +func IsAlreadyExistsError(err error) bool { + aee, ok := err.(interface { + AlreadyExists() bool + }) + return ok && aee.AlreadyExists() +} + +// IsActiveOperationInProgressError checks if given error is ActiveOperationInProgress error +func IsActiveOperationInProgressError(err error) bool { + aee, ok := err.(interface { + ActiveOperationInProgress() bool + }) + return ok && aee.ActiveOperationInProgress() +} diff --git a/components/helm-broker/internal/storage/factory_test.go b/components/helm-broker/internal/storage/factory_test.go new file mode 100644 index 000000000000..734daca1f254 --- /dev/null +++ b/components/helm-broker/internal/storage/factory_test.go @@ -0,0 +1,47 @@ +package storage_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/kyma-project/kyma/components/helm-broker/internal/storage" + "github.com/kyma-project/kyma/components/helm-broker/internal/storage/driver/etcd" + "github.com/kyma-project/kyma/components/helm-broker/internal/storage/driver/memory" + "github.com/kyma-project/kyma/components/helm-broker/internal/storage/testdata" +) + +func TestNewFactory(t *testing.T) { + for s, tc := range map[string]struct { + cfgGen func() storage.ConfigList + expBundle interface{} + expChart interface{} + expInstance interface{} + expInstanceOperation interface{} + }{ + "MemorySingleAll": {testdata.GoldenConfigMemorySingleAll, &memory.Bundle{}, &memory.Chart{}, &memory.Instance{}, &memory.InstanceOperation{}}, + "MemorySingleSeparate": {testdata.GoldenConfigMemorySingleSeparate, &memory.Bundle{}, &memory.Chart{}, &memory.Instance{}, &memory.InstanceOperation{}}, + "MemoryMultipleSeparate": {testdata.GoldenConfigMemoryMultipleSeparate, &memory.Bundle{}, &memory.Chart{}, &memory.Instance{}, &memory.InstanceOperation{}}, + "EtcdSingleAll": {testdata.GoldenConfigEtcdSingleAll, &etcd.Bundle{}, &etcd.Chart{}, &etcd.Instance{}, &etcd.InstanceOperation{}}, + "EtcdSingleSeparate": {testdata.GoldenConfigEtcdSingleSeparate, &etcd.Bundle{}, &etcd.Chart{}, &etcd.Instance{}, &etcd.InstanceOperation{}}, + "EtcdMultipleSeparate": {testdata.GoldenConfigEtcdMultipleSeparate, &etcd.Bundle{}, &etcd.Chart{}, &etcd.Instance{}, &etcd.InstanceOperation{}}, + "MixEMMESeparate": {testdata.GoldenConfigMixEMMESeparate, &etcd.Bundle{}, &memory.Chart{}, &memory.Instance{}, &etcd.InstanceOperation{}}, + "MixMMEEGrouped": {testdata.GoldenConfigMixMMEEGrouped, &memory.Bundle{}, &memory.Chart{}, &etcd.Instance{}, &etcd.InstanceOperation{}}, + } { + t.Run(s, func(t *testing.T) { + // GIVEN: + cfg := tc.cfgGen() + + // WHEN: + got, err := storage.NewFactory(&cfg) + + // THEN: + assert.NoError(t, err) + + assert.IsType(t, tc.expBundle, got.Bundle()) + assert.IsType(t, tc.expChart, got.Chart()) + assert.IsType(t, tc.expInstance, got.Instance()) + assert.IsType(t, tc.expInstanceOperation, got.InstanceOperation()) + }) + } +} diff --git a/components/helm-broker/internal/storage/storage.go b/components/helm-broker/internal/storage/storage.go new file mode 100644 index 000000000000..396d9c568c0a --- /dev/null +++ b/components/helm-broker/internal/storage/storage.go @@ -0,0 +1,189 @@ +package storage + +import ( + "github.com/pkg/errors" + "gopkg.in/yaml.v2" + + "github.com/kyma-project/kyma/components/helm-broker/internal/storage/driver/etcd" + "github.com/kyma-project/kyma/components/helm-broker/internal/storage/driver/memory" +) + +// Factory provides access to concrete storage. +// Multiple calls should to specific storage return the same storage instance. +type Factory interface { + Bundle() Bundle + Chart() Chart + Instance() Instance + InstanceOperation() InstanceOperation + InstanceBindData() InstanceBindData +} + +// DriverType defines type of data storage +type DriverType string + +const ( + // DriverEtcd is a driver for key-value store - Etcd + DriverEtcd DriverType = "etcd" + // DriverMemory is a driver to local in-memory store + DriverMemory DriverType = "memory" +) + +// EntityName defines name of the entity in database +type EntityName string + +const ( + // EntityAll represents name of all entities + EntityAll EntityName = "all" + // EntityChart represents name of chart entities + EntityChart EntityName = "chart" + // EntityBundle represents name of bundle entities + EntityBundle EntityName = "bundle" + // EntityInstance represents name of services instances entities + EntityInstance EntityName = "instance" + // EntityInstanceOperation represents name of instances operations entities + EntityInstanceOperation EntityName = "instanceOperation" + // EntityInstanceBindData represents name of bind data entities + EntityInstanceBindData EntityName = "entityInstanceBindData" +) + +// ProviderConfig provides configuration to the database provider +type ProviderConfig struct{} + +// ProviderConfigMap contains map of provided configurations for given entities +type ProviderConfigMap map[EntityName]ProviderConfig + +// Config contains database configuration. +type Config struct { + Driver DriverType `json:"driver" valid:"required"` + Provide ProviderConfigMap `json:"provide" valid:"required"` + Etcd etcd.Config `json:"etcd"` + Memory memory.Config `json:"memory"` +} + +// ConfigList is a list of configurations +type ConfigList []Config + +// ConfigParse is parsing yaml file to the ConfigList +func ConfigParse(inByte []byte) (*ConfigList, error) { + var cl ConfigList + + if err := yaml.Unmarshal(inByte, &cl); err != nil { + return nil, errors.Wrap(err, "while unmarshalling yaml") + } + + return &cl, nil +} + +// NewConfigListAllMemory returns configured configList with the memory driver for all entities. +func NewConfigListAllMemory() *ConfigList { + return &ConfigList{{Driver: DriverMemory, Provide: ProviderConfigMap{EntityAll: ProviderConfig{}}}} +} + +// NewFactory is a factory for entities based on given ConfigList +// TODO: add error handling +func NewFactory(cl *ConfigList) (Factory, error) { + fact := concreteFactory{} + + for _, cfg := range *cl { + + var ( + bundleFact func() (Bundle, error) + chartFact func() (Chart, error) + instanceFact func() (Instance, error) + instanceOperationFact func() (InstanceOperation, error) + instanceBindDataFact func() (InstanceBindData, error) + ) + + switch cfg.Driver { + case DriverMemory: + bundleFact = func() (Bundle, error) { + return memory.NewBundle(), nil + } + chartFact = func() (Chart, error) { + return memory.NewChart(), nil + } + instanceFact = func() (Instance, error) { + return memory.NewInstance(), nil + } + instanceOperationFact = func() (InstanceOperation, error) { + return memory.NewInstanceOperation(), nil + } + instanceBindDataFact = func() (InstanceBindData, error) { + return memory.NewInstanceBindData(), nil + } + case DriverEtcd: + var cli etcd.Client + if cfg.Etcd.ForceClient != nil { + cli = cfg.Etcd.ForceClient + } else { + cli, _ = etcd.NewClient(cfg.Etcd) + } + + bundleFact = func() (Bundle, error) { + return etcd.NewBundle(cli) + } + chartFact = func() (Chart, error) { + return etcd.NewChart(cli) + } + instanceFact = func() (Instance, error) { + return etcd.NewInstance(cli) + } + instanceOperationFact = func() (InstanceOperation, error) { + return etcd.NewInstanceOperation(cli) + } + instanceBindDataFact = func() (InstanceBindData, error) { + return etcd.NewInstanceBindData(cli) + } + default: + return nil, errors.New("unknown driver type") + } + + for em := range cfg.Provide { + switch em { + case EntityChart: + fact.chart, _ = chartFact() + case EntityBundle: + fact.bundle, _ = bundleFact() + case EntityInstance: + fact.instance, _ = instanceFact() + case EntityInstanceOperation: + fact.instanceOperation, _ = instanceOperationFact() + case EntityInstanceBindData: + fact.instanceBindData, _ = instanceBindDataFact() + case EntityAll: + fact.chart, _ = chartFact() + fact.bundle, _ = bundleFact() + fact.instance, _ = instanceFact() + fact.instanceOperation, _ = instanceOperationFact() + fact.instanceBindData, _ = instanceBindDataFact() + default: + } + } + } + + return &fact, nil +} + +type concreteFactory struct { + bundle Bundle + chart Chart + instance Instance + instanceOperation InstanceOperation + instanceBindData InstanceBindData +} + +func (f *concreteFactory) Bundle() Bundle { + return f.bundle +} +func (f *concreteFactory) Chart() Chart { + return f.chart +} +func (f *concreteFactory) Instance() Instance { + return f.instance +} +func (f *concreteFactory) InstanceOperation() InstanceOperation { + return f.instanceOperation +} +func (f *concreteFactory) InstanceBindData() InstanceBindData { + return f.instanceBindData +} diff --git a/components/helm-broker/internal/storage/testdata/Config.golden.go b/components/helm-broker/internal/storage/testdata/Config.golden.go new file mode 100644 index 000000000000..315fe3f7e444 --- /dev/null +++ b/components/helm-broker/internal/storage/testdata/Config.golden.go @@ -0,0 +1,93 @@ +package testdata + +import "github.com/kyma-project/kyma/components/helm-broker/internal/storage" + +func GoldenConfigMemorySingleAll() storage.ConfigList { + return storage.ConfigList{ + { + Driver: storage.DriverMemory, + Provide: storage.ProviderConfigMap{ + storage.EntityAll: storage.ProviderConfig{}, + }, + }, + } +} + +func GoldenConfigMemorySingleSeparate() storage.ConfigList { + return storage.ConfigList{ + { + Driver: storage.DriverMemory, + Provide: storage.ProviderConfigMap{ + storage.EntityBundle: storage.ProviderConfig{}, + storage.EntityChart: storage.ProviderConfig{}, + storage.EntityInstance: storage.ProviderConfig{}, + storage.EntityInstanceOperation: storage.ProviderConfig{}, + }, + }, + } +} + +func GoldenConfigMemoryMultipleSeparate() storage.ConfigList { + return storage.ConfigList{ + {Driver: storage.DriverMemory, Provide: storage.ProviderConfigMap{storage.EntityBundle: storage.ProviderConfig{}}}, + {Driver: storage.DriverMemory, Provide: storage.ProviderConfigMap{storage.EntityChart: storage.ProviderConfig{}}}, + {Driver: storage.DriverMemory, Provide: storage.ProviderConfigMap{storage.EntityInstance: storage.ProviderConfig{}}}, + {Driver: storage.DriverMemory, Provide: storage.ProviderConfigMap{storage.EntityInstanceOperation: storage.ProviderConfig{}}}, + } +} + +func GoldenConfigEtcdSingleAll() storage.ConfigList { + return storage.ConfigList{ + { + Driver: storage.DriverEtcd, + Provide: storage.ProviderConfigMap{ + storage.EntityAll: storage.ProviderConfig{}, + }, + }, + } +} + +func GoldenConfigEtcdSingleSeparate() storage.ConfigList { + return storage.ConfigList{ + { + Driver: storage.DriverEtcd, + Provide: storage.ProviderConfigMap{ + storage.EntityBundle: storage.ProviderConfig{}, + storage.EntityChart: storage.ProviderConfig{}, + storage.EntityInstance: storage.ProviderConfig{}, + storage.EntityInstanceOperation: storage.ProviderConfig{}, + }, + }, + } +} + +func GoldenConfigEtcdMultipleSeparate() storage.ConfigList { + return storage.ConfigList{ + {Driver: storage.DriverEtcd, Provide: storage.ProviderConfigMap{storage.EntityBundle: storage.ProviderConfig{}}}, + {Driver: storage.DriverEtcd, Provide: storage.ProviderConfigMap{storage.EntityChart: storage.ProviderConfig{}}}, + {Driver: storage.DriverEtcd, Provide: storage.ProviderConfigMap{storage.EntityInstance: storage.ProviderConfig{}}}, + {Driver: storage.DriverEtcd, Provide: storage.ProviderConfigMap{storage.EntityInstanceOperation: storage.ProviderConfig{}}}, + } +} + +func GoldenConfigMixEMMESeparate() storage.ConfigList { + return storage.ConfigList{ + {Driver: storage.DriverEtcd, Provide: storage.ProviderConfigMap{storage.EntityBundle: storage.ProviderConfig{}}}, + {Driver: storage.DriverMemory, Provide: storage.ProviderConfigMap{storage.EntityChart: storage.ProviderConfig{}}}, + {Driver: storage.DriverMemory, Provide: storage.ProviderConfigMap{storage.EntityInstance: storage.ProviderConfig{}}}, + {Driver: storage.DriverEtcd, Provide: storage.ProviderConfigMap{storage.EntityInstanceOperation: storage.ProviderConfig{}}}, + } +} + +func GoldenConfigMixMMEEGrouped() storage.ConfigList { + return storage.ConfigList{ + {Driver: storage.DriverMemory, Provide: storage.ProviderConfigMap{ + storage.EntityBundle: storage.ProviderConfig{}, + storage.EntityChart: storage.ProviderConfig{}, + }}, + {Driver: storage.DriverEtcd, Provide: storage.ProviderConfigMap{ + storage.EntityInstance: storage.ProviderConfig{}, + storage.EntityInstanceOperation: storage.ProviderConfig{}, + }}, + } +} diff --git a/components/helm-broker/internal/storage/testdata/ConfigAllMemory.input.yaml b/components/helm-broker/internal/storage/testdata/ConfigAllMemory.input.yaml new file mode 100644 index 000000000000..ac0ad4ef6d23 --- /dev/null +++ b/components/helm-broker/internal/storage/testdata/ConfigAllMemory.input.yaml @@ -0,0 +1,3 @@ +- driver: memory + provide: + all: ~ diff --git a/components/helm-broker/internal/storage/testdata/ex.input.yaml b/components/helm-broker/internal/storage/testdata/ex.input.yaml new file mode 100644 index 000000000000..6641b4af75e6 --- /dev/null +++ b/components/helm-broker/internal/storage/testdata/ex.input.yaml @@ -0,0 +1,17 @@ +storage: + - driver: memory + provide: + bundle: ~ + chart: ~ + memory: + max-keys: 4096 + - driver: etcd + provide: + instance: + prefix: instance + instanceOperation: + prefix: instanceOperation + etcd: + endpoints: + - hostA + - hostB diff --git a/components/helm-broker/internal/storage/testing/bundle_test.go b/components/helm-broker/internal/storage/testing/bundle_test.go new file mode 100644 index 000000000000..18be5257d7af --- /dev/null +++ b/components/helm-broker/internal/storage/testing/bundle_test.go @@ -0,0 +1,330 @@ +package testing + +import ( + "fmt" + "testing" + + "github.com/Masterminds/semver" + "github.com/stretchr/testify/assert" + + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/kyma-project/kyma/components/helm-broker/internal/storage" +) + +func TestBundleGet(t *testing.T) { + tRunDrivers(t, "Found", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newBundleTestSuite(t, sf) + ts.PopulateStorage() + exp := ts.MustGetFixture("A1") + + // WHEN: + got, err := ts.s.Get(exp.Name, exp.Version) + + // THEN: + assert.NoError(t, err) + ts.AssertBundleEqual(exp, got) + }) + + tRunDrivers(t, "NotFound", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newBundleTestSuite(t, sf) + exp := ts.MustGetFixture("A1") + + // WHEN: + got, err := ts.s.Get(exp.Name, exp.Version) + + // THEN: + ts.AssertNotFoundError(err) + assert.Nil(t, got) + }) +} + +func TestBundleGetByID(t *testing.T) { + tRunDrivers(t, "Found", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newBundleTestSuite(t, sf) + ts.PopulateStorage() + exp := ts.MustGetFixture("A1") + + // WHEN: + got, err := ts.s.GetByID(exp.ID) + + // THEN: + assert.NoError(t, err) + ts.AssertBundleEqual(exp, got) + }) + + tRunDrivers(t, "NotFound", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newBundleTestSuite(t, sf) + exp := ts.MustGetFixture("A1") + + // WHEN: + got, err := ts.s.GetByID(exp.ID) + + // THEN: + ts.AssertNotFoundError(err) + assert.Nil(t, got) + }) +} + +func TestBundleUpsert(t *testing.T) { + tRunDrivers(t, "Success/New", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newBundleTestSuite(t, sf) + fix := ts.MustGetFixture("A1") + + // WHEN: + replace, err := ts.s.Upsert(fix) + + // THEN: + assert.NoError(t, err) + assert.False(t, replace) + }) + + tRunDrivers(t, "Success/Replace", func(t *testing.T, sf storage.Factory) { + // GIVEN: + expDesc := "updated description" + ts := newBundleTestSuite(t, sf) + fix := ts.MustGetFixture("A1") + ts.s.Upsert(fix) + + // WHEN: + fixNew := ts.MustCopyFixture(fix) + fixNew.Description = expDesc + replace, err := ts.s.Upsert(fixNew) + + // THEN: + assert.NoError(t, err) + assert.True(t, replace) + + got, err := ts.s.GetByID(fixNew.ID) + assert.NoError(t, err) + ts.AssertBundleEqual(fixNew, got) + }) + + tRunDrivers(t, "Failure/EmptyVersion", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newBundleTestSuite(t, sf) + fix := ts.MustGetFixture("A1") + fix.Version = semver.Version{} + + // WHEN: + _, err := ts.s.Upsert(fix) + + // THEN: + assert.EqualError(t, err, "both name and version must be set") + }) +} + +func TestBundleRemove(t *testing.T) { + tRunDrivers(t, "Found", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newBundleTestSuite(t, sf) + ts.PopulateStorage() + exp := ts.MustGetFixture("A1") + + // WHEN: + err := ts.s.Remove(exp.Name, exp.Version) + + // THEN: + assert.NoError(t, err) + ts.AssertBundleDoesNotExist(exp) + }) + + tRunDrivers(t, "NotFound", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newBundleTestSuite(t, sf) + exp := ts.MustGetFixture("A1") + + // WHEN: + err := ts.s.Remove(exp.Name, exp.Version) + + // THEN: + ts.AssertNotFoundError(err) + }) +} + +func TestBundleRemoveByID(t *testing.T) { + tRunDrivers(t, "Found", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newBundleTestSuite(t, sf) + ts.PopulateStorage() + exp := ts.MustGetFixture("A1") + + // WHEN: + err := ts.s.RemoveByID(exp.ID) + + // THEN: + assert.NoError(t, err) + ts.AssertBundleDoesNotExist(exp) + }) + + tRunDrivers(t, "NotFound", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newBundleTestSuite(t, sf) + exp := ts.MustGetFixture("A1") + + // WHEN: + err := ts.s.RemoveByID(exp.ID) + + // THEN: + ts.AssertNotFoundError(err) + }) +} + +func TestBundleFindAll(t *testing.T) { + + tRunDrivers(t, "NonEmpty", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newBundleTestSuite(t, sf) + ts.PopulateStorage() + + // WHEN: + got, err := ts.s.FindAll() + + // THEN: + assert.NoError(t, err) + ts.AssertBundlesReturned(got) + }) + + tRunDrivers(t, "Empty", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newBundleTestSuite(t, sf) + + // WHEN: + got, err := ts.s.FindAll() + + // THEN: + assert.Empty(t, got) + assert.NoError(t, err) + }) +} + +func newBundleTestSuite(t *testing.T, sf storage.Factory) *bundleTestSuite { + ts := bundleTestSuite{ + t: t, + s: sf.Bundle(), + fixtures: make(map[internal.BundleID]*internal.Bundle), + fixturesSymToIDMap: make(map[string]internal.BundleID), + } + + ts.generateFixtures() + + return &ts +} + +type bundleTestSuite struct { + t *testing.T + s storage.Bundle + fixtures map[internal.BundleID]*internal.Bundle + fixturesSymToIDMap map[string]internal.BundleID +} + +func (ts *bundleTestSuite) generateFixtures() { + for fs, ft := range map[string]struct{ id, name, version, desc string }{ + "A1": {"id-A-001", "name-A", "0.0.1", "desc-A-001"}, + "A2": {"id-A-002", "name-A", "0.0.2", "desc-A-002"}, + "B1": {"id-B-001", "name-B", "0.0.1", "desc-B-001"}, + "B2": {"id-B-002", "name-B", "0.0.2", "desc-B-002"}, + } { + b := &internal.Bundle{ + ID: internal.BundleID(ft.id), + Name: internal.BundleName(ft.name), + Version: *semver.MustParse(ft.version), + Description: ft.desc, + } + + ts.fixtures[b.ID] = b + ts.fixturesSymToIDMap[fs] = b.ID + } +} + +func (ts *bundleTestSuite) PopulateStorage() { + for _, b := range ts.fixtures { + ts.s.Upsert(ts.MustCopyFixture(b)) + } +} + +func (ts *bundleTestSuite) MustGetFixture(sym string) *internal.Bundle { + id, found := ts.fixturesSymToIDMap[sym] + if !found { + panic(fmt.Sprintf("fixture symbol not found, sym: %s", sym)) + } + + b, found := ts.fixtures[id] + if !found { + panic(fmt.Sprintf("fixture not found, sym: %s, id: %s", sym, id)) + } + + return b +} + +// CopyFixture is copying fixture +// BEWARE: not all fields are copied, only those currently used in this test suite scope +func (ts *bundleTestSuite) MustCopyFixture(in *internal.Bundle) *internal.Bundle { + return &internal.Bundle{ + ID: in.ID, + Name: in.Name, + Version: *semver.MustParse(in.Version.String()), + Description: in.Description, + } +} + +// AssertBundleEqual performs partial match for bundle. +// It's suitable only for tests as match is PARTIAL. +func (ts *bundleTestSuite) AssertBundleEqual(exp, got *internal.Bundle) bool { + ts.t.Helper() + + result := assert.Equal(ts.t, exp.ID, got.ID, "mismatch on ID") + result = assert.Equal(ts.t, exp.Name, got.Name, "mismatch on Name") && result + result = assert.True(ts.t, exp.Version.Equal(&got.Version), "mismatch on Version") && result + result = assert.Equal(ts.t, exp.Description, got.Description, "mismatch on Description") && result + + return result +} + +func (ts *bundleTestSuite) AssertBundlesReturned(got []*internal.Bundle) bool { + ts.t.Helper() + + result := true + + fixturesToMatch := make(map[internal.BundleID]struct{}) + for id := range ts.fixtures { + fixturesToMatch[id] = struct{}{} + } + + for _, bGot := range got { + if _, found := fixturesToMatch[bGot.ID]; !found { + assert.Fail(ts.t, "unexpected fixtures returned, ID: %s", bGot.ID) + result = false + continue + } + + delete(fixturesToMatch, bGot.ID) + bExp, _ := ts.fixtures[bGot.ID] + result = result && ts.AssertBundleEqual(bExp, bGot) + } + + result = result && assert.Empty(ts.t, fixturesToMatch, "not all expected fixtures matched") + + return result +} + +func (ts *bundleTestSuite) AssertNotFoundError(err error) bool { + ts.t.Helper() + return assert.True(ts.t, storage.IsNotFoundError(err), "NotFound error expected") +} + +func (ts *bundleTestSuite) AssertBundleDoesNotExist(b *internal.Bundle) bool { + ts.t.Helper() + + _, err := ts.s.GetByID(b.ID) + result := assert.True(ts.t, storage.IsNotFoundError(err), "NotFound error expected on GetByID") + + _, err = ts.s.Get(b.Name, b.Version) + result = assert.True(ts.t, storage.IsNotFoundError(err), "NotFound error expected on Get") && result + + return result +} diff --git a/components/helm-broker/internal/storage/testing/chart_test.go b/components/helm-broker/internal/storage/testing/chart_test.go new file mode 100644 index 000000000000..78724c5dba1b --- /dev/null +++ b/components/helm-broker/internal/storage/testing/chart_test.go @@ -0,0 +1,250 @@ +package testing + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/Masterminds/semver" + "github.com/stretchr/testify/assert" + "k8s.io/helm/pkg/proto/hapi/chart" + + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/kyma-project/kyma/components/helm-broker/internal/storage" +) + +func TestChartGet(t *testing.T) { + tRunDrivers(t, "Found", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newChartTestSuite(t, sf) + ts.PopulateStorage() + exp := ts.MustGetFixture("A1") + + // WHEN: + got, err := ts.s.Get(internal.ChartName(exp.Metadata.Name), *semver.MustParse(exp.Metadata.Version)) + + // THEN: + assert.NoError(t, err) + ts.AssertChartEqual(exp, got) + }) + + tRunDrivers(t, "NotFound", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newChartTestSuite(t, sf) + exp := ts.MustGetFixture("A1") + + // WHEN: + got, err := ts.s.Get(internal.ChartName(exp.Metadata.Name), *semver.MustParse(exp.Metadata.Version)) + + // THEN: + ts.AssertNotFoundError(err) + assert.Nil(t, got) + }) +} + +func TestChartUpsert(t *testing.T) { + tRunDrivers(t, "Success/New", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newChartTestSuite(t, sf) + fix := ts.MustGetFixture("A1") + + // WHEN: + replace, err := ts.s.Upsert(fix) + + // THEN: + assert.NoError(t, err) + assert.False(t, replace) + }) + + tRunDrivers(t, "Success/Replace", func(t *testing.T, sf storage.Factory) { + // GIVEN: + expDesc := "updated description" + ts := newChartTestSuite(t, sf) + fix := ts.MustGetFixture("A1") + ts.s.Upsert(fix) + + // WHEN: + fixNew := ts.MustCopyFixture(fix) + fixNew.Metadata.Description = expDesc + replace, err := ts.s.Upsert(fixNew) + + // THEN: + assert.NoError(t, err) + assert.True(t, replace) + + got, err := ts.s.Get(internal.ChartName(fixNew.Metadata.Name), *semver.MustParse(fixNew.Metadata.Version)) + assert.NoError(t, err) + ts.AssertChartEqual(fixNew, got) + }) + + tRunDrivers(t, "Failure/EmptyVersion", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newChartTestSuite(t, sf) + fix := ts.MustGetFixture("A1") + fix.Metadata.Version = "" + + // WHEN: + _, err := ts.s.Upsert(fix) + + // THEN: + assert.EqualError(t, err, "both name and version must be set") + }) +} + +func TestChartRemove(t *testing.T) { + tRunDrivers(t, "Found", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newChartTestSuite(t, sf) + ts.PopulateStorage() + exp := ts.MustGetFixture("A1") + + // WHEN: + err := ts.s.Remove(internal.ChartName(exp.Metadata.Name), *semver.MustParse(exp.Metadata.Version)) + + // THEN: + assert.NoError(t, err) + ts.AssertChartDoesNotExist(exp) + }) + + tRunDrivers(t, "NotFound", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newChartTestSuite(t, sf) + exp := ts.MustGetFixture("A1") + + // WHEN: + err := ts.s.Remove(internal.ChartName(exp.Metadata.Name), *semver.MustParse(exp.Metadata.Version)) + + // THEN: + ts.AssertNotFoundError(err) + }) +} + +func newChartTestSuite(t *testing.T, sf storage.Factory) *chartTestSuite { + ts := chartTestSuite{ + t: t, + s: sf.Chart(), + fixtures: make(map[chartNameVersion]*chart.Chart), + fixturesSymToKeyMap: make(map[string]chartNameVersion), + } + + ts.generateFixtures() + + return &ts +} + +type chartNameVersion string + +type chartTestSuite struct { + t *testing.T + s storage.Chart + fixtures map[chartNameVersion]*chart.Chart + fixturesSymToKeyMap map[string]chartNameVersion +} + +func (chartTestSuite) key(name internal.ChartName, ver semver.Version) chartNameVersion { + return chartNameVersion(fmt.Sprintf("%s|%s", name, ver.String())) +} + +func (chartTestSuite) mustKeyFromChart(c *chart.Chart) chartNameVersion { + if c.Metadata == nil { + panic("metadata must not be nil") + } + + return chartNameVersion(fmt.Sprintf("%s|%s", c.Metadata.Name, c.Metadata.Version)) +} + +func (ts *chartTestSuite) generateFixtures() { + for fs, ft := range map[string]struct{ id, name, version, desc string }{ + "A1": {"id-A-001", "name-A", "0.0.1", "desc-A-001"}, + "A2": {"id-A-002", "name-A", "0.0.2", "desc-A-002"}, + "B1": {"id-B-001", "name-B", "0.0.1", "desc-B-001"}, + "B2": {"id-B-002", "name-B", "0.0.2", "desc-B-002"}, + } { + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: ft.name, + Version: ft.version, + Description: ft.desc, + }, + } + + k := ts.mustKeyFromChart(c) + ts.fixtures[k] = c + ts.fixturesSymToKeyMap[fs] = k + } +} + +func (ts *chartTestSuite) PopulateStorage() { + for _, b := range ts.fixtures { + ts.s.Upsert(ts.MustCopyFixture(b)) + } +} + +func (ts *chartTestSuite) MustGetFixture(sym string) *chart.Chart { + k, found := ts.fixturesSymToKeyMap[sym] + if !found { + panic(fmt.Sprintf("fixture symbol not found, sym: %s", sym)) + } + + b, found := ts.fixtures[k] + if !found { + panic(fmt.Sprintf("fixture not found, sym: %s, nameVersion: %s", sym, k)) + } + + return b +} + +// CopyFixture is copying fixture +// BEWARE: not all fields are copied, only those currently used in this test suite scope +func (ts *chartTestSuite) MustCopyFixture(in *chart.Chart) *chart.Chart { + m, err := json.Marshal(in) + if err != nil { + panic(fmt.Sprintf("input chart marchaling failed, err: %s", err)) + } + + var out chart.Chart + if err := json.Unmarshal(m, &out); err != nil { + panic(fmt.Sprintf("input chart unmarchaling failed, err: %s", err)) + } + + return &out +} + +// AssertBundleEqual performs partial match for bundle. +// It's suitable only for tests as match is PARTIAL. +func (ts *chartTestSuite) AssertChartEqual(exp, got *chart.Chart) bool { + ts.t.Helper() + + expSet := exp == nil + gotSet := got == nil + + if expSet != gotSet { + assert.Fail(ts.t, fmt.Sprintf("mismatch on charts existence, exp set: %t, got set: %t", expSet, gotSet)) + return false + } + + expMetaSet := exp.Metadata == nil + gotMetaSet := got.Metadata == nil + + if expMetaSet != gotMetaSet { + assert.Fail(ts.t, fmt.Sprintf("mismatch on metadata status, exp set: %t, got set: %t", expMetaSet, gotMetaSet)) + return false + } + + result := assert.Equal(ts.t, exp.Metadata.Name, got.Metadata.Name, "mismatch on Name") + result = assert.Equal(ts.t, exp.Metadata.Version, got.Metadata.Version, "mismatch on Version") && result + result = assert.Equal(ts.t, exp.Metadata.Description, got.Metadata.Description, "mismatch on Description") && result + + return result +} + +func (ts *chartTestSuite) AssertNotFoundError(err error) bool { + ts.t.Helper() + return assert.True(ts.t, storage.IsNotFoundError(err), "NotFound error expected") +} + +func (ts *chartTestSuite) AssertChartDoesNotExist(exp *chart.Chart) bool { + ts.t.Helper() + _, err := ts.s.Get(internal.ChartName(exp.Metadata.Name), *semver.MustParse(exp.Metadata.Version)) + return assert.True(ts.t, storage.IsNotFoundError(err), "NotFound error expected") +} diff --git a/components/helm-broker/internal/storage/testing/common_test.go b/components/helm-broker/internal/storage/testing/common_test.go new file mode 100644 index 000000000000..3003bdbaefe3 --- /dev/null +++ b/components/helm-broker/internal/storage/testing/common_test.go @@ -0,0 +1,85 @@ +package testing + +import ( + "fmt" + "testing" + "time" + + "github.com/coreos/etcd/integration" + "github.com/coreos/pkg/capnslog" + "github.com/stretchr/testify/require" + + "github.com/kyma-project/kyma/components/helm-broker/internal/storage" + "github.com/kyma-project/kyma/components/helm-broker/internal/storage/driver/etcd" + "github.com/kyma-project/kyma/components/helm-broker/internal/storage/driver/memory" +) + +var allDrivers = map[storage.DriverType]func() storage.ConfigList{ + storage.DriverMemory: func() storage.ConfigList { + return storage.ConfigList{storage.Config{ + Driver: storage.DriverMemory, + Provide: storage.ProviderConfigMap{storage.EntityAll: storage.ProviderConfig{}}, + Memory: memory.Config{ + // Ignored for now + MaxKeys: 666, + }, + }} + }, + storage.DriverEtcd: func() storage.ConfigList { + + return storage.ConfigList{storage.Config{ + Driver: storage.DriverEtcd, + Provide: storage.ProviderConfigMap{storage.EntityAll: storage.ProviderConfig{}}, + Etcd: etcd.Config{}, + }} + }, +} + +func tRunDrivers(t *testing.T, tName string, f func(*testing.T, storage.Factory)) bool { + result := true + for dt, clGen := range allDrivers { + cl := clGen() + + fT := func(t *testing.T) { + if dt == storage.DriverEtcd { + // silence logs for all coreos packages to silence etcd + ft := capnslog.NewNilFormatter() + + // enable verbose logging + //ft := capnslog.NewPrettyFormatter(os.Stdout, true) + capnslog.SetFormatter(ft) + + cfg := integration.ClusterConfig{ + Size: 1, + QuotaBackendBytes: 10 * 1024 * 1024, + UseGRPC: true, + } + + clus := integration.NewClusterByConfig(t, &cfg) + m := clus.Members[0] + + // lower cluster startup time + m.BootstrapTimeout = time.Millisecond + m.ElectionTicks = 2 + m.TickMs = 1 + m.ServerConfig.TickMs = 1 + + clus.Launch(t) + client, err := integration.NewClientV3(m) + require.NoError(t, err) + + defer clus.Terminate(t) + + cl[0].Etcd.ForceClient = client + } + + sf, err := storage.NewFactory(&cl) + require.NoError(t, err) + + f(t, sf) + } + result = t.Run(fmt.Sprintf("%s/%s", dt, tName), fT) && result + } + + return result +} diff --git a/components/helm-broker/internal/storage/testing/doc.go b/components/helm-broker/internal/storage/testing/doc.go new file mode 100644 index 000000000000..73f8d8ea41ab --- /dev/null +++ b/components/helm-broker/internal/storage/testing/doc.go @@ -0,0 +1,4 @@ +// Package testing provides test functions for storage. +// Those functions should not be driver (backend) specific but should test storage API +// regardless of actual implementation. +package testing diff --git a/components/helm-broker/internal/storage/testing/instance_bind_data_test.go b/components/helm-broker/internal/storage/testing/instance_bind_data_test.go new file mode 100644 index 000000000000..b53a43c243b2 --- /dev/null +++ b/components/helm-broker/internal/storage/testing/instance_bind_data_test.go @@ -0,0 +1,207 @@ +package testing + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/kyma-project/kyma/components/helm-broker/internal/storage" +) + +func TestInstanceBindDataGet(t *testing.T) { + tRunDrivers(t, "Found", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceBindDataTestSuite(t, sf) + ts.PopulateStorage() + exp := ts.MustGetFixture("single") + + // WHEN: + got, err := ts.s.Get(exp.InstanceID) + + // THEN: + assert.NoError(t, err) + ts.AssertInstanceEqual(exp, got) + }) + + tRunDrivers(t, "Failure/NotFound", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceBindDataTestSuite(t, sf) + ts.PopulateStorage() + + // WHEN: + got, err := ts.s.Get(internal.InstanceID("non-existing-iID")) + + // THEN: + ts.AssertNotFoundError(err) + assert.Nil(t, got) + }) +} + +func TestInstanceBindDataInsert(t *testing.T) { + tRunDrivers(t, "Success/New", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceBindDataTestSuite(t, sf) + fix := ts.MustGetFixture("single") + + // WHEN: + err := ts.s.Insert(fix) + + // THEN: + assert.NoError(t, err) + }) + + tRunDrivers(t, "Failure/AlreadyExist", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceBindDataTestSuite(t, sf) + fix := ts.MustGetFixture("single") + ts.s.Insert(fix) + + // WHEN: + fixNew := ts.MustCopyFixture(fix) + err := ts.s.Insert(fixNew) + + // THEN: + ts.AssertAlreadyExistsError(err) + }) + + tRunDrivers(t, "Failure/EmptyID", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceBindDataTestSuite(t, sf) + fix := ts.MustGetFixture("single") + fix.InstanceID = internal.InstanceID("") + + // WHEN: + err := ts.s.Insert(fix) + + // THEN: + assert.EqualError(t, err, "instance id must be set") + }) +} + +func TestInstanceBindDataRemove(t *testing.T) { + tRunDrivers(t, "Success", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceBindDataTestSuite(t, sf) + ts.PopulateStorage() + exp := ts.MustGetFixture("single") + + // WHEN: + err := ts.s.Remove(exp.InstanceID) + + // THEN: + assert.NoError(t, err) + ts.AssertInstanceBindDataDoesNotExist(exp) + }) + + tRunDrivers(t, "Failure/NotFound", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceBindDataTestSuite(t, sf) + ts.PopulateStorage() + + // WHEN: + err := ts.s.Remove(internal.InstanceID("non-existing-iID")) + + // THEN: + ts.AssertNotFoundError(err) + }) +} + +func newInstanceBindDataTestSuite(t *testing.T, sf storage.Factory) *instanceBindDataTestSuite { + ts := instanceBindDataTestSuite{ + t: t, + s: sf.InstanceBindData(), + fixtures: make(map[internal.InstanceID]*internal.InstanceBindData), + fixturesSymToKeyMap: make(map[string]internal.InstanceID), + } + + ts.generateFixtures() + + return &ts +} + +type instanceBindDataTestSuite struct { + t *testing.T + s storage.InstanceBindData + fixtures map[internal.InstanceID]*internal.InstanceBindData + fixturesSymToKeyMap map[string]internal.InstanceID +} + +func (ts *instanceBindDataTestSuite) generateFixtures() { + for fs, ft := range map[string]struct { + id string + cred map[string]string + }{ + "single": {"id-01", map[string]string{"c1": "v1"}}, + "multiple": {"id-02", map[string]string{"c1": "v1", "c2": "v2"}}, + "empty": {"id-03", map[string]string{}}, + } { + cred := make(internal.InstanceCredentials) + + i := &internal.InstanceBindData{ + InstanceID: internal.InstanceID(ft.id), + Credentials: cred, + } + + ts.fixtures[i.InstanceID] = i + ts.fixturesSymToKeyMap[fs] = i.InstanceID + } +} + +func (ts *instanceBindDataTestSuite) PopulateStorage() { + for _, b := range ts.fixtures { + ts.s.Insert(ts.MustCopyFixture(b)) + } +} + +func (ts *instanceBindDataTestSuite) MustGetFixture(sym string) *internal.InstanceBindData { + k, found := ts.fixturesSymToKeyMap[sym] + if !found { + panic(fmt.Sprintf("fixture symbol not found, sym: %s", sym)) + } + + b, found := ts.fixtures[k] + if !found { + panic(fmt.Sprintf("fixture not found, sym: %s, nameVersion: %s", sym, k)) + } + + return b +} + +// CopyFixture is copying fixture +// BEWARE: not all fields are copied, only those currently used in this test suite scope +func (ts *instanceBindDataTestSuite) MustCopyFixture(in *internal.InstanceBindData) *internal.InstanceBindData { + out := &internal.InstanceBindData{ + InstanceID: in.InstanceID, + Credentials: make(internal.InstanceCredentials), + } + + for i := range in.Credentials { + val := in.Credentials[i] + out.Credentials[i] = val + } + + return out +} + +func (ts *instanceBindDataTestSuite) AssertInstanceEqual(exp, got *internal.InstanceBindData) bool { + ts.t.Helper() + return assert.EqualValues(ts.t, exp, got) +} + +func (ts *instanceBindDataTestSuite) AssertNotFoundError(err error) bool { + ts.t.Helper() + return assert.True(ts.t, storage.IsNotFoundError(err), "NotFound error expected") +} + +func (ts *instanceBindDataTestSuite) AssertAlreadyExistsError(err error) bool { + ts.t.Helper() + return assert.True(ts.t, storage.IsAlreadyExistsError(err), "AlreadyExists error expected") +} + +func (ts *instanceBindDataTestSuite) AssertInstanceBindDataDoesNotExist(i *internal.InstanceBindData) bool { + ts.t.Helper() + _, err := ts.s.Get(i.InstanceID) + return assert.True(ts.t, storage.IsNotFoundError(err), "NotFound error expected") +} diff --git a/components/helm-broker/internal/storage/testing/instance_test.go b/components/helm-broker/internal/storage/testing/instance_test.go new file mode 100644 index 000000000000..c78290a6c015 --- /dev/null +++ b/components/helm-broker/internal/storage/testing/instance_test.go @@ -0,0 +1,201 @@ +package testing + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/kyma-project/kyma/components/helm-broker/internal/storage" +) + +func TestInstanceGet(t *testing.T) { + tRunDrivers(t, "Found", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceTestSuite(t, sf) + ts.PopulateStorage() + exp := ts.MustGetFixture("A1") + + // WHEN: + got, err := ts.s.Get(exp.ID) + + // THEN: + assert.NoError(t, err) + ts.AssertInstanceEqual(exp, got) + }) + + tRunDrivers(t, "Failure/NotFound", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceTestSuite(t, sf) + ts.PopulateStorage() + + // WHEN: + got, err := ts.s.Get(internal.InstanceID("non-existing-iID")) + + // THEN: + ts.AssertNotFoundError(err) + assert.Nil(t, got) + }) +} + +func TestInstanceInsert(t *testing.T) { + tRunDrivers(t, "Success/New", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceTestSuite(t, sf) + fix := ts.MustGetFixture("A1") + + // WHEN: + err := ts.s.Insert(fix) + + // THEN: + assert.NoError(t, err) + }) + + tRunDrivers(t, "Failure/AlreadyExist", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceTestSuite(t, sf) + fix := ts.MustGetFixture("A1") + ts.s.Insert(fix) + + // WHEN: + fixNew := ts.MustCopyFixture(fix) + err := ts.s.Insert(fixNew) + + // THEN: + ts.AssertAlreadyExistsError(err) + }) + + tRunDrivers(t, "Failure/EmptyID", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceTestSuite(t, sf) + fix := ts.MustGetFixture("A1") + fix.ID = internal.InstanceID("") + + // WHEN: + err := ts.s.Insert(fix) + + // THEN: + assert.EqualError(t, err, "instance id must be set") + }) +} + +func TestInstanceRemove(t *testing.T) { + tRunDrivers(t, "Success", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceTestSuite(t, sf) + ts.PopulateStorage() + exp := ts.MustGetFixture("A1") + + // WHEN: + err := ts.s.Remove(exp.ID) + + // THEN: + assert.NoError(t, err) + ts.AssertInstanceDoesNotExist(exp) + }) + + tRunDrivers(t, "Failure/NotFound", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceTestSuite(t, sf) + exp := ts.MustGetFixture("A1") + + // WHEN: + err := ts.s.Remove(exp.ID) + + // THEN: + ts.AssertNotFoundError(err) + }) +} + +func newInstanceTestSuite(t *testing.T, sf storage.Factory) *instanceTestSuite { + ts := instanceTestSuite{ + t: t, + s: sf.Instance(), + fixtures: make(map[internal.InstanceID]*internal.Instance), + fixturesSymToKeyMap: make(map[string]internal.InstanceID), + } + + ts.generateFixtures() + + return &ts +} + +type instanceTestSuite struct { + t *testing.T + s storage.Instance + fixtures map[internal.InstanceID]*internal.Instance + fixturesSymToKeyMap map[string]internal.InstanceID +} + +func (ts *instanceTestSuite) generateFixtures() { + for fs, ft := range map[string]struct{ id, sID, spID, rName, pHash string }{ + "A1": {"id-01", "sID-01", "spID-01", "rName-01-01", "pHash-01"}, + "A2": {"id-02", "sID-01", "spID-01", "rName-01-02", "pHash-02"}, + "A3": {"id-03", "sID-03", "spID-03", "rName-03", "pHash-03"}, + } { + i := &internal.Instance{ + ID: internal.InstanceID(ft.id), + ServiceID: internal.ServiceID(ft.sID), + ServicePlanID: internal.ServicePlanID(ft.spID), + ReleaseName: internal.ReleaseName(ft.rName), + ParamsHash: ft.pHash, + } + + ts.fixtures[i.ID] = i + ts.fixturesSymToKeyMap[fs] = i.ID + } +} + +func (ts *instanceTestSuite) PopulateStorage() { + for _, b := range ts.fixtures { + ts.s.Insert(ts.MustCopyFixture(b)) + } +} + +func (ts *instanceTestSuite) MustGetFixture(sym string) *internal.Instance { + k, found := ts.fixturesSymToKeyMap[sym] + if !found { + panic(fmt.Sprintf("fixture symbol not found, sym: %s", sym)) + } + + b, found := ts.fixtures[k] + if !found { + panic(fmt.Sprintf("fixture not found, sym: %s, nameVersion: %s", sym, k)) + } + + return b +} + +// CopyFixture is copying fixture +// BEWARE: not all fields are copied, only those currently used in this test suite scope +func (ts *instanceTestSuite) MustCopyFixture(in *internal.Instance) *internal.Instance { + return &internal.Instance{ + ID: in.ID, + ServiceID: in.ServiceID, + ServicePlanID: in.ServicePlanID, + ReleaseName: in.ReleaseName, + ParamsHash: in.ParamsHash, + } +} + +func (ts *instanceTestSuite) AssertInstanceEqual(exp, got *internal.Instance) bool { + ts.t.Helper() + return assert.EqualValues(ts.t, exp, got) +} + +func (ts *instanceTestSuite) AssertNotFoundError(err error) bool { + ts.t.Helper() + return assert.True(ts.t, storage.IsNotFoundError(err), "NotFound error expected") +} + +func (ts *instanceTestSuite) AssertAlreadyExistsError(err error) bool { + ts.t.Helper() + return assert.True(ts.t, storage.IsAlreadyExistsError(err), "AlreadyExists error expected") +} + +func (ts *instanceTestSuite) AssertInstanceDoesNotExist(i *internal.Instance) bool { + ts.t.Helper() + _, err := ts.s.Get(i.ID) + return assert.True(ts.t, storage.IsNotFoundError(err), "NotFound error expected") +} diff --git a/components/helm-broker/internal/storage/testing/operation_export_test.go b/components/helm-broker/internal/storage/testing/operation_export_test.go new file mode 100644 index 000000000000..55e47069eed3 --- /dev/null +++ b/components/helm-broker/internal/storage/testing/operation_export_test.go @@ -0,0 +1,22 @@ +package testing + +import ( + "fmt" + "time" + + "github.com/kyma-project/kyma/components/helm-broker/internal/storage" + "github.com/kyma-project/kyma/components/helm-broker/internal/storage/driver/etcd" + "github.com/kyma-project/kyma/components/helm-broker/internal/storage/driver/memory" +) + +func mustInstanceOperationWithClock(u storage.InstanceOperation, nowProvider func() time.Time) storage.InstanceOperation { + switch uCst := u.(type) { + case *memory.InstanceOperation: + return uCst.WithTimeProvider(nowProvider) + case *etcd.InstanceOperation: + return uCst.WithTimeProvider(nowProvider) + default: + } + + panic(fmt.Sprintf("unsupported InstanceOperation storage type: %T", u)) +} diff --git a/components/helm-broker/internal/storage/testing/operation_test.go b/components/helm-broker/internal/storage/testing/operation_test.go new file mode 100644 index 000000000000..7b2acd9417a7 --- /dev/null +++ b/components/helm-broker/internal/storage/testing/operation_test.go @@ -0,0 +1,460 @@ +package testing + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/kyma-project/kyma/components/helm-broker/internal/storage" +) + +func TestInstanceOperationGet(t *testing.T) { + tRunDrivers(t, "Found", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInMemoryOperationTestSuite(t, sf) + ts.PopulateStorage() + exp := ts.MustGetFixture("i01/o01/Create/h1/InProgress") + + // WHEN: + got, err := ts.s.Get(exp.InstanceID, exp.OperationID) + + // THEN: + assert.NoError(t, err) + ts.AssertInstanceOperationEqualWithoutCreatedAt(exp, got) + }) + + tRunDrivers(t, "NotFound/Instance", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInMemoryOperationTestSuite(t, sf) + ts.PopulateStorage() + exp := ts.MustGetFixture("i01/o01/Create/h1/InProgress") + + // WHEN: + got, err := ts.s.Get(internal.InstanceID("non-existing-iID"), exp.OperationID) + + // THEN: + ts.AssertNotFoundError(err) + assert.Nil(t, got) + }) + + tRunDrivers(t, "NotFound/Operation", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInMemoryOperationTestSuite(t, sf) + ts.PopulateStorage() + exp := ts.MustGetFixture("i01/o01/Create/h1/InProgress") + + // WHEN: + got, err := ts.s.Get(exp.InstanceID, internal.OperationID("non-existing-opID")) + + // THEN: + ts.AssertNotFoundError(err) + assert.Nil(t, got) + }) + + tRunDrivers(t, "NotFound/InstanceAndOperation", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInMemoryOperationTestSuite(t, sf) + ts.PopulateStorage() + + // WHEN: + got, err := ts.s.Get(internal.InstanceID("non-existing-iID"), internal.OperationID("non-existing-opID")) + + // THEN: + ts.AssertNotFoundError(err) + assert.Nil(t, got) + }) +} + +func TestInstanceOperationInsert(t *testing.T) { + tRunDrivers(t, "Success", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInMemoryOperationTestSuite(t, sf) + fix := ts.MustGetFixture("i01/o01/Create/h1/InProgress") + + // WHEN: + err := ts.s.Insert(fix) + + // THEN: + assert.NoError(t, err) + }) + + tRunDrivers(t, "Failure/AlreadyExist", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInMemoryOperationTestSuite(t, sf) + fix := ts.MustGetFixture("i01/o01/Create/h1/InProgress") + ts.s.Insert(fix) + + // WHEN: + fixNew := ts.MustCopyFixture(fix) + err := ts.s.Insert(fixNew) + + // THEN: + ts.AssertAlreadyExistsError(err) + }) + + tRunDrivers(t, "Failure/EmptyInstanceID", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInMemoryOperationTestSuite(t, sf) + fix := ts.MustGetFixture("i01/o01/Create/h1/InProgress") + fix.InstanceID = internal.InstanceID("") + + // WHEN: + err := ts.s.Insert(fix) + + // THEN: + assert.EqualError(t, err, "both instance and operation id must be set") + }) + + tRunDrivers(t, "Failure/EmptyOperationID", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInMemoryOperationTestSuite(t, sf) + fix := ts.MustGetFixture("i01/o01/Create/h1/InProgress") + fix.OperationID = internal.OperationID("") + + // WHEN: + err := ts.s.Insert(fix) + + // THEN: + assert.EqualError(t, err, "both instance and operation id must be set") + }) + + tRunDrivers(t, "Failure/ActiveOperationAlreadyExists", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInMemoryOperationTestSuite(t, sf) + fix := ts.MustGetFixture("i01/o01/Create/h1/InProgress") + ts.s.Insert(fix) + + // WHEN: + fixNew := ts.MustCopyFixture(fix) + fixNew.OperationID = internal.OperationID("new-operation") + err := ts.s.Insert(fixNew) + + // THEN: + ts.AssertActiveOperationInProgressError(err) + }) +} + +func TestInstanceOperationInsertGet(t *testing.T) { + tRunDrivers(t, "Success/TimeSetInInstance", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInMemoryOperationTestSuite(t, sf) + + fixTime := time.Date(2020, 1, 1, 0, 0, 1, 0, time.UTC) + ts.s = mustInstanceOperationWithClock(ts.s, func() time.Time { return fixTime }) + + fixOp := ts.MustGetFixture("i01/o01/Create/h1/InProgress") + + // WHEN: + err := ts.s.Insert(fixOp) + + // THEN: + assert.NoError(t, err) + assert.True(t, fixOp.CreatedAt.Equal(fixTime)) + }) + + tRunDrivers(t, "Success/TimeSetInStorage", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInMemoryOperationTestSuite(t, sf) + + fixTime := time.Date(2020, 1, 1, 0, 0, 1, 0, time.UTC) + ts.s = mustInstanceOperationWithClock(ts.s, func() time.Time { return fixTime }) + + fixOp := ts.MustGetFixture("i01/o01/Create/h1/InProgress") + + // WHEN: + err := ts.s.Insert(fixOp) + + // THEN: + assert.NoError(t, err) + + gotOp, err := ts.s.Get(fixOp.InstanceID, fixOp.OperationID) + require.NoError(t, err) + + assert.True(t, gotOp.CreatedAt.Equal(fixTime)) + }) +} + +func TestInstanceOperationGetAll(t *testing.T) { + tRunDrivers(t, "Found", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInMemoryOperationTestSuite(t, sf) + ts.PopulateStorage() + iID := internal.InstanceID("iID-002") + expOpIDs := []internal.OperationID{"oID-002-002", "oID-002-003"} + + // WHEN: + got, err := ts.s.GetAll(iID) + + // THEN: + assert.NoError(t, err) + + require.Len(t, got, len(expOpIDs)) + for _, op := range got { + assert.Contains(t, expOpIDs, op.OperationID) + } + }) + + tRunDrivers(t, "NotFound/Instance", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInMemoryOperationTestSuite(t, sf) + ts.PopulateStorage() + + // WHEN: + got, err := ts.s.GetAll(internal.InstanceID("iID-non-existent")) + + // THEN: + ts.AssertNotFoundError(err) + assert.Nil(t, got) + }) +} + +func TestInstanceOperationUpdateState(t *testing.T) { + tRunDrivers(t, "Success", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInMemoryOperationTestSuite(t, sf) + fix := ts.MustGetFixture("i01/o01/Create/h1/InProgress") + ts.s.Insert(fix) + + exp := ts.MustCopyFixture(fix) + exp.State = internal.OperationStateSucceeded + exp.StateDescription = nil + + // WHEN: + err := ts.s.UpdateState(fix.InstanceID, fix.OperationID, internal.OperationStateSucceeded) + + // THEN: + assert.NoError(t, err) + + got, err := ts.s.Get(fix.InstanceID, fix.OperationID) + require.NoError(t, err) + + ts.AssertInstanceOperationEqualWithoutCreatedAt(exp, got) + }) +} + +func TestInstanceOperationUpdateStateDesc(t *testing.T) { + tRunDrivers(t, "Success/DescSet", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInMemoryOperationTestSuite(t, sf) + fix := ts.MustGetFixture("i01/o01/Create/h1/InProgress") + ts.s.Insert(fix) + + expDesc := "updated desc" + exp := ts.MustCopyFixture(fix) + exp.State = internal.OperationStateSucceeded + exp.StateDescription = &expDesc + + // WHEN: + expDescCpy := expDesc + err := ts.s.UpdateStateDesc(fix.InstanceID, fix.OperationID, internal.OperationStateSucceeded, &expDescCpy) + + // THEN: + assert.NoError(t, err) + + got, err := ts.s.Get(fix.InstanceID, fix.OperationID) + require.NoError(t, err) + + ts.AssertInstanceOperationEqualWithoutCreatedAt(exp, got) + }) + + tRunDrivers(t, "Success/DescRemoved", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInMemoryOperationTestSuite(t, sf) + fix := ts.MustGetFixture("i01/o01/Create/h1/InProgress") + ts.s.Insert(fix) + + exp := ts.MustCopyFixture(fix) + exp.State = internal.OperationStateSucceeded + exp.StateDescription = nil + + // WHEN: + err := ts.s.UpdateStateDesc(fix.InstanceID, fix.OperationID, internal.OperationStateSucceeded, nil) + + // THEN: + assert.NoError(t, err) + + got, err := ts.s.Get(fix.InstanceID, fix.OperationID) + require.NoError(t, err) + + ts.AssertInstanceOperationEqualWithoutCreatedAt(exp, got) + }) +} + +func TestInstanceOperationRemove(t *testing.T) { + tRunDrivers(t, "Success", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInMemoryOperationTestSuite(t, sf) + ts.PopulateStorage() + exp := ts.MustGetFixture("i01/o01/Create/h1/InProgress") + + // WHEN: + err := ts.s.Remove(exp.InstanceID, exp.OperationID) + + // THEN: + assert.NoError(t, err) + ts.AssertOperationDoesNotExist(exp) + }) + + tRunDrivers(t, "Failure/NotFound", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInMemoryOperationTestSuite(t, sf) + exp := ts.MustGetFixture("i01/o01/Create/h1/InProgress") + + // WHEN: + err := ts.s.Remove(exp.InstanceID, exp.OperationID) + + // THEN: + ts.AssertNotFoundError(err) + }) +} + +func newInMemoryOperationTestSuite(t *testing.T, sf storage.Factory) *operationTestSuite { + ts := operationTestSuite{ + t: t, + s: sf.InstanceOperation(), + fixtures: make(map[instanceOperationRef]*internal.InstanceOperation), + fixturesSymToRefMap: make(map[string]instanceOperationRef), + } + + ts.generateFixtures() + + return &ts +} + +type instanceOperationRef struct { + InstanceID internal.InstanceID + OperationID internal.OperationID +} + +type operationTestSuite struct { + t *testing.T + s storage.InstanceOperation + fixtures map[instanceOperationRef]*internal.InstanceOperation + fixturesSymToRefMap map[string]instanceOperationRef + fixturesPopulationOrder []instanceOperationRef +} + +func (ts *operationTestSuite) generateFixtures() { + for _, ft := range []struct { + sym string + iID, opID string + opType internal.OperationType + opState internal.OperationState + sDesc, pHash string + }{ + // Order is important as storage will reject insert if there is in ptogress operation for given instance. + {"i01/o01/Create/h1/InProgress", "iID-001", "oID-001-001", internal.OperationTypeCreate, internal.OperationStateInProgress, "state desc 001", "pHash-001"}, + {"i02/o02/Create/h2/Succeeded", "iID-002", "oID-002-002", internal.OperationTypeCreate, internal.OperationStateSucceeded, "state desc 002", "pHash-002"}, + {"i02/o03/Remove/h3/InProgress", "iID-002", "oID-002-003", internal.OperationTypeRemove, internal.OperationStateInProgress, "state desc 003", "pHash-003"}, + } { + iID := internal.InstanceID(ft.iID) + opID := internal.OperationID(ft.opID) + + ir := instanceOperationRef{ + InstanceID: iID, + OperationID: opID, + } + + io := internal.InstanceOperation{ + InstanceID: iID, + OperationID: opID, + Type: ft.opType, + State: ft.opState, + StateDescription: &ft.sDesc, + ParamsHash: ft.pHash, + } + + ts.fixtures[ir] = &io + ts.fixturesSymToRefMap[ft.sym] = ir + ts.fixturesPopulationOrder = append(ts.fixturesPopulationOrder, ir) + } +} + +func (ts *operationTestSuite) PopulateStorage() { + for _, ir := range ts.fixturesPopulationOrder { + io := ts.fixtures[ir] + err := ts.s.Insert(ts.MustCopyFixture(io)) + if err != nil { + ts.t.Fatalf("populate storage failed, io: %v, err: %s", io, err) + } + } +} + +func (ts *operationTestSuite) MustGetFixture(sym string) *internal.InstanceOperation { + ref, found := ts.fixturesSymToRefMap[sym] + if !found { + panic(fmt.Sprintf("fixture symbol not found, sym: %s", sym)) + } + + b, found := ts.fixtures[ref] + if !found { + panic(fmt.Sprintf("fixture not found, sym: %s, ref: %v", sym, ref)) + } + + return b +} + +func (ts *operationTestSuite) MustCopyFixture(in *internal.InstanceOperation) *internal.InstanceOperation { + out := internal.InstanceOperation{ + InstanceID: in.InstanceID, + OperationID: in.OperationID, + Type: in.Type, + State: in.State, + ParamsHash: in.ParamsHash, + CreatedAt: in.CreatedAt, + } + + if in.StateDescription != nil { + var sDescCpy string + sDescCpy = *in.StateDescription + out.StateDescription = &sDescCpy + } + + return &out +} + +// AssertInstanceOperationEqualWithoutCreatedAt performs partial match for bundle. +// It's suitable only for tests as match is PARTIAL. +func (ts *operationTestSuite) AssertInstanceOperationEqualWithoutCreatedAt(exp, got *internal.InstanceOperation) bool { + ts.t.Helper() + + expSet := exp == nil + gotSet := got == nil + + if expSet != gotSet { + assert.Fail(ts.t, fmt.Sprintf("mismatch on operations existence, exp set: %t, got set: %t", expSet, gotSet)) + return false + } + + expCpy := ts.MustCopyFixture(exp) + expCpy.CreatedAt = time.Time{} + gotCpy := ts.MustCopyFixture(got) + gotCpy.CreatedAt = time.Time{} + + return assert.EqualValues(ts.t, expCpy, gotCpy) +} + +func (ts *operationTestSuite) AssertNotFoundError(err error) bool { + ts.t.Helper() + return assert.True(ts.t, storage.IsNotFoundError(err), "NotFound error expected") +} + +func (ts *operationTestSuite) AssertAlreadyExistsError(err error) bool { + ts.t.Helper() + return assert.True(ts.t, storage.IsAlreadyExistsError(err), "AlreadyExists error expected") +} + +func (ts *operationTestSuite) AssertActiveOperationInProgressError(err error) bool { + ts.t.Helper() + return assert.True(ts.t, storage.IsActiveOperationInProgressError(err), "ActiveOperationInProgress error expected") +} + +func (ts *operationTestSuite) AssertOperationDoesNotExist(op *internal.InstanceOperation) bool { + ts.t.Helper() + _, err := ts.s.Get(op.InstanceID, op.OperationID) + return assert.True(ts.t, storage.IsNotFoundError(err), "NotFound error expected") +} diff --git a/components/helm-broker/internal/ybind/automock/chart_go_template_renderer.go b/components/helm-broker/internal/ybind/automock/chart_go_template_renderer.go new file mode 100644 index 000000000000..095c8396b2ae --- /dev/null +++ b/components/helm-broker/internal/ybind/automock/chart_go_template_renderer.go @@ -0,0 +1,34 @@ +// Code generated by mockery v1.0.0 +package automock + +import chart "k8s.io/helm/pkg/proto/hapi/chart" +import chartutil "k8s.io/helm/pkg/chartutil" +import mock "github.com/stretchr/testify/mock" + +// ChartGoTemplateRenderer is an autogenerated mock type for the ChartGoTemplateRenderer type +type ChartGoTemplateRenderer struct { + mock.Mock +} + +// Render provides a mock function with given fields: _a0, _a1 +func (_m *ChartGoTemplateRenderer) Render(_a0 *chart.Chart, _a1 chartutil.Values) (map[string]string, error) { + ret := _m.Called(_a0, _a1) + + var r0 map[string]string + if rf, ok := ret.Get(0).(func(*chart.Chart, chartutil.Values) map[string]string); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]string) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*chart.Chart, chartutil.Values) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/helm-broker/internal/ybind/renderer.go b/components/helm-broker/internal/ybind/renderer.go new file mode 100644 index 000000000000..94cfb77dc768 --- /dev/null +++ b/components/helm-broker/internal/ybind/renderer.go @@ -0,0 +1,100 @@ +package ybind + +import ( + "fmt" + + "github.com/pkg/errors" + "k8s.io/helm/pkg/chartutil" + "k8s.io/helm/pkg/engine" + "k8s.io/helm/pkg/proto/hapi/chart" + rls "k8s.io/helm/pkg/proto/hapi/services" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +const ( + goTplEngine = "gotpl" + bindFile = "bindTmpl" +) + +//go:generate mockery -name=chartGoTemplateRenderer -output=automock -outpkg=automock -case=underscore +type chartGoTemplateRenderer interface { + Render(*chart.Chart, chartutil.Values) (map[string]string, error) +} + +type toRenderValuesCaps func(*chart.Chart, *chart.Config, chartutil.ReleaseOptions, *chartutil.Capabilities) (chartutil.Values, error) + +// Renderer purpose is to render helm template directives, like: {{ .Release.Namespace }} +type Renderer struct { + renderEngine chartGoTemplateRenderer + toRenderValuesCaps toRenderValuesCaps +} + +// NewRenderer creates new instance of Renderer. +func NewRenderer() *Renderer { + return &Renderer{ + renderEngine: engine.New(), + toRenderValuesCaps: chartutil.ToRenderValuesCaps, + } +} + +// Render renders given bindTemplate in context of helm Chart by e.g. replacing directives like: {{ .Release.Namespace }} +func (r *Renderer) Render(bindTemplate internal.BundlePlanBindTemplate, resp *rls.InstallReleaseResponse) (RenderedBindYAML, error) { + if err := r.validateInstallReleaseResponse(resp); err != nil { + return nil, errors.Wrap(err, "while validating input") + } + + ch := resp.Release.Chart + options := r.createReleaseOptions(resp) + chartCap := &chartutil.Capabilities{} + + valsToRender, err := r.toRenderValuesCaps(ch, resp.Release.Config, options, chartCap) + if err != nil { + return nil, errors.Wrap(err, "while merging values to render") + } + + ch.Templates = append(ch.Templates, &chart.Template{Name: bindFile, Data: bindTemplate}) + + files, err := r.renderEngine.Render(ch, valsToRender) + if err != nil { + return nil, errors.Wrap(err, "while rendering files") + } + + rendered, exits := files[fmt.Sprintf("%s/%s", ch.Metadata.Name, bindFile)] + if !exits { + return nil, fmt.Errorf("%v file was not resolved after rendering", bindFile) + } + + return RenderedBindYAML(rendered), nil +} + +func (*Renderer) validateInstallReleaseResponse(resp *rls.InstallReleaseResponse) error { + if resp == nil { + return fmt.Errorf("input parameter 'InstallReleaseResponse' cannot be nil") + } + + if resp.Release == nil { + return fmt.Errorf("'Release' filed from 'InstallReleaseResponse' is missing") + } + + if resp.Release.Info == nil { + return fmt.Errorf("'Info' filed from 'InstallReleaseResponse' is missing") + } + + ch := resp.Release.Chart + if ch.Metadata.Engine != "" && ch.Metadata.Engine != goTplEngine { + return fmt.Errorf("chart %q requested non-existent template engine %q", ch.Metadata.Name, ch.Metadata.Engine) + } + + return nil +} + +func (*Renderer) createReleaseOptions(resp *rls.InstallReleaseResponse) chartutil.ReleaseOptions { + return chartutil.ReleaseOptions{ + Name: resp.Release.Name, + Time: resp.Release.Info.LastDeployed, + Namespace: resp.Release.Namespace, + Revision: int(resp.Release.Version), + IsInstall: true, + } +} diff --git a/components/helm-broker/internal/ybind/renderer_example_test.go b/components/helm-broker/internal/ybind/renderer_example_test.go new file mode 100644 index 000000000000..41df0ed371a1 --- /dev/null +++ b/components/helm-broker/internal/ybind/renderer_example_test.go @@ -0,0 +1,66 @@ +//+build integration + +package ybind_test + +import ( + "fmt" + "io/ioutil" + "log" + "path/filepath" + + "github.com/kyma-project/kyma/components/helm-broker/internal" + yhelm "github.com/kyma-project/kyma/components/helm-broker/internal/helm" + "github.com/kyma-project/kyma/components/helm-broker/internal/platform/logger/spy" + "github.com/kyma-project/kyma/components/helm-broker/internal/ybind" + "k8s.io/helm/pkg/chartutil" +) + +// To run it you need expose tiller pod port by: +// kubectl port-forward 44134:44134 -n kube-system +func ExampleNewRenderer() { + const releaseName = "example-renderer-test" + bindTmplRenderer := ybind.NewRenderer() + + // loadChart + ch, err := chartutil.Load("testdata/repository/redis-0.0.3/chart/redis") + fatalOnErr(err) + + // load bind template for above chart + b, err := ioutil.ReadFile(filepath.Join("testdata/repository", "redis-0.0.3/plans/micro/bind.yaml")) + fatalOnErr(err) + + hClient := yhelm.NewClient("localhost:44134", spy.NewLogDummy()) + + // install chart in same way as we are doing in our business logic + resp, err := hClient.Install(ch, internal.ChartValues{}, releaseName, "ns-name") + + // clean-up, even if install error occurred + defer hClient.Delete(releaseName) + fatalOnErr(err) + + rendered, err := bindTmplRenderer.Render(internal.BundlePlanBindTemplate(b), resp) + fatalOnErr(err) + + fmt.Println(string(rendered)) + + // Output: + // credential: + // - name: HOST + // value: example-renderer-test-redis.ns-name.svc.cluster.local + // - name: PORT + // valueFrom: + // serviceRef: + // name: example-renderer-test-redis + // jsonpath: '{ .spec.ports[?(@.name=="redis")].port }' + // - name: REDIS_PASSWORD + // valueFrom: + // secretKeyRef: + // name: example-renderer-test-redis + // key: redis-password +} + +func fatalOnErr(err error) { + if err != nil { + log.Fatal(err) + } +} diff --git a/components/helm-broker/internal/ybind/renderer_export_test.go b/components/helm-broker/internal/ybind/renderer_export_test.go new file mode 100644 index 000000000000..58d41dc17606 --- /dev/null +++ b/components/helm-broker/internal/ybind/renderer_export_test.go @@ -0,0 +1,8 @@ +package ybind + +func NewRendererWithDeps(renderEngine chartGoTemplateRenderer, toRenderValuesCaps toRenderValuesCaps) *Renderer { + return &Renderer{ + renderEngine: renderEngine, + toRenderValuesCaps: toRenderValuesCaps, + } +} diff --git a/components/helm-broker/internal/ybind/renderer_test.go b/components/helm-broker/internal/ybind/renderer_test.go new file mode 100644 index 000000000000..c48d18226c53 --- /dev/null +++ b/components/helm-broker/internal/ybind/renderer_test.go @@ -0,0 +1,217 @@ +package ybind_test + +import ( + "errors" + "fmt" + "testing" + + google_protobuf "github.com/golang/protobuf/ptypes/timestamp" + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/kyma-project/kyma/components/helm-broker/internal/ybind" + "github.com/kyma-project/kyma/components/helm-broker/internal/ybind/automock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "k8s.io/helm/pkg/chartutil" + "k8s.io/helm/pkg/proto/hapi/chart" + hapi_release5 "k8s.io/helm/pkg/proto/hapi/release" + "k8s.io/helm/pkg/proto/hapi/services" +) + +func TestRenderSuccess(t *testing.T) { + // given + fixResp := fixInstallReleaseResponse(fixChart()) + fixRenderOutFiles := map[string]string{ + fmt.Sprintf("%s/%s", fixChart().Metadata.Name, "bindTmpl"): "rendered-content", + } + tplToRender := internal.BundlePlanBindTemplate("template-body-to-render") + + engineRenderMock := &automock.ChartGoTemplateRenderer{} + defer engineRenderMock.AssertExpectations(t) + engineRenderMock.On("Render", mock.MatchedBy(chartWithTpl(t, tplToRender)), fixChartutilValues()). + Return(fixRenderOutFiles, nil) + + toRenderFake := toRenderValuesFake{t}.WithInputAssertion(fixChart(), fixResp) + renderer := ybind.NewRendererWithDeps(engineRenderMock, toRenderFake) + + // when + out, err := renderer.Render(tplToRender, fixResp) + + // then + require.NoError(t, err) + assert.EqualValues(t, "rendered-content", out) +} + +func TestRenderFailureOnInputParamValidation(t *testing.T) { + for tn, tc := range map[string]struct { + expErrMsg string + givenResp *services.InstallReleaseResponse + }{ + "response is nil": { + expErrMsg: "input parameter 'InstallReleaseResponse' cannot be nil", + givenResp: nil, + }, + "missing Release filed": { + expErrMsg: "'Release' filed from 'InstallReleaseResponse' is missing", + givenResp: func() *services.InstallReleaseResponse { + malformedResp := fixInstallReleaseResponse(fixChart()) + malformedResp.Release = nil + return malformedResp + }(), + }, + "missing Info filed": { + expErrMsg: "'Info' filed from 'InstallReleaseResponse' is missing", + givenResp: func() *services.InstallReleaseResponse { + malformedResp := fixInstallReleaseResponse(fixChart()) + malformedResp.Release.Info = nil + return malformedResp + }(), + }, + "unsupported render engine": { + expErrMsg: "chart \"test-chart\" requested non-existent template engine \"osm-engine\"", + givenResp: func() *services.InstallReleaseResponse { + malformedResp := fixInstallReleaseResponse(fixChart()) + malformedResp.Release.Chart.Metadata.Engine = "osm-engine" + return malformedResp + }(), + }, + } { + t.Run(tn, func(t *testing.T) { + // given + tplToRender := internal.BundlePlanBindTemplate("template-body-to-render") + renderer := ybind.NewRendererWithDeps(nil, nil) + + // when + out, err := renderer.Render(tplToRender, tc.givenResp) + + // then + require.EqualError(t, err, fmt.Sprintf("while validating input: %s", tc.expErrMsg)) + assert.Nil(t, out) + }) + } +} + +func TestRenderFailureOnCreatingToRenderValues(t *testing.T) { + // given + fixErr := errors.New("fix err") + fixResp := fixInstallReleaseResponse(fixChart()) + tplToRender := internal.BundlePlanBindTemplate("template-body-to-render") + + toRenderFake := toRenderValuesFake{t}.WithForcedError(fixErr) + renderer := ybind.NewRendererWithDeps(nil, toRenderFake) + + // when + out, err := renderer.Render(tplToRender, fixResp) + + // then + require.EqualError(t, err, "while merging values to render: fix err") + assert.Nil(t, out) +} + +func TestRenderFailureOnEngineRender(t *testing.T) { + // given + fixResp := fixInstallReleaseResponse(fixChart()) + fixErr := errors.New("fix err") + tplToRender := internal.BundlePlanBindTemplate("template-body-to-render") + + toRenderFake := toRenderValuesFake{t}.WithInputAssertion(fixChart(), fixResp) + + engineRenderMock := &automock.ChartGoTemplateRenderer{} + defer engineRenderMock.AssertExpectations(t) + engineRenderMock.On("Render", mock.MatchedBy(chartWithTpl(t, tplToRender)), fixChartutilValues()). + Return(nil, fixErr) + + renderer := ybind.NewRendererWithDeps(engineRenderMock, toRenderFake) + + // when + out, err := renderer.Render(tplToRender, fixResp) + + // then + assert.EqualError(t, err, fmt.Sprintf("while rendering files: %s", fixErr)) + assert.Nil(t, out) +} + +func TestRenderFailureOnExtractingResolveBindFile(t *testing.T) { + // given + fixResp := fixInstallReleaseResponse(fixChart()) + tplToRender := internal.BundlePlanBindTemplate("template-body-to-render") + + engineRenderMock := &automock.ChartGoTemplateRenderer{} + defer engineRenderMock.AssertExpectations(t) + engineRenderMock.On("Render", mock.MatchedBy(chartWithTpl(t, tplToRender)), fixChartutilValues()). + Return(map[string]string{}, nil) + + toRenderFake := toRenderValuesFake{t}.WithInputAssertion(fixChart(), fixResp) + renderer := ybind.NewRendererWithDeps(engineRenderMock, toRenderFake) + + // when + out, err := renderer.Render(tplToRender, fixResp) + + // then + assert.EqualError(t, err, "bindTmpl file was not resolved after rendering") + assert.Nil(t, out) +} + +func chartWithTpl(t *testing.T, expTpl internal.BundlePlanBindTemplate) func(*chart.Chart) bool { + return func(ch *chart.Chart) bool { + assert.Contains(t, ch.Templates, &chart.Template{Name: "bindTmpl", Data: expTpl}) + return true + } +} + +type toRenderValuesFake struct { + t *testing.T +} + +func (r toRenderValuesFake) WithInputAssertion(expChrt chart.Chart, expResp *services.InstallReleaseResponse) func(*chart.Chart, *chart.Config, chartutil.ReleaseOptions, *chartutil.Capabilities) (chartutil.Values, error) { + return func(chrt *chart.Chart, chrtVals *chart.Config, options chartutil.ReleaseOptions, caps *chartutil.Capabilities) (chartutil.Values, error) { + assert.Equal(r.t, expChrt, *chrt) + assert.Equal(r.t, expResp.Release.Config, chrtVals) + assert.Equal(r.t, chartutil.ReleaseOptions{ + Name: expResp.Release.Name, + Time: expResp.Release.Info.LastDeployed, + Namespace: expResp.Release.Namespace, + Revision: int(expResp.Release.Version), + IsInstall: true, + }, options) + assert.Equal(r.t, &chartutil.Capabilities{}, caps) + return fixChartutilValues(), nil + } +} + +func (r toRenderValuesFake) WithForcedError(err error) func(*chart.Chart, *chart.Config, chartutil.ReleaseOptions, *chartutil.Capabilities) (chartutil.Values, error) { + return func(chrt *chart.Chart, chrtVals *chart.Config, options chartutil.ReleaseOptions, caps *chartutil.Capabilities) (chartutil.Values, error) { + return nil, err + } +} + +func fixChartutilValues() chartutil.Values { + return chartutil.Values{"fix_val_key": "fix_val"} +} + +func fixChart() chart.Chart { + return chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test-chart", + }, + } +} +func fixInstallReleaseResponse(ch chart.Chart) *services.InstallReleaseResponse { + return &services.InstallReleaseResponse{ + Release: &hapi_release5.Release{ + Info: &hapi_release5.Info{ + LastDeployed: &google_protobuf.Timestamp{ + Seconds: 123123123, + Nanos: 1, + }, + }, + Config: &chart.Config{ + Raw: "raw-config", + }, + Name: "test-release", + Namespace: "test-ns", + Version: int32(123), + Chart: &ch, + }, + } +} diff --git a/components/helm-broker/internal/ybind/resolver.go b/components/helm-broker/internal/ybind/resolver.go new file mode 100644 index 000000000000..2cf044ed7adc --- /dev/null +++ b/components/helm-broker/internal/ybind/resolver.go @@ -0,0 +1,196 @@ +package ybind + +import ( + "bytes" + "fmt" + + "github.com/ghodss/yaml" + "github.com/pkg/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + corev1 "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/util/jsonpath" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +// Resolver implements resolver for chart values. +type Resolver struct { + clientCoreV1 corev1.CoreV1Interface +} + +// NewResolver returns new instance of Resolver. +func NewResolver(clientCoreV1 corev1.CoreV1Interface) *Resolver { + return &Resolver{ + clientCoreV1: clientCoreV1, + } +} + +// ResolveOutput represents results of Resolve. +type ResolveOutput struct { + Credentials internal.InstanceCredentials +} + +// Resolve determines the final value of credentials +// +// Resolve policy rules +// 1. When a key exists in multiple sources defined by `credentialFrom` section, then the value associated with the last source will take precedence +// 2. When you duplicate a key in `credential` section then error will be returned +// 3. Values defined by `credentialFrom` section will be overridden by values from `credential` section if keys will be duplicated +func (r *Resolver) Resolve(bindYAML RenderedBindYAML, ns internal.Namespace) (*ResolveOutput, error) { + var bind BindYAML + if err := yaml.Unmarshal(bindYAML, &bind); err != nil { + return nil, errors.Wrap(err, "while unmarshaling bind yaml") + } + + credFrom := credentials{} + for _, v := range bind.CredentialFrom { + envs, err := r.getCredFromAllRefValues(ns, v) + if err != nil { + return nil, err + } + //Policy no. 1: Use Set method to allow override value. + for k, v := range envs { + credFrom.Set(k, v) + } + } + + cred := credentials{} + for _, v := range bind.Credential { + if v.Value != "" { + // Policy no. 2: Use Insert method to return error on duplication. + if err := cred.Insert(v.Name, v.Value); err != nil { + return nil, err + } + } else if v.ValueFrom != nil { + val, err := r.getCredVarKeyRefValue(ns, *v.ValueFrom) + if err != nil { + return nil, err + } + // Policy no. 2: Use Insert method to return error on duplication. + if err := cred.Insert(v.Name, val); err != nil { + return nil, err + } + } + } + + // Policy no. 3: Merge `credential` vars to `credentialFrom` vars to allow replace existing keys. + for k, v := range cred { + credFrom.Set(k, v) + } + + return &ResolveOutput{ + Credentials: internal.InstanceCredentials(credFrom), + }, nil +} + +// getCredFromAllRefValues returns the key-value pairs referenced by the given CredentialFromSource in the supplied namespace +func (r *Resolver) getCredFromAllRefValues(ns internal.Namespace, from CredentialFromSource) (map[string]string, error) { + if from.ConfigMapRef != nil { + return getConfigMapAllValues(r.clientCoreV1, ns, *from.ConfigMapRef) + } + + if from.SecretRef != nil { + return getSecretAllValues(r.clientCoreV1, ns, *from.SecretRef) + } + + return map[string]string{}, fmt.Errorf("invalid credentialFrom") +} + +// getCredVarKeyRefValue returns the value referenced by the given CredentialVarSource in the supplied namespace +func (r *Resolver) getCredVarKeyRefValue(ns internal.Namespace, from CredentialVarSource) (string, error) { + if from.SecretKeyRef != nil { + return getSecretKeyValue(r.clientCoreV1, ns, *from.SecretKeyRef) + } + + if from.ConfigMapKeyRef != nil { + return getConfigMapKeyValue(r.clientCoreV1, ns, *from.ConfigMapKeyRef) + } + + if from.ServiceRef != nil { + return getServiceJSONPathValue(r.clientCoreV1, ns, *from.ServiceRef) + } + return "", fmt.Errorf("invalid valueFrom") +} + +// getSecretAllValues returns key-value pairs populated from secret in the supplied namespace +func getSecretAllValues(client corev1.CoreV1Interface, namespace internal.Namespace, secretSelector NameSelector) (map[string]string, error) { + secret, err := client.Secrets(string(namespace)).Get(secretSelector.Name, metav1.GetOptions{}) + if err != nil { + return map[string]string{}, errors.Wrapf(err, "while getting secrets [%s] from namespace [%s]", secretSelector.Name, namespace) + } + + envs := map[string]string{} + for k, v := range secret.Data { + envs[k] = string(v) + } + + return envs, nil +} + +// getConfigMapAllValues returns key-value pairs populated from configmap in the supplied namespace +func getConfigMapAllValues(client corev1.CoreV1Interface, namespace internal.Namespace, configMapSelector NameSelector) (map[string]string, error) { + configMap, err := client.ConfigMaps(string(namespace)).Get(configMapSelector.Name, metav1.GetOptions{}) + if err != nil { + return map[string]string{}, errors.Wrapf(err, "while getting configmap [%s] from namespace [%s]", configMapSelector.Name, namespace) + } + + return configMap.Data, nil +} + +// getSecretKeyValue returns the value of a secret in the supplied namespace +func getSecretKeyValue(client corev1.CoreV1Interface, namespace internal.Namespace, secretSelector KeySelector) (string, error) { + secret, err := client.Secrets(string(namespace)).Get(secretSelector.Name, metav1.GetOptions{}) + if err != nil { + return "", errors.Wrapf(err, "while getting secrets [%s] from namespace [%s]", secretSelector.Name, namespace) + } + + data, ok := secret.Data[secretSelector.Key] + if !ok { + return "", fmt.Errorf("key %s not found in secret %s in namespace %s", secretSelector.Key, secretSelector.Name, namespace) + } + + return string(data), nil +} + +// getConfigMapKeyValue returns the value of a configmap in the supplied namespace +func getConfigMapKeyValue(client corev1.CoreV1Interface, namespace internal.Namespace, configMapSelector KeySelector) (string, error) { + configMap, err := client.ConfigMaps(string(namespace)).Get(configMapSelector.Name, metav1.GetOptions{}) + if err != nil { + return "", errors.Wrapf(err, "while getting configmap [%s] from namespace [%s]", configMapSelector.Name, namespace) + } + data, ok := configMap.Data[configMapSelector.Key] + if !ok { + return "", fmt.Errorf("key %s not found in config map %s in namespace %s", configMapSelector.Key, configMapSelector.Name, namespace) + } + return string(data), nil +} + +func getServiceJSONPathValue(kubeClient corev1.CoreV1Interface, namespace internal.Namespace, selector JSONPathSelector) (string, error) { + service, err := kubeClient.Services(string(namespace)).Get(selector.Name, metav1.GetOptions{}) + if err != nil { + return "", errors.Wrapf(err, "while getting service [%s] from namespace [%s]", selector.Name, namespace) + } + pathParser := jsonpath.New("service pathParser parser") + if err := pathParser.Parse(selector.JSONPath); err != nil { + return "", errors.Wrapf(err, "while parsing json path [%s] for service [%s] in namespace [%s]", selector.JSONPath, selector.Name, namespace) + } + out := bytes.Buffer{} + if err := pathParser.Execute(&out, service); err != nil { + return "", errors.Wrapf(err, "while selecting json path [%s] for service [%s] in namespace [%s]", selector.JSONPath, selector.Name, namespace) + } + return out.String(), nil +} + +type credentials map[string]string + +func (e *credentials) Insert(name, value string) error { + if _, exists := (*e)[name]; exists { + return fmt.Errorf("conflict: found credentials with the same name %q", name) + } + (*e)[name] = value + return nil +} + +func (e *credentials) Set(name, value string) { + (*e)[name] = value +} diff --git a/components/helm-broker/internal/ybind/resolver_example_test.go b/components/helm-broker/internal/ybind/resolver_example_test.go new file mode 100644 index 000000000000..40015f337130 --- /dev/null +++ b/components/helm-broker/internal/ybind/resolver_example_test.go @@ -0,0 +1,157 @@ +//+build integration + +package ybind_test + +import ( + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/kyma-project/kyma/components/helm-broker/internal/ybind" + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" +) + +func ExampleNewResolver() { + // use the current context in kubeconfig + config, err := clientcmd.BuildConfigFromFlags("", filepath.Join(os.Getenv("HOME"), ".kube", "config")) + fatalOnErr(err) + + // create the clientset + clientset, err := kubernetes.NewForConfig(config) + fatalOnErr(err) + + // create namespace for test + nsSpec := &v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "resolver-example-test"}} + _, err = clientset.CoreV1().Namespaces().Create(nsSpec) + defer clientset.CoreV1().Namespaces().Delete(nsSpec.ObjectMeta.Name, &metav1.DeleteOptions{}) + fatalOnErr(err) + + _, err = clientset.CoreV1().Secrets(nsSpec.Name).Create(&v1.Secret{ + Type: v1.SecretTypeOpaque, + ObjectMeta: metav1.ObjectMeta{ + Name: "single-secret-test-redis", + }, + StringData: map[string]string{ + // The serialized form of the secret data is a base64 encoded string, so we need to pass here raw data + "redis-password": "gopherek", + }, + }) + fatalOnErr(err) + + _, err = clientset.CoreV1().Secrets(nsSpec.Name).Create(&v1.Secret{ + Type: v1.SecretTypeOpaque, + ObjectMeta: metav1.ObjectMeta{ + Name: "all-secret-test-redis", + }, + StringData: map[string]string{ + // The serialized form of the secret data is a base64 encoded string, so we need to pass here raw data + "secret-key-no-1": "piko", + "secret-key-no-2": "bello", + }, + }) + fatalOnErr(err) + + _, err = clientset.CoreV1().ConfigMaps(nsSpec.Name).Create(&v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "single-cfg-map-test-redis", + }, + Data: map[string]string{ + "username": "redisMaster", + }, + }) + fatalOnErr(err) + + _, err = clientset.CoreV1().ConfigMaps(nsSpec.Name).Create(&v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "all-cfg-map-test-redis", + }, + Data: map[string]string{ + "cfg-key-no-1": "margarita", + "cfg-key-no-2": "capricciosa", + }, + }) + fatalOnErr(err) + + _, err = clientset.CoreV1().Services(nsSpec.Name).Create(&v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example-renderer-test-redis", + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeNodePort, + Selector: map[string]string{"app": "some-app"}, + Ports: []v1.ServicePort{ + { + Name: "redis", + Port: 123, + }, + }, + }, + }) + + fatalOnErr(err) + + resolver := ybind.NewResolver(clientset.CoreV1()) + out, err := resolver.Resolve(fixBindYAML(), internal.Namespace(nsSpec.Name)) + fatalOnErr(err) + + printSorted(out.Credentials) + + // Output: + // key: HOST_PORT, value: 123 + // key: REDIS_PASSWORD, value: gopherek + // key: REDIS_USERNAME, value: redisMaster + // key: URL, value: host1-example-renderer-test-redis.ns-name.svc.cluster.local:6379 + // key: cfg-key-no-1, value: override-value + // key: cfg-key-no-2, value: capricciosa + // key: secret-key-no-1, value: piko + // key: secret-key-no-2, value: bello +} + +func printSorted(m map[string]string) { + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + + for _, key := range keys { + fmt.Printf("key: %s, value: %s\n", key, m[key]) + } + +} + +func fixBindYAML() []byte { + return []byte(` +credential: + - name: cfg-key-no-1 + value: override-value + - name: URL + value: host1-example-renderer-test-redis.ns-name.svc.cluster.local:6379 + - name: HOST_PORT + valueFrom: + serviceRef: + name: example-renderer-test-redis + jsonpath: '{ .spec.ports[?(@.name=="redis")].port }' + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: single-secret-test-redis + key: redis-password + - name: REDIS_USERNAME + valueFrom: + configMapKeyRef: + name: single-cfg-map-test-redis + key: username + +credentialFrom: + - configMapRef: + name: all-cfg-map-test-redis + - secretRef: + name: all-secret-test-redis +`) +} diff --git a/components/helm-broker/internal/ybind/resolver_test.go b/components/helm-broker/internal/ybind/resolver_test.go new file mode 100644 index 000000000000..cb019974bcce --- /dev/null +++ b/components/helm-broker/internal/ybind/resolver_test.go @@ -0,0 +1,191 @@ +package ybind_test + +import ( + "fmt" + "testing" + + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/kyma-project/kyma/components/helm-broker/internal/ybind" + "github.com/renstrom/dedent" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +// Policy #1: When a key exists in multiple sources defined by `credentialFrom` section, then the value associated with the last source will take precedence +func TestResolvePolicyNo1(t *testing.T) { + const ( + configName, secretName = "all-cfg-map-test-redis", "all-secret-test-redis" + keyNo1, keyNo2, keyNo3 = "keyNo1", "keyNo2", "keyNo3" + namespace = "test-ns" + ) + + type given struct { + configData configMapData + secretData secretData + bindYAML string + } + type expected struct { + credentials internal.InstanceCredentials + } + for tn, tc := range map[string]struct { + given + expected + }{ + "secret overrides configMap values": { + given: given{ + configData: configMapData{keyNo1: "key_1_cfg_val", keyNo2: "key_2_cfg_val"}, + secretData: secretData{keyNo1: []byte("key_1_secret_val"), keyNo3: []byte("key_3_secret_val")}, + bindYAML: dedent.Dedent(` + credentialFrom: + - configMapRef: + name: ` + configName + ` + - secretRef: + name: ` + secretName), + }, + expected: expected{ + credentials: internal.InstanceCredentials{ + keyNo1: "key_1_secret_val", + keyNo2: "key_2_cfg_val", + keyNo3: "key_3_secret_val", + }, + }, + }, + "configMap overrides secret values": { + given: given{ + configData: configMapData{keyNo1: "key_1_cfg_val", keyNo2: "key_2_cfg_val"}, + secretData: secretData{keyNo1: []byte("key_1_secret_val"), keyNo3: []byte("key_3_secret_val")}, + bindYAML: dedent.Dedent(` + credentialFrom: + - secretRef: + name: ` + secretName + ` + - configMapRef: + name: ` + configName), + }, + expected: expected{ + credentials: internal.InstanceCredentials{ + keyNo1: "key_1_cfg_val", + keyNo2: "key_2_cfg_val", + keyNo3: "key_3_secret_val", + }, + }, + }, + } { + t.Run(tn, func(t *testing.T) { + // given + ts := newResolverTestSuit() + var ( + configMap = ts.configMap(namespace, "all-cfg-map-test-redis", tc.given.configData) + secret = ts.secret(namespace, "all-secret-test-redis", tc.given.secretData) + fakeClient = fake.NewSimpleClientset(&configMap, &secret) + resolver = ybind.NewResolver(fakeClient.CoreV1()) + ) + + // when + out, err := resolver.Resolve(ybind.RenderedBindYAML(tc.given.bindYAML), internal.Namespace(namespace)) + + // then + require.NoError(t, err) + assert.EqualValues(t, tc.expected.credentials, out.Credentials) + assert.Len(t, fakeClient.Actions(), 2) + }) + } +} + +// Policy #2: When you duplicate a key in `credential` section then error will be returned +func TestResolvePolicyNo2(t *testing.T) { + // given + const ( + keyNo1 = "keyNo1" + namespace = "test-ns" + ) + + bindYAML := dedent.Dedent(` + credential: + - name: ` + keyNo1 + ` + value: duplicated-value + - name: ` + keyNo1 + ` + value: duplicated-value`) + resolver := ybind.NewResolver(nil) + + // when + out, err := resolver.Resolve(ybind.RenderedBindYAML(bindYAML), internal.Namespace(namespace)) + + // then + assert.EqualError(t, err, fmt.Sprintf("conflict: found credentials with the same name %q", keyNo1)) + assert.Nil(t, out) +} + +// Policy #3: Values defined by `credentialFrom` section will be overridden by values from `credential` section if keys will be duplicated +func TestResolvePolicyNo3(t *testing.T) { + // given + const ( + configName, secretName = "all-cfg-map-test-redis", "all-secret-test-redis" + keyNo1, keyNo2, keyNo3 = "keyNo1", "keyNo2", "keyNo3" + namespace = "test-ns" + ) + + ts := newResolverTestSuit() + var ( + configData = configMapData{keyNo1: "key_1_cfg_val", keyNo2: "key_2_cfg_val"} + secretData = secretData{keyNo1: []byte("key_1_secret_val"), keyNo3: []byte("key_3_secret_val")} + bindYAML = dedent.Dedent(` + credential: + - name: ` + keyNo1 + ` + value: override-value + credentialFrom: + - configMapRef: + name: ` + configName + ` + - secretRef: + name: ` + secretName) + credentials = internal.InstanceCredentials{ + keyNo1: "override-value", + keyNo2: "key_2_cfg_val", + keyNo3: "key_3_secret_val", + } + configMap = ts.configMap(namespace, "all-cfg-map-test-redis", configData) + secret = ts.secret(namespace, "all-secret-test-redis", secretData) + fakeClient = fake.NewSimpleClientset(&configMap, &secret) + resolver = ybind.NewResolver(fakeClient.CoreV1()) + ) + + // when + out, err := resolver.Resolve(ybind.RenderedBindYAML(bindYAML), internal.Namespace(namespace)) + + // then + require.NoError(t, err) + assert.EqualValues(t, credentials, out.Credentials) + assert.Len(t, fakeClient.Actions(), 2) +} + +type resolverServiceTestSuit struct{} + +func newResolverTestSuit() *resolverServiceTestSuit { + return &resolverServiceTestSuit{} +} + +type configMapData map[string]string + +func (*resolverServiceTestSuit) configMap(namespace, name string, data configMapData) v1.ConfigMap { + return v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + Data: data, + } +} + +type secretData map[string][]byte + +func (*resolverServiceTestSuit) secret(namespace, name string, data secretData) v1.Secret { + return v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + Data: data, + } +} diff --git a/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/.helmignore b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/.helmignore new file mode 100644 index 000000000000..6b8710a711f3 --- /dev/null +++ b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/.helmignore @@ -0,0 +1 @@ +.git diff --git a/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/Chart.yaml b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/Chart.yaml new file mode 100644 index 000000000000..4159b7dcdb21 --- /dev/null +++ b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/Chart.yaml @@ -0,0 +1,16 @@ +name: redis +version: 0.10.1 +appVersion: 3.2.9 +description: Open source, advanced key-value store. It is often referred to as a data structure server since keys can contain strings, hashes, lists, sets and sorted sets. +keywords: +- redis +- keyvalue +- database +home: http://redis.io/ +icon: https://bitnami.com/assets/stacks/redis/img/redis-stack-220x234.png +sources: +- https://github.com/bitnami/bitnami-docker-redis +maintainers: +- name: bitnami-bot + email: containers@bitnami.com +engine: gotpl diff --git a/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/README.md b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/README.md new file mode 100644 index 000000000000..a92fe50db3a3 --- /dev/null +++ b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/README.md @@ -0,0 +1,120 @@ +# Redis + +[Redis](http://redis.io/) is an advanced key-value cache and store. It is often referred to as a data structure server since keys can contain strings, hashes, lists, sets, sorted sets, bitmaps and hyperloglogs. + +## TL;DR; + +```bash +$ helm install stable/redis +``` + +## Introduction + +This chart bootstraps a [Redis](https://github.com/bitnami/bitnami-docker-redis) deployment on a [Kubernetes](http://kubernetes.io) cluster using the [Helm](https://helm.sh) package manager. + +## Prerequisites + +- Kubernetes 1.4+ with Beta APIs enabled +- PV provisioner support in the underlying infrastructure + +## Installing the Chart + +To install the chart with the release name `my-release`: + +```bash +$ helm install --name my-release stable/redis +``` + +The command deploys Redis on the Kubernetes cluster in the default configuration. The [configuration](#configuration) section lists the parameters that can be configured during installation. + +> **Tip**: List all releases using `helm list` + +## Uninstalling the Chart + +To uninstall/delete the `my-release` deployment: + +```bash +$ helm delete my-release +``` + +The command removes all the Kubernetes components associated with the chart and deletes the release. + +## Configuration + +The following tables lists the configurable parameters of the Redis chart and their default values. + +| Parameter | Description | Default | +| -------------------------- | ------------------------------------- | --------------------------------------------------------- | +| `image` | Redis image | `bitnami/redis:{VERSION}` | +| `imagePullPolicy` | Image pull policy | `IfNotPresent` | +| `usePassword` | Use password | `true` | +| `redisPassword` | Redis password | Randomly generated | +| `args` | Redis command-line args | [] | +| `persistence.enabled` | Use a PVC to persist data | `true` | +| `persistence.existingClaim`| Use an existing PVC to persist data | `nil` | +| `persistence.storageClass` | Storage class of backing PVC | `generic` | +| `persistence.accessMode` | Use volume as ReadOnly or ReadWrite | `ReadWriteOnce` | +| `persistence.size` | Size of data volume | `8Gi` | +| `resources` | CPU/Memory resource requests/limits | Memory: `256Mi`, CPU: `100m` | +| `metrics.enabled` | Start a side-car prometheus exporter | `false` | +| `metrics.image` | Exporter image | `oliver006/redis_exporter` | +| `metrics.imageTag` | Exporter image | `v0.11` | +| `metrics.imagePullPolicy` | Exporter image pull policy | `IfNotPresent` | +| `metrics.resources` | Exporter resource requests/limit | Memory: `256Mi`, CPU: `100m` | +| `nodeSelector` | Node labels for pod assignment | {} | +| `tolerations` | Toleration labels for pod assignment | [] | +| `networkPolicy.enabled` | Enable NetworkPolicy | `false` | +| `networkPolicy.allowExternal` | Don't require client label for connections | `true` | + +The above parameters map to the env variables defined in [bitnami/redis](http://github.com/bitnami/bitnami-docker-redis). For more information please refer to the [bitnami/redis](http://github.com/bitnami/bitnami-docker-redis) image documentation. + +Specify each parameter using the `--set key=value[,key=value]` argument to `helm install`. For example, + +```bash +$ helm install --name my-release \ + --set redisPassword=secretpassword \ + stable/redis +``` + +The above command sets the Redis server password to `secretpassword`. + +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/redis +``` + +> **Tip**: You can use the default [values.yaml](values.yaml) + +## NetworkPolicy + +To enable network policy for Redis, install +[a networking plugin that implements the Kubernetes NetworkPolicy spec](https://kubernetes.io/docs/tasks/administer-cluster/declare-network-policy#before-you-begin), +and set `networkPolicy.enabled` to `true`. + +For Kubernetes v1.5 & v1.6, you must also turn on NetworkPolicy by setting +the DefaultDeny namespace annotation. Note: this will enforce policy for _all_ pods in the namespace: + + kubectl annotate namespace default "net.beta.kubernetes.io/network-policy={\"ingress\":{\"isolation\":\"DefaultDeny\"}}" + +With NetworkPolicy enabled, only pods with the generated client label will be +able to connect to Redis. This label will be displayed in the output +after a successful install. + +## Persistence + +The [Bitnami Redis](https://github.com/bitnami/bitnami-docker-redis) image stores the Redis data and configurations at the `/bitnami/redis` path of the container. + +By default, the chart mounts a [Persistent Volume](http://kubernetes.io/docs/user-guide/persistent-volumes/) volume at this location. The volume is created using dynamic volume provisioning. If a Persistent Volume Claim already exists, specify it during installation. + +### Existing PersistentVolumeClaim + +1. Create the PersistentVolume +1. Create the PersistentVolumeClaim +1. Install the chart +```bash +$ helm install --set persistence.existingClaim=PVC_NAME redis +``` + +## Metrics +The chart optionally can start a metrics exporter for [prometheus](https://prometheus.io). The metrics endpoint (port 9121) is not exposed and it is expected that the metrics are collected from inside the k8s cluster using something similar as the described in the [example Prometheus scrape configuration](https://github.com/prometheus/prometheus/blob/master/documentation/examples/prometheus-kubernetes.yml). diff --git a/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/NOTES.txt b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/NOTES.txt new file mode 100644 index 000000000000..1110b6ba8b49 --- /dev/null +++ b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/NOTES.txt @@ -0,0 +1,28 @@ +Redis can be accessed via port 6379 on the following DNS name from within your cluster: +{{ template "redis.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local + +{{- if .Values.usePassword }} +To get your password run: + + REDIS_PASSWORD=$(kubectl get secret --namespace {{ .Release.Namespace }} {{ template "redis.fullname" . }} -o jsonpath="{.data.redis-password}" | base64 --decode) +{{- end }} + +To connect to your Redis server: + +1. Run a Redis pod that you can use as a client: + + kubectl run {{ template "redis.fullname" . }}-client --rm --tty -i \ + {{ if .Values.usePassword }} --env REDIS_PASSWORD=$REDIS_PASSWORD{{ end }} + {{- if and (.Values.networkPolicy.enabled) (not .Values.networkPolicy.allowExternal) }}--labels="{{ template "redis.fullname" . }}-client=true" \{{- end }} + --image {{ .Values.image }} -- bash + +2. Connect using the Redis CLI: + + redis-cli -h {{ template "redis.fullname" . }}{{ if .Values.usePassword }} -a $REDIS_PASSWORD{{ end }} + +{{ if and (.Values.networkPolicy.enabled) (not .Values.networkPolicy.allowExternal) }} +Note: Since NetworkPolicy is enabled, only pods with label +{{ template "redis.fullname" . }}-client=true" +will be able to connect to redis. +{{- end }} + diff --git a/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/_helpers.tpl b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/_helpers.tpl new file mode 100644 index 000000000000..f96369929fa2 --- /dev/null +++ b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/_helpers.tpl @@ -0,0 +1,27 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "redis.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). +*/}} +{{- define "redis.fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Return the appropriate apiVersion for networkpolicy. +*/}} +{{- define "networkPolicy.apiVersion" -}} +{{- if and (ge .Capabilities.KubeVersion.Minor "4") (le .Capabilities.KubeVersion.Minor "6") -}} +{{- print "extensions/v1beta1" -}} +{{- else if ge .Capabilities.KubeVersion.Minor "7" -}} +{{- print "networking.k8s.io/v1" -}} +{{- end -}} +{{- end -}} \ No newline at end of file diff --git a/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/deployment.yaml b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/deployment.yaml new file mode 100644 index 000000000000..cf272dab9dad --- /dev/null +++ b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/deployment.yaml @@ -0,0 +1,96 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: {{ template "redis.fullname" . }} + labels: + app: {{ template "redis.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +spec: + replicas: 1 + strategy: + rollingUpdate: + maxUnavailable: 0 + template: + metadata: + labels: + app: {{ template "redis.fullname" . }} + spec: + {{- if .Values.nodeSelector }} + nodeSelector: +{{ toYaml .Values.nodeSelector | indent 8 }} + {{- end }} + {{- if .Values.tolerations }} + tolerations: +{{ toYaml .Values.tolerations | indent 8 }} + {{- end }} + containers: + - name: {{ template "redis.fullname" . }} + image: "{{ .Values.image }}" + imagePullPolicy: {{ default "" .Values.imagePullPolicy | quote }} + {{- if .Values.args }} + args: +{{ toYaml .Values.args | indent 10 }} + {{- end }} + env: + {{- if .Values.usePassword }} + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "redis.fullname" . }} + key: redis-password + {{- else }} + - name: ALLOW_EMPTY_PASSWORD + value: "yes" + {{- end }} + ports: + - name: redis + containerPort: 6379 + livenessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 30 + timeoutSeconds: 5 + readinessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 5 + timeoutSeconds: 1 + resources: +{{ toYaml .Values.resources | indent 10 }} + volumeMounts: + - name: redis-data + mountPath: /bitnami/redis +{{- if .Values.metrics.enabled }} + - name: metrics + image: "{{ .Values.metrics.image }}:{{ .Values.metrics.imageTag }}" + imagePullPolicy: {{ .Values.metrics.imagePullPolicy | quote }} + env: + - name: REDIS_ALIAS + value: {{ template "redis.fullname" . }} + {{- if .Values.usePassword }} + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "redis.fullname" . }} + key: redis-password + {{- end }} + ports: + - name: metrics + containerPort: 9121 + resources: +{{ toYaml .Values.metrics.resources | indent 10 }} +{{- end }} + volumes: + - name: redis-data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ .Values.persistence.existingClaim | default (include "redis.fullname" .) }} + {{- else }} + emptyDir: {} + {{- end -}} diff --git a/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/networkpolicy.yaml b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/networkpolicy.yaml new file mode 100644 index 000000000000..eb6640f073ac --- /dev/null +++ b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/networkpolicy.yaml @@ -0,0 +1,30 @@ +{{- if .Values.networkPolicy.enabled }} +kind: NetworkPolicy +apiVersion: {{ template "networkPolicy.apiVersion" . }} +metadata: + name: "{{ template "redis.fullname" . }}" + labels: + app: {{ template "redis.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +spec: + podSelector: + matchLabels: + app: {{ template "redis.fullname" . }} + ingress: + # Allow inbound connections + - ports: + - port: 6379 + {{- if not .Values.networkPolicy.allowExternal }} + from: + - podSelector: + matchLabels: + {{ template "redis.fullname" . }}-client: "true" + {{- end }} + {{- if .Values.metrics.enabled }} + # Allow prometheus scrapes for metrics + - ports: + - port: 9121 + {{- end }} +{{- end }} diff --git a/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/pvc.yaml b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/pvc.yaml new file mode 100644 index 000000000000..27de14b46d64 --- /dev/null +++ b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/pvc.yaml @@ -0,0 +1,24 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +kind: PersistentVolumeClaim +apiVersion: v1 +metadata: + name: {{ template "redis.fullname" . }} + labels: + app: {{ template "redis.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +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/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/secrets.yaml b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/secrets.yaml new file mode 100644 index 000000000000..6ab6b053e52c --- /dev/null +++ b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/secrets.yaml @@ -0,0 +1,18 @@ +{{- if .Values.usePassword -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ template "redis.fullname" . }} + labels: + app: {{ template "redis.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +type: Opaque +data: + {{- if .Values.redisPassword }} + redis-password: {{ .Values.redisPassword | b64enc | quote }} + {{- else }} + redis-password: {{ randAlphaNum 10 | b64enc | quote }} + {{- end }} +{{- end -}} diff --git a/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/svc.yaml b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/svc.yaml new file mode 100644 index 000000000000..5081e1ebae5b --- /dev/null +++ b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/svc.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ template "redis.fullname" . }} + labels: + app: {{ template "redis.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +{{- if .Values.metrics.enabled }} + annotations: +{{ toYaml .Values.metrics.annotations | indent 4 }} +{{- end }} +spec: + ports: + - name: redis + port: 6379 + targetPort: redis + selector: + app: {{ template "redis.fullname" . }} diff --git a/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/tests/test-redis-connection.yaml b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/tests/test-redis-connection.yaml new file mode 100644 index 000000000000..8f1da8c1108b --- /dev/null +++ b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/templates/tests/test-redis-connection.yaml @@ -0,0 +1,28 @@ +{{- $name := printf "test-connection-%s" .Release.Name | trunc 63 }} +{{- $host := printf "%s.%s.svc.cluster.local" (include "redis.fullname" .) .Release.Namespace }} +apiVersion: v1 +kind: Pod +metadata: + name: {{ $name }} + annotations: + "helm.sh/hook": test-success + labels: + "helm-chart-test": "true" +spec: + containers: + - name: {{ $name }} + image: "redis:3.2-alpine" + env: + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "redis.fullname" . }} + key: redis-password + command: ["redis-cli"] + args: [ + "-h", "{{ $host }}", + "-p", "6379", + "-a", "$(REDIS_PASSWORD)", + "ping" + ] + restartPolicy: Never diff --git a/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/values.yaml b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/values.yaml new file mode 100644 index 000000000000..1bc35d537907 --- /dev/null +++ b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/chart/redis/values.yaml @@ -0,0 +1,86 @@ +## Bitnami Redis image version +## ref: https://hub.docker.com/r/bitnami/redis/tags/ +## +image: bitnami/redis:3.2.9-r2 + +## Specify a imagePullPolicy +## ref: http://kubernetes.io/docs/user-guide/images/#pre-pulling-images +## +imagePullPolicy: IfNotPresent + +## Use password authentication +usePassword: true + +## Redis password +## Defaults to a random 10-character alphanumeric string if not set and usePassword is true +## ref: https://github.com/bitnami/bitnami-docker-redis#setting-the-server-password-on-first-run +## +# redisPassword: + +## Redis command arguments +## +## Can be used to specify command line arguments, for example: +## +## args: +## - "redis-server" +## - "--maxmemory-policy volatile-ttl" +args: + +## Enable persistence using Persistent Volume Claims +## ref: http://kubernetes.io/docs/user-guide/persistent-volumes/ +## +persistence: + enabled: true + + ## A manually managed Persistent Volume and Claim + ## Requires persistence.enabled: true + ## If defined, PVC must be created manually before volume will be bound + # existingClaim: + + ## redis 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 + +metrics: + enabled: false + image: oliver006/redis_exporter + imageTag: v0.11 + imagePullPolicy: IfNotPresent + resources: {} + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9121" + +## Configure resource requests and limits +## ref: http://kubernetes.io/docs/user-guide/compute-resources/ +## +resources: + requests: + memory: 256Mi + cpu: 100m + +## Node labels and tolerations for pod assignment +## ref: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#nodeselector +## ref: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#taints-and-tolerations-beta-feature +nodeSelector: {} +tolerations: [] + +networkPolicy: + ## Enable creation of NetworkPolicy resources. + ## + enabled: false + + ## The Policy model to apply. When set to false, only pods with the correct + ## client label will have network access to the port PostgreSQL is listening + ## on. When true, Redis will accept connections from any source + ## (with the correct destination port). + ## + allowExternal: true + diff --git a/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/meta.yaml b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/meta.yaml new file mode 100644 index 000000000000..912a774cbaa3 --- /dev/null +++ b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/meta.yaml @@ -0,0 +1,13 @@ +name: redis +version: 0.0.3 +id: a2257daa-0e26-4c61-a68d-8a7453c1b767 +description: "Redis package" +displayName: Redis + +tags: database, cache +providerDisplayName: bitnami +longDescription: Redis is an advanced key-value cache and store +documentationURL: https://github.com/bitnami/bitnami-docker-redis +supportURL: http://slack.oss.bitnami.com/ +imageURL: https://upload.wikimedia.org/wikipedia/en/6/6b/Redis_Logo.svg +bindable: true diff --git a/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/enterprise/bind.yaml b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/enterprise/bind.yaml new file mode 100644 index 000000000000..5e154ed2d68b --- /dev/null +++ b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/enterprise/bind.yaml @@ -0,0 +1,13 @@ +credential: + - name: HOST + value: {{ template "redis.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local + - name: PORT + valueFrom: + serviceRef: + name: {{ template "redis.fullname" . }} + jsonpath: '{ .spec.ports[?(@.name=="redis")].port }' + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "redis.fullname" . }} + key: redis-password diff --git a/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/enterprise/create-instance-schema.json b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/enterprise/create-instance-schema.json new file mode 100644 index 000000000000..0787ac7e8aa3 --- /dev/null +++ b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/enterprise/create-instance-schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "redisPassword": { + "type": "string", + "description": "Redis password.", + "default": "Defaults to a random 10-character alphanumeric string" + }, + "imagePullPolicy": { + "type": "string", + "enum": ["Always", "IfNotPresent", "Never"], + "default": "IfNotPresent" + } + } +} \ No newline at end of file diff --git a/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/enterprise/meta.yaml b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/enterprise/meta.yaml new file mode 100644 index 000000000000..dbdc52981c77 --- /dev/null +++ b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/enterprise/meta.yaml @@ -0,0 +1,6 @@ +name: enterprise +id: a6078798-70a1-4674-af90-aba364dd6a56 +description: "Enterprise plan" +displayName: Enterprise + +bindable: true \ No newline at end of file diff --git a/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/enterprise/values.yaml b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/enterprise/values.yaml new file mode 100644 index 000000000000..0815ef636f56 --- /dev/null +++ b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/enterprise/values.yaml @@ -0,0 +1,7 @@ +## Configure resource requests and limits +## ref: http://kubernetes.io/docs/user-guide/compute-resources/ +## +resources: + requests: + memory: 512Mi + cpu: 200m diff --git a/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/micro/bind.yaml b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/micro/bind.yaml new file mode 100644 index 000000000000..21e64f5c010f --- /dev/null +++ b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/micro/bind.yaml @@ -0,0 +1,15 @@ +credential: + - name: HOST + value: {{ template "redis.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local + - name: PORT + valueFrom: + serviceRef: + name: {{ template "redis.fullname" . }} + jsonpath: '{ .spec.ports[?(@.name=="redis")].port }' +{{- if .Values.usePassword }} + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "redis.fullname" . }} + key: redis-password +{{- end }} diff --git a/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/micro/create-instance-schema.json b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/micro/create-instance-schema.json new file mode 100644 index 000000000000..0787ac7e8aa3 --- /dev/null +++ b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/micro/create-instance-schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "redisPassword": { + "type": "string", + "description": "Redis password.", + "default": "Defaults to a random 10-character alphanumeric string" + }, + "imagePullPolicy": { + "type": "string", + "enum": ["Always", "IfNotPresent", "Never"], + "default": "IfNotPresent" + } + } +} \ No newline at end of file diff --git a/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/micro/meta.yaml b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/micro/meta.yaml new file mode 100644 index 000000000000..c459e43ebd09 --- /dev/null +++ b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/micro/meta.yaml @@ -0,0 +1,6 @@ +name: micro +id: a6078798-70a1-4674-af94-ab9664d36a54 +description: "Micro plan" +displayName: Micro + +bindable: true \ No newline at end of file diff --git a/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/micro/values.yaml b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/micro/values.yaml new file mode 100644 index 000000000000..1a0694f7bd58 --- /dev/null +++ b/components/helm-broker/internal/ybind/testdata/repository/redis-0.0.3/plans/micro/values.yaml @@ -0,0 +1,7 @@ +## Configure resource requests and limits +## ref: http://kubernetes.io/docs/user-guide/compute-resources/ +## +resources: + requests: + memory: 128Mi + cpu: 50m diff --git a/components/helm-broker/internal/ybind/types.go b/components/helm-broker/internal/ybind/types.go new file mode 100644 index 000000000000..49ab5cb3d15c --- /dev/null +++ b/components/helm-broker/internal/ybind/types.go @@ -0,0 +1,64 @@ +package ybind + +// BindYAML represents a yBundle plan bind.yaml structure +type BindYAML struct { + Credential []CredentialVar `json:"credential"` + CredentialFrom []CredentialFromSource `json:"credentialFrom"` +} + +// CredentialVar represents an credential variable. +type CredentialVar struct { + // Required + Name string `json:"name"` + // Optional: no more than one of the following may be specified. + Value string `json:"value"` + // Optional: Specifies a source the value of this var should come from. + ValueFrom *CredentialVarSource `json:"valueFrom,omitempty"` +} + +// CredentialVarSource represents a source for the value of an CredentialVar. +// ONLY ONE of its fields may be set. +type CredentialVarSource struct { + // Selects a key of a ConfigMap. + // +optional + ConfigMapKeyRef *KeySelector `json:"configMapKeyRef,omitempty"` + // Selects a key of a Secret in the helm release namespace. + // +optional + SecretKeyRef *KeySelector `json:"secretKeyRef,omitempty"` + // Selects a field from a Service. + // +optional + ServiceRef *JSONPathSelector `json:"serviceRef,omitempty"` +} + +// KeySelector selects a key of a k8s resource. +type KeySelector struct { + NameSelector `json:",inline"` + // The key of the resource to select from. + Key string `json:"key"` +} + +// JSONPathSelector select a field of a k8s resource by defining JSONPath +type JSONPathSelector struct { + NameSelector `json:",inline"` + // JSONPath template for extracting given value from resource. + JSONPath string `json:"jsonpath"` +} + +// CredentialFromSource represents list of sources to populate credentials variables. +type CredentialFromSource struct { + // The ConfigMap to select from + // + optional + ConfigMapRef *NameSelector `json:"configMapRef,omitempty"` + // The Secret to select from + // +optional + SecretRef *NameSelector `json:"secretRef,omitempty"` +} + +// NameSelector selects by the name of k8s resource. +type NameSelector struct { + // Name the name of k8s resource. + Name string `json:"name"` +} + +// RenderedBindYAML is used to represent already rendered YAML for binding. +type RenderedBindYAML []byte diff --git a/components/helm-broker/internal/ybundle/automock/bundle_loader.go b/components/helm-broker/internal/ybundle/automock/bundle_loader.go new file mode 100644 index 000000000000..62fc3fd41303 --- /dev/null +++ b/components/helm-broker/internal/ybundle/automock/bundle_loader.go @@ -0,0 +1,43 @@ +package automock + +import chart "k8s.io/helm/pkg/proto/hapi/chart" +import internal "github.com/kyma-project/kyma/components/helm-broker/internal" +import io "io" +import mock "github.com/stretchr/testify/mock" + +// BundleLoader is an autogenerated mock type for the BundleLoader type +type BundleLoader struct { + mock.Mock +} + +// Load provides a mock function with given fields: _a0 +func (_m *BundleLoader) Load(_a0 io.Reader) (*internal.Bundle, []*chart.Chart, error) { + ret := _m.Called(_a0) + + var r0 *internal.Bundle + if rf, ok := ret.Get(0).(func(io.Reader) *internal.Bundle); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*internal.Bundle) + } + } + + var r1 []*chart.Chart + if rf, ok := ret.Get(1).(func(io.Reader) []*chart.Chart); ok { + r1 = rf(_a0) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]*chart.Chart) + } + } + + var r2 error + if rf, ok := ret.Get(2).(func(io.Reader) error); ok { + r2 = rf(_a0) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} diff --git a/components/helm-broker/internal/ybundle/automock/bundle_upserter.go b/components/helm-broker/internal/ybundle/automock/bundle_upserter.go new file mode 100644 index 000000000000..eb010289a498 --- /dev/null +++ b/components/helm-broker/internal/ybundle/automock/bundle_upserter.go @@ -0,0 +1,30 @@ +package automock + +import internal "github.com/kyma-project/kyma/components/helm-broker/internal" +import mock "github.com/stretchr/testify/mock" + +// BundleUpserter is an autogenerated mock type for the BundleUpserter type +type BundleUpserter struct { + mock.Mock +} + +// Upsert provides a mock function with given fields: _a0 +func (_m *BundleUpserter) Upsert(_a0 *internal.Bundle) (bool, error) { + ret := _m.Called(_a0) + + var r0 bool + if rf, ok := ret.Get(0).(func(*internal.Bundle) bool); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func(*internal.Bundle) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/helm-broker/internal/ybundle/automock/chart_upserter.go b/components/helm-broker/internal/ybundle/automock/chart_upserter.go new file mode 100644 index 000000000000..263462beec5f --- /dev/null +++ b/components/helm-broker/internal/ybundle/automock/chart_upserter.go @@ -0,0 +1,30 @@ +package automock + +import chart "k8s.io/helm/pkg/proto/hapi/chart" +import mock "github.com/stretchr/testify/mock" + +// ChartUpserter is an autogenerated mock type for the ChartUpserter type +type ChartUpserter struct { + mock.Mock +} + +// Upsert provides a mock function with given fields: c +func (_m *ChartUpserter) Upsert(c *chart.Chart) (bool, error) { + ret := _m.Called(c) + + var r0 bool + if rf, ok := ret.Get(0).(func(*chart.Chart) bool); ok { + r0 = rf(c) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func(*chart.Chart) error); ok { + r1 = rf(c) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/helm-broker/internal/ybundle/automock/extended.go b/components/helm-broker/internal/ybundle/automock/extended.go new file mode 100644 index 000000000000..01165c00574d --- /dev/null +++ b/components/helm-broker/internal/ybundle/automock/extended.go @@ -0,0 +1,54 @@ +package automock + +import ( + "strings" + + "github.com/stretchr/testify/mock" + "k8s.io/helm/pkg/proto/hapi/chart" + + "io" + + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/kyma-project/kyma/components/helm-broker/internal/ybundle" +) + +func (m *Repository) ExpectOnIndexReader(idxContent string) { + r := strings.NewReader(idxContent) + m.On("IndexReader").Return(r, func() {}, nil) +} + +func (m *Repository) ExpectErrorOnIndexReader(outErr error) { + m.On("IndexReader").Return(nil, nil, outErr) +} + +func (m *Repository) ExpectOnBundleReader(givenName ybundle.BundleName, givenVersion ybundle.BundleVersion, bundleReader io.Reader) { + m.On("BundleReader", givenName, givenVersion).Return(bundleReader, func() {}, nil) +} + +func (m *Repository) ExpectErrorOnBundleReader(outErr error) { + m.On("BundleReader", mock.Anything, mock.Anything).Return(nil, nil, outErr) +} + +func (mati *BundleLoader) ExpectOnLoad(r io.Reader, outBundle *internal.Bundle, outCharts []*chart.Chart) { + mati.On("Load", r).Return(outBundle, outCharts, nil) +} + +func (mati *BundleLoader) ExpectErrorOnLoad(outErr error) { + mati.On("Load", mock.Anything).Return(nil, nil, outErr) +} + +func (bi *BundleUpserter) ExpectOnUpsert(inBundle *internal.Bundle) { + bi.On("Upsert", inBundle).Return(false, nil) +} + +func (bi *BundleUpserter) ExpectErrorOnUpsert(outErr error) { + bi.On("Upsert", mock.Anything).Return(false, outErr) +} + +func (ci *ChartUpserter) ExpectOnUpsert(inChart *chart.Chart) { + ci.On("Upsert", inChart).Return(false, nil) +} + +func (ci *ChartUpserter) ExpectErrorOnUpsert(outErr error) { + ci.On("Upsert", mock.Anything).Return(false, outErr) +} diff --git a/components/helm-broker/internal/ybundle/automock/repository.go b/components/helm-broker/internal/ybundle/automock/repository.go new file mode 100644 index 000000000000..0ee32438cb12 --- /dev/null +++ b/components/helm-broker/internal/ybundle/automock/repository.go @@ -0,0 +1,74 @@ +package automock + +import io "io" +import mock "github.com/stretchr/testify/mock" +import ybundle "github.com/kyma-project/kyma/components/helm-broker/internal/ybundle" + +// repository is an autogenerated mock type for the repository type +type Repository struct { + mock.Mock +} + +// BundleReader provides a mock function with given fields: name, version +func (_m *Repository) BundleReader(name ybundle.BundleName, version ybundle.BundleVersion) (io.Reader, func(), error) { + ret := _m.Called(name, version) + + var r0 io.Reader + if rf, ok := ret.Get(0).(func(ybundle.BundleName, ybundle.BundleVersion) io.Reader); ok { + r0 = rf(name, version) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(io.Reader) + } + } + + var r1 func() + if rf, ok := ret.Get(1).(func(ybundle.BundleName, ybundle.BundleVersion) func()); ok { + r1 = rf(name, version) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(func()) + } + } + + var r2 error + if rf, ok := ret.Get(2).(func(ybundle.BundleName, ybundle.BundleVersion) error); ok { + r2 = rf(name, version) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// IndexReader provides a mock function with given fields: +func (_m *Repository) IndexReader() (io.Reader, func(), error) { + ret := _m.Called() + + var r0 io.Reader + if rf, ok := ret.Get(0).(func() io.Reader); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(io.Reader) + } + } + + var r1 func() + if rf, ok := ret.Get(1).(func() func()); ok { + r1 = rf() + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(func()) + } + } + + var r2 error + if rf, ok := ret.Get(2).(func() error); ok { + r2 = rf() + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} diff --git a/components/helm-broker/internal/ybundle/export_test.go b/components/helm-broker/internal/ybundle/export_test.go new file mode 100644 index 000000000000..0cec0cf971cc --- /dev/null +++ b/components/helm-broker/internal/ybundle/export_test.go @@ -0,0 +1,5 @@ +package ybundle + +func (l *Loader) SetCreateTmpDir(tmpDir func(dir, prefix string) (name string, err error)) { + l.createTmpDir = tmpDir +} diff --git a/components/helm-broker/internal/ybundle/form.go b/components/helm-broker/internal/ybundle/form.go new file mode 100644 index 000000000000..ef620e1d6bcb --- /dev/null +++ b/components/helm-broker/internal/ybundle/form.go @@ -0,0 +1,221 @@ +package ybundle + +import ( + "fmt" + "strings" + + "github.com/Masterminds/semver" + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/pkg/errors" + "k8s.io/helm/pkg/proto/hapi/chart" +) + +type form struct { + Meta *formMeta + Plans map[string]*formPlan +} + +type formMeta struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Version string `yaml:"version"` + Description string `yaml:"description"` + DisplayName string `yaml:"displayName"` + Tags string `yaml:"tags"` + ProviderDisplayName string `yaml:"providerDisplayName"` + LongDescription string `yaml:"longDescription"` + DocumentationURL string `yaml:"documentationURL"` + SupportURL string `yaml:"supportURL"` + ImageURL string `yaml:"imageURL"` + Bindable bool `yaml:"bindable"` +} + +func (m *formMeta) Validate() error { + messages := []string{} + + if m.ID == "" { + messages = append(messages, "missing ID field") + } + if m.Name == "" { + messages = append(messages, "missing Name field") + } + if m.Version == "" { + messages = append(messages, "missing Version field") + } + if m.Description == "" { + messages = append(messages, "missing Description field") + } + if m.DisplayName == "" { + messages = append(messages, "missing displayName field") + } + + if len(messages) > 0 { + return errors.New(strings.Join(messages, ", ")) + } + + return nil +} +func (f *form) Validate() error { + messages := []string{} + + if f.Meta == nil { + messages = append(messages, fmt.Sprintf("missing metadata information about bundle. Please check if bundle contains %q file", bundleMetaName)) + } + if len(f.Plans) == 0 { + messages = append(messages, "bundle does not contains any plans") + } + for name, plan := range f.Plans { + if err := plan.Validate(); err != nil { + messages = append(messages, fmt.Sprintf("while validating %q plan: %s", name, err.Error())) + } + } + + if f.Meta != nil { + if err := f.Meta.Validate(); err != nil { + messages = append(messages, fmt.Sprintf("while validating bundle meta: %s", err.Error())) + } + } + + if len(messages) > 0 { + return errors.New(strings.Join(messages, ", ")) + } + + return nil +} + +func (f *form) ToModel(c *chart.Chart) (internal.Bundle, error) { + ybVer, err := semver.NewVersion(f.Meta.Version) + if err != nil { + return internal.Bundle{}, errors.Wrap(err, "while converting form string version to semver type") + } + + mappedPlans := make(map[internal.BundlePlanID]internal.BundlePlan) + for name, plan := range f.Plans { + dm, err := plan.ToModel(c) + if err != nil { + return internal.Bundle{}, errors.Wrapf(err, "while mapping to model %q plan", name) + } + mappedPlans[internal.BundlePlanID(plan.Meta.ID)] = dm + } + + return internal.Bundle{ + ID: internal.BundleID(f.Meta.ID), + Name: internal.BundleName(f.Meta.Name), + Description: f.Meta.Description, + Bindable: f.Meta.Bindable, + Metadata: internal.BundleMetadata{ + DisplayName: f.Meta.DisplayName, + DocumentationURL: f.Meta.DocumentationURL, + ImageURL: f.Meta.ImageURL, + LongDescription: f.Meta.LongDescription, + ProviderDisplayName: f.Meta.ProviderDisplayName, + SupportURL: f.Meta.SupportURL, + }, + Tags: f.mapTagToModel(), + Version: *ybVer, + Plans: mappedPlans, + }, nil +} + +func (f *form) mapTagToModel() []internal.BundleTag { + splittedTags := strings.Split(f.Meta.Tags, ",") + mapped := make([]internal.BundleTag, 0, len(splittedTags)) + for i := range splittedTags { + mapped = append(mapped, internal.BundleTag(strings.TrimSpace(splittedTags[i]))) + } + return mapped +} + +type formPlan struct { + Meta *formPlanMeta + SchemasUpdate *internal.PlanSchema + SchemasCreate *internal.PlanSchema + Values map[string]interface{} + BindTemplate []byte +} + +func (p *formPlan) Validate() error { + if p.Meta == nil { + return fmt.Errorf("missing metadata information about plan. Please check if plan contains %q file", bundlePlanMetaName) + } + + if p.Meta.Bindable != nil && *p.Meta.Bindable == true && p.BindTemplate == nil { + return fmt.Errorf("plans is marked as bindable but %s file was not found in plan %s", bundlePlanBindTemplateFileName, p.Meta.Name) + } + + if err := p.Meta.Validate(); err != nil { + return errors.Wrap(err, "while validating plan meta") + } + + return nil +} + +func (p *formPlan) ToModel(c *chart.Chart) (internal.BundlePlan, error) { + if c == nil { + return internal.BundlePlan{}, errors.New("missing input param chart") + } + if c.Metadata == nil { + return internal.BundlePlan{}, errors.New("missing Metadata field in input param chart") + } + + cVer, err := semver.NewVersion(c.Metadata.Version) + if err != nil { + return internal.BundlePlan{}, errors.Wrap(err, "while converting chart string version to semver type") + } + + cRef := internal.ChartRef{ + Name: internal.ChartName(c.Metadata.Name), + Version: *cVer, + } + + mappedSchemas := make(map[internal.PlanSchemaType]internal.PlanSchema) + + if p.SchemasUpdate != nil { + mappedSchemas[internal.SchemaTypeUpdate] = *p.SchemasUpdate + } + if p.SchemasCreate != nil { + mappedSchemas[internal.SchemaTypeProvision] = *p.SchemasCreate + } + + return internal.BundlePlan{ + ID: internal.BundlePlanID(p.Meta.ID), + Name: internal.BundlePlanName(p.Meta.Name), + Description: p.Meta.Description, + Metadata: internal.BundlePlanMetadata{ + DisplayName: p.Meta.DisplayName, + }, + ChartValues: internal.ChartValues(p.Values), + Schemas: mappedSchemas, + ChartRef: cRef, + Bindable: p.Meta.Bindable, + BindTemplate: p.BindTemplate, + }, nil +} + +type formPlanMeta struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Description string `yaml:"description"` + DisplayName string `yaml:"displayName"` + Bindable *bool `yaml:"bindable"` +} + +func (f *formPlanMeta) Validate() error { + messages := []string{} + if f.ID == "" { + messages = append(messages, "missing ID field") + } + if f.Name == "" { + messages = append(messages, "missing Name field") + } + if f.Description == "" { + messages = append(messages, "missing Description field") + } + if f.DisplayName == "" { + messages = append(messages, "missing displayName field") + } + if len(messages) > 0 { + return errors.New(strings.Join(messages, ", ")) + } + return nil +} diff --git a/components/helm-broker/internal/ybundle/form_test.go b/components/helm-broker/internal/ybundle/form_test.go new file mode 100644 index 000000000000..0baeab79b9fc --- /dev/null +++ b/components/helm-broker/internal/ybundle/form_test.go @@ -0,0 +1,365 @@ +package ybundle + +import ( + "testing" + + "github.com/Masterminds/semver" + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/helm/pkg/proto/hapi/chart" +) + +func TestFormToModelSuccess(t *testing.T) { + // given + fixForm := fixValidForm() + fixForm.Plans = nil // Already tested by TestFormPlanToModelSuccess, triggering such flow here, will be difficult (postponed) + + fixChart := fixValidChart() + + ver, err := semver.NewVersion(fixForm.Meta.Version) + require.NoError(t, err) + + expBundle := internal.Bundle{ + ID: internal.BundleID(fixForm.Meta.ID), + Name: internal.BundleName(fixForm.Meta.Name), + Description: fixForm.Meta.Description, + Metadata: internal.BundleMetadata{ + DisplayName: fixForm.Meta.DisplayName, + DocumentationURL: fixForm.Meta.DocumentationURL, + ImageURL: fixForm.Meta.ImageURL, + LongDescription: fixForm.Meta.LongDescription, + ProviderDisplayName: fixForm.Meta.ProviderDisplayName, + SupportURL: fixForm.Meta.SupportURL, + }, + Tags: []internal.BundleTag{"go", "golang"}, + Bindable: fixForm.Meta.Bindable, + Version: *ver, + Plans: make(map[internal.BundlePlanID]internal.BundlePlan), + } + // when + gotBundle, err := fixForm.ToModel(&fixChart) + + // then + require.NoError(t, err) + assert.Equal(t, expBundle, gotBundle) +} + +func TestFormToModelFailure(t *testing.T) { + // given + fixChart := fixValidChart() + + fixForm := fixValidForm() + fixForm.Meta.Version = "abc" + + // when + gotBundle, err := fixForm.ToModel(&fixChart) + + // then + require.EqualError(t, err, "while converting form string version to semver type: Invalid Semantic Version") + assert.Zero(t, gotBundle) +} + +func TestFormValidateSuccess(t *testing.T) { + for tn, tc := range map[string]struct { + fixForm form + }{ + "all fields provided": { + fixForm: fixValidForm(), + }, + "not required fields are empty": { + fixForm: func() form { + f := fixValidForm() + // change to zero values + f.Meta.Tags = "" + f.Meta.ProviderDisplayName = "" + f.Meta.LongDescription = "" + f.Meta.DocumentationURL = "" + f.Meta.SupportURL = "" + f.Meta.ImageURL = "" + f.Meta.Bindable = false + return f + }(), + }, + } { + t.Run(tn, func(t *testing.T) { + // when + err := tc.fixForm.Validate() + + // then + assert.NoError(t, err) + }) + } +} + +func TestFormValidateFailure(t *testing.T) { + for tn, tc := range map[string]struct { + fixForm form + errMsgs []string + }{ + "missing form meta field": { + fixForm: func() form { + fix := fixValidForm() + fix.Meta = nil + return fix + }(), + errMsgs: []string{"missing metadata information about bundle. Please check if bundle contains \"meta.yaml\" file"}, + }, + "missing form ID field": { + fixForm: func() form { + fix := fixValidForm() + fix.Meta.ID = "" + return fix + }(), + errMsgs: []string{"while validating bundle meta: missing ID field"}, + }, + "missing form Name field": { + fixForm: func() form { + fix := fixValidForm() + fix.Meta.Name = "" + return fix + }(), + errMsgs: []string{"while validating bundle meta: missing Name field"}, + }, + "missing form Version field": { + fixForm: func() form { + fix := fixValidForm() + fix.Meta.Version = "" + return fix + }(), + errMsgs: []string{"while validating bundle meta: missing Version field"}, + }, + "missing form Description field": { + fixForm: func() form { + fix := fixValidForm() + fix.Meta.Description = "" + return fix + }(), + errMsgs: []string{"while validating bundle meta: missing Description field"}, + }, + "missing form displayName field": { + fixForm: func() form { + fix := fixValidForm() + fix.Meta.DisplayName = "" + return fix + }(), + errMsgs: []string{"while validating bundle meta: missing displayName field"}, + }, + "invalid form plan entry": { + fixForm: func() form { + fix := fixValidForm() + for k := range fix.Plans { // remove meta from plans + fix.Plans[k].Meta = nil + } + return fix + }(), + errMsgs: []string{"while validating \"micro-plan-id-123\" plan: missing metadata information about plan. Please check if plan contains \"meta.yaml\" file", + "while validating \"myk-plan-id-123\" plan: missing metadata information about plan. Please check if plan contains \"meta.yaml\" file"}, + }, + } { + t.Run(tn, func(t *testing.T) { + // when + err := tc.fixForm.Validate() + + // then + for _, msg := range tc.errMsgs { + assert.Contains(t, err.Error(), msg) + } + }) + } +} + +func TestFormPlanToModelSuccess(t *testing.T) { + // given + fixPlan := fixValidFormPlan("test-to-model-success") + fixChart := fixValidChart() + + charVer, err := semver.NewVersion(fixValidChart().Metadata.Version) + require.NoError(t, err) + + expBundlePlan := internal.BundlePlan{ + ID: internal.BundlePlanID(fixPlan.Meta.ID), + Name: internal.BundlePlanName(fixPlan.Meta.Name), + Description: fixPlan.Meta.Description, + Metadata: internal.BundlePlanMetadata{ + DisplayName: fixPlan.Meta.DisplayName, + }, + Schemas: map[internal.PlanSchemaType]internal.PlanSchema{ + internal.SchemaTypeProvision: *fixPlan.SchemasCreate, + }, + ChartRef: internal.ChartRef{ + Name: internal.ChartName(fixChart.Metadata.Name), + Version: *charVer, + }, + ChartValues: fixPlan.Values, + Bindable: fixPlan.Meta.Bindable, + BindTemplate: fixPlan.BindTemplate, + } + + // when + gotBundlePlan, err := fixPlan.ToModel(&fixChart) + + // then + require.NoError(t, err) + assert.Equal(t, expBundlePlan, gotBundlePlan) +} + +func TestFormPlanToModelFailure(t *testing.T) { + t.Run("on input chart param", func(t *testing.T) { + for tn, tc := range map[string]struct { + errMsg string + fixChart *chart.Chart + }{ + "nil input chart": { + fixChart: nil, + errMsg: "missing input param chart", + }, + "mising chart metadata": { + fixChart: func() *chart.Chart { + fixChart := fixValidChart() + fixChart.Metadata = nil + return &fixChart + }(), + errMsg: "missing Metadata field in input param chart", + }, + "invalid chart version": { + fixChart: func() *chart.Chart { + fixChart := fixValidChart() + fixChart.Metadata.Version = "abc" + return &fixChart + }(), + errMsg: "while converting chart string version to semver type: Invalid Semantic Version", + }, + } { + t.Run(tn, func(t *testing.T) { + // given + fixPlan := fixValidFormPlan("test-to-model-success") + + // when + gotBundlePlan, err := fixPlan.ToModel(tc.fixChart) + + // then + require.EqualError(t, err, tc.errMsg) + assert.Zero(t, gotBundlePlan) + }) + } + }) +} + +func TestFormPlanValidateSuccess(t *testing.T) { + // given + fixFormPlan := fixValidFormPlan("valid-success-test") + + // when + err := fixFormPlan.Validate() + + // then + assert.NoError(t, err) +} + +func TestFormPlanValidateFailure(t *testing.T) { + for tn, tc := range map[string]struct { + fixFormPlan formPlan + errMsg string + }{ + "missing meta field": { + fixFormPlan: func() formPlan { + fix := fixValidFormPlan("missing-fields") + fix.Meta = nil + return fix + }(), + errMsg: "missing metadata information about plan. Please check if plan contains \"meta.yaml\" file", + }, + "missing ID field": { + fixFormPlan: func() formPlan { + fix := fixValidFormPlan("missing-fields") + fix.Meta.ID = "" + return fix + }(), + errMsg: "while validating plan meta: missing ID field", + }, + "missing Name field": { + fixFormPlan: func() formPlan { + fix := fixValidFormPlan("missing-fields") + fix.Meta.Name = "" + return fix + }(), + errMsg: "while validating plan meta: missing Name field", + }, + "missing Description field": { + fixFormPlan: func() formPlan { + fix := fixValidFormPlan("missing-fields") + fix.Meta.Description = "" + return fix + }(), + errMsg: "while validating plan meta: missing Description field", + }, + "missing displayName field": { + fixFormPlan: func() formPlan { + fix := fixValidFormPlan("missing-fields") + fix.Meta.DisplayName = "" + return fix + }(), + errMsg: "while validating plan meta: missing displayName field", + }, + } { + t.Run(tn, func(t *testing.T) { + // when + err := tc.fixFormPlan.Validate() + + // then + assert.EqualError(t, err, tc.errMsg) + }) + } +} + +func fixValidForm() form { + micro := fixValidFormPlan("micro-plan-id-123") + myk := fixValidFormPlan("myk-plan-id-123") + + return form{ + Meta: &formMeta{ + ID: "123-id-123", + Description: "form-desc", + Name: "form-name", + Version: "1.0.1", + SupportURL: "http://support.url", + ProviderDisplayName: "Gopherek Inc.", + LongDescription: "Gopher Gopherowi Idefixem", + ImageURL: "http://image.url", + DocumentationURL: "http://documentation.url", + DisplayName: "Gopher Form", + Tags: "go, golang", + Bindable: true, + }, + Plans: map[string]*formPlan{ + micro.Meta.ID: µ, + myk.Meta.ID: &myk, + }, + } +} + +func fixValidFormPlan(id string) formPlan { + return formPlan{ + Meta: &formPlanMeta{ + ID: id, + Name: "name", + Description: "desc", + DisplayName: "Plan Display Name", + }, + SchemasCreate: &internal.PlanSchema{}, + Values: map[string]interface{}{ + "par1": "val1", + }, + BindTemplate: []byte(`bindTemplate`), + } +} + +func fixValidChart() chart.Chart { + return chart.Chart{ + Metadata: &chart.Metadata{ + Version: "9.9.9", + Name: "test-chart", + }, + } +} diff --git a/components/helm-broker/internal/ybundle/helpers_test.go b/components/helm-broker/internal/ybundle/helpers_test.go new file mode 100644 index 000000000000..e5b17fea35e2 --- /dev/null +++ b/components/helm-broker/internal/ybundle/helpers_test.go @@ -0,0 +1,154 @@ +package ybundle_test + +import ( + "fmt" + "io/ioutil" + "os" + "strings" + "testing" + + "encoding/json" + + "github.com/Masterminds/semver" + "github.com/ghodss/yaml" + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/stretchr/testify/require" +) + +func fixtureBundle(t *testing.T, testdataBasePath string) internal.Bundle { + var meta struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Version string `yaml:"version"` + Description string `yaml:"description"` + DisplayName string `yaml:"displayName"` + Tags string `yaml:"tags"` + ProviderDisplayName string `yaml:"providerDisplayName"` + LongDescription string `yaml:"longDescription"` + DocumentationURL string `yaml:"documentationURL"` + SupportURL string `yaml:"supportURL"` + ImageURL string `yaml:"imageURL"` + Bindable bool `yaml:"bindable"` + } + unmarshalYamlTestdata(t, testdataBasePath+"meta.yaml", &meta) + bVer, err := semver.NewVersion(meta.Version) + require.NoError(t, err) + + charRef := fixChartRef(t, testdataBasePath) + micro := fixturePlan(t, testdataBasePath, "micro", charRef) + enterprise := fixturePlan(t, testdataBasePath, "enterprise", charRef) + + mapTagsToModel := func(tags string) []internal.BundleTag { + splittedTags := strings.Split(tags, ",") + mapped := make([]internal.BundleTag, 0, len(splittedTags)) + for i := range splittedTags { + mapped = append(mapped, internal.BundleTag(strings.TrimSpace(splittedTags[i]))) + } + return mapped + } + + return internal.Bundle{ + ID: internal.BundleID(meta.ID), + Version: *bVer, + Name: internal.BundleName(meta.Name), + Description: meta.Description, + Bindable: meta.Bindable, + Metadata: internal.BundleMetadata{ + DisplayName: meta.DisplayName, + DocumentationURL: meta.DocumentationURL, + ImageURL: meta.ImageURL, + LongDescription: meta.LongDescription, + ProviderDisplayName: meta.ProviderDisplayName, + SupportURL: meta.SupportURL, + }, + Tags: mapTagsToModel(meta.Tags), + Plans: map[internal.BundlePlanID]internal.BundlePlan{ + micro.ID: micro, + enterprise.ID: enterprise, + }, + } +} + +func fixChartRef(t *testing.T, testdataBasePath string) internal.ChartRef { + var chart struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + } + unmarshalYamlTestdata(t, testdataBasePath+"/chart/redis/Chart.yaml", &chart) + cVer, err := semver.NewVersion(chart.Version) + require.NoError(t, err) + + return internal.ChartRef{ + Name: internal.ChartName(chart.Name), + Version: *cVer, + } +} + +func fixturePlan(t *testing.T, testdataBasePath string, planName string, cRef internal.ChartRef) internal.BundlePlan { + fullbasePath := fmt.Sprintf("%s/plans/%s/", testdataBasePath, planName) + var meta struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Description string `yaml:"description"` + DisplayName string `yaml:"displayName"` + Bindable *bool `yaml:"bindable"` + } + unmarshalYamlTestdata(t, fullbasePath+"meta.yaml", &meta) + + var chartVal map[string]interface{} + unmarshalYamlTestdata(t, fullbasePath+"values.yaml", &chartVal) + + schemaCreate := unmarshalJSONTestdata(t, fullbasePath+"create-instance-schema.json") + + schemaUpdate := unmarshalJSONTestdata(t, fullbasePath+"update-instance-schema.json") + + bindTemplate := loadRawTestdata(t, fullbasePath+"bind.yaml") + + return internal.BundlePlan{ + ID: internal.BundlePlanID(meta.ID), + Description: meta.Description, + Metadata: internal.BundlePlanMetadata{ + DisplayName: meta.DisplayName, + }, + Bindable: meta.Bindable, + Name: internal.BundlePlanName(meta.Name), + ChartRef: cRef, + Schemas: map[internal.PlanSchemaType]internal.PlanSchema{ + internal.SchemaTypeProvision: schemaCreate, + internal.SchemaTypeUpdate: schemaUpdate, + }, + ChartValues: internal.ChartValues(chartVal), + BindTemplate: bindTemplate, + } +} + +func unmarshalYamlTestdata(t *testing.T, filename string, unmarshalTo interface{}) { + b, err := ioutil.ReadFile(filename) + require.NoError(t, err) + err = yaml.Unmarshal(b, unmarshalTo) + require.NoError(t, err) +} + +func unmarshalJSONTestdata(t *testing.T, filename string) internal.PlanSchema { + b, err := ioutil.ReadFile(filename) + require.NoError(t, err) + schema := new(internal.PlanSchema) + err = json.Unmarshal(b, schema) + require.NoError(t, err) + return *schema +} + +func loadRawTestdata(t *testing.T, filename string) []byte { + b, err := ioutil.ReadFile(filename) + require.NoError(t, err) + return b +} +func assertDirNotExits(t *testing.T, path string) { + _, err := os.Stat(path) + if err == nil { + t.Errorf("Directory %q stil exists", path) + } + if !os.IsNotExist(err) { + t.Errorf("Got error while checking if dir %q exits: %v", path, err) + } +} diff --git a/components/helm-broker/internal/ybundle/loader.go b/components/helm-broker/internal/ybundle/loader.go new file mode 100644 index 000000000000..f7c7b1a977cc --- /dev/null +++ b/components/helm-broker/internal/ybundle/loader.go @@ -0,0 +1,317 @@ +package ybundle + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + "strings" + + "github.com/ghodss/yaml" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "k8s.io/helm/pkg/chartutil" + "k8s.io/helm/pkg/proto/hapi/chart" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +const ( + bundleChartDirName = "chart" + bundleMetaName = "meta.yaml" + bundlePlanDirName = "plans" + + bundlePlanMetaName = "meta.yaml" + bundlePlaSchemaCreateJSONName = "create-instance-schema.json" + bundlePlanSchemaUpdateJSONName = "update-instance-schema.json" + bundlePlanValuesFileName = "values.yaml" + bundlePlanBindTemplateFileName = "bind.yaml" + + maxSchemaLength = 65536 // 64 k +) + +// Loader provides loading of bundles from repository and representing them as bundles and charts. +type Loader struct { + tmpDir string + loadChart func(name string) (*chart.Chart, error) + createTmpDir func(dir, prefix string) (name string, err error) + log *logrus.Entry +} + +// NewLoader returns new instance of Loader. +func NewLoader(tmpDir string, log *logrus.Entry) *Loader { + return &Loader{ + tmpDir: tmpDir, + loadChart: chartutil.Load, + createTmpDir: ioutil.TempDir, + log: log.WithField("service", "ybundle:loader"), + } +} + +// Load takes stream with compressed tgz archive as io.Reader, tries to unpack it to tmp directory, +// and then loads it as YBundle and helm Chart +func (l *Loader) Load(in io.Reader) (*internal.Bundle, []*chart.Chart, error) { + unpackedDir, err := l.unpackArchive(l.tmpDir, in) + if err != nil { + return nil, nil, err + } + cleanPath := filepath.Clean(unpackedDir) + if strings.HasPrefix(cleanPath, l.tmpDir) { + defer os.RemoveAll(unpackedDir) + } else { + defer l.log.Warnf("directory %s was not deleted because its name does not resolve to expected path", unpackedDir) + } + + return l.loadDir(unpackedDir) +} + +// LoadDir takes uncompressed chart in specified directory and loads it. +func (l Loader) LoadDir(path string) (*internal.Bundle, []*chart.Chart, error) { + return l.loadDir(path) +} + +func (l Loader) loadDir(path string) (*internal.Bundle, []*chart.Chart, error) { + c, err := l.loadChartFromDir(path) + if err != nil { + return nil, nil, errors.Wrap(err, "while loading chart") + } + + form, err := l.createFormFromBundleDir(path) + if err != nil { + return nil, nil, errors.Wrap(err, "while mapping buffered files to form") + } + + if err := form.Validate(); err != nil { + return nil, nil, errors.Wrap(err, "while validating form") + } + + yb, err := form.ToModel(c) + if err != nil { + return nil, nil, errors.Wrap(err, "while mapping form to model") + } + + return &yb, []*chart.Chart{c}, nil +} + +func (l Loader) loadChartFromDir(baseDir string) (*chart.Chart, error) { + cDir := filepath.Join(baseDir, bundleChartDirName) + files, err := ioutil.ReadDir(cDir) + switch { + case err == nil: + case os.IsNotExist(err): + return nil, errors.New("bundle does not contains \"chart\" directory") + default: + return nil, errors.Wrapf(err, "while reading directory %s", cDir) + } + + if len(files) != 1 || !files[0].IsDir() { + return nil, fmt.Errorf("%q directory MUST contain one folder", bundleChartDirName) + } + + // In current version we have only one chart per bundle + // in future version we will have some loop over each plan to load all charts + c, err := l.loadChart(filepath.Join(cDir, files[0].Name())) + switch { + case err == nil: + case os.IsNotExist(err): + return nil, errors.New("bundle does not contains \"chart\" directory") + default: + return nil, errors.Wrap(err, "while loading chart") + } + + return c, nil +} + +func (l Loader) createFormFromBundleDir(baseDir string) (*form, error) { + f := &form{Plans: make(map[string]*formPlan)} + + bundleMetaFile, err := ioutil.ReadFile(filepath.Join(baseDir, bundleMetaName)) + switch { + case err == nil: + case os.IsNotExist(err): + return nil, fmt.Errorf("missing metadata information about bundle, please check if bundle contains %q file", bundleMetaName) + default: + return nil, errors.Wrapf(err, "while reading %q file", bundleMetaName) + } + + if err := yaml.Unmarshal(bundleMetaFile, &f.Meta); err != nil { + return nil, errors.Wrapf(err, "while unmarshaling bundle %q file", bundleMetaName) + } + + plansPath := filepath.Join(baseDir, bundlePlanDirName) + files, err := ioutil.ReadDir(plansPath) + switch { + case err == nil: + case os.IsNotExist(err): + return nil, fmt.Errorf("bundle does not contains any plans, please check if bundle contains %q directory", bundlePlanDirName) + default: + return nil, errors.Wrapf(err, "while reading %q file", bundleMetaName) + } + + for _, fileInfo := range files { + if fileInfo.IsDir() { + planName := fileInfo.Name() + f.Plans[planName] = &formPlan{} + + if err := l.loadPlanDefinition(filepath.Join(plansPath, planName), f.Plans[planName]); err != nil { + return nil, err + } + } + } + + return f, nil +} + +func (Loader) loadPlanDefinition(path string, plan *formPlan) error { + topdir, err := filepath.Abs(path) + if err != nil { + return err + } + + unmarshalPlanErr := func(err error, filename string) error { + return errors.Wrapf(err, "while unmarshaling plan %q file", filename) + } + + if err := yamlUnmarshal(topdir, bundlePlanMetaName, &plan.Meta, true); err != nil { + return unmarshalPlanErr(err, bundlePlanMetaName) + } + + if err := yamlUnmarshal(topdir, bundlePlanValuesFileName, &plan.Values, false); err != nil { + return unmarshalPlanErr(err, bundlePlanValuesFileName) + } + + if plan.SchemasCreate, err = loadPlanSchema(topdir, bundlePlaSchemaCreateJSONName, false); err != nil { + return unmarshalPlanErr(err, bundlePlaSchemaCreateJSONName) + } + + if plan.SchemasUpdate, err = loadPlanSchema(topdir, bundlePlanSchemaUpdateJSONName, false); err != nil { + return unmarshalPlanErr(err, bundlePlanSchemaUpdateJSONName) + } + + if plan.BindTemplate, err = loadRaw(topdir, bundlePlanBindTemplateFileName, false); err != nil { + return errors.Wrapf(err, "while loading plan %q file", bundlePlanBindTemplateFileName) + } + + return nil +} + +// unpackArchive unpack from a reader containing a compressed tar archive to tmpdir. +func (l Loader) unpackArchive(baseDir string, in io.Reader) (string, error) { + dir, err := l.createTmpDir(baseDir, "unpacked-bundle") + if err != nil { + return "", err + } + + unzipped, err := gzip.NewReader(in) + if err != nil { + return "", err + } + defer unzipped.Close() + + tr := tar.NewReader(unzipped) + +Unpack: + for { + header, err := tr.Next() + switch err { + case nil: + case io.EOF: + break Unpack + default: + return "", err + } + + // the target location where the dir/file should be created + target := filepath.Join(dir, header.Name) + + // check the file type + switch header.Typeflag { + // its a dir and if it doesn't exist - create it + case tar.TypeDir: + if _, err := os.Stat(target); os.IsNotExist(err) { + if err := os.MkdirAll(target, 0755); err != nil { + return "", err + } + } + // it's a file - create it + case tar.TypeReg: + if err := l.createFile(target, header.Mode, tr); err != nil { + return "", err + } + } + } + + return dir, nil +} + +func (Loader) createFile(target string, mode int64, r io.Reader) error { + f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(mode)) + if err != nil { + return err + } + defer f.Close() + + // copy over contents + if _, err := io.Copy(f, r); err != nil { + return err + } + return nil +} + +func yamlUnmarshal(basePath, fileName string, unmarshalTo interface{}, required bool) error { + b, err := ioutil.ReadFile(filepath.Join(basePath, fileName)) + switch { + case err == nil: + case os.IsNotExist(err) && !required: + return nil + case os.IsNotExist(err) && required: + return fmt.Errorf("%q is required but is not present", fileName) + default: + return err + } + + return yaml.Unmarshal(b, unmarshalTo) +} + +func loadPlanSchema(basePath, fileName string, required bool) (*internal.PlanSchema, error) { + b, err := ioutil.ReadFile(filepath.Join(basePath, fileName)) + switch { + case err == nil: + case os.IsNotExist(err) && !required: + return nil, nil + case os.IsNotExist(err) && required: + return nil, fmt.Errorf("%q is required but is not present", fileName) + default: + return nil, errors.Wrap(err, "while loading plan schema") + } + + // OSB API defines: Schemas MUST NOT be larger than 64kB. + // See: https://github.com/openservicebrokerapi/servicebroker/blob/v2.13/spec.md#schema-object + if len(b) >= maxSchemaLength { + return nil, fmt.Errorf("schema %s is larger than 64 kB", fileName) + } + + var schema internal.PlanSchema + err = json.Unmarshal(b, &schema) + return &schema, errors.Wrap(err, "while loading plan shcema") +} + +func loadRaw(basePath, fileName string, required bool) ([]byte, error) { + b, err := ioutil.ReadFile(filepath.Join(basePath, fileName)) + switch { + case err == nil: + case os.IsNotExist(err) && !required: + return nil, nil + case os.IsNotExist(err) && required: + return nil, fmt.Errorf("%q is required but is not present", fileName) + default: + return nil, err + } + + return b, nil +} diff --git a/components/helm-broker/internal/ybundle/loader_test.go b/components/helm-broker/internal/ybundle/loader_test.go new file mode 100644 index 000000000000..7ead4d3d9289 --- /dev/null +++ b/components/helm-broker/internal/ybundle/loader_test.go @@ -0,0 +1,113 @@ +package ybundle_test + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "k8s.io/helm/pkg/chartutil" + + "github.com/kyma-project/kyma/components/helm-broker/internal/ybundle" + "github.com/kyma-project/kyma/components/helm-broker/platform/logger/spy" +) + +// TestLoaderLoad processes given test case bundle and compares it to the +// corresponding files from ./testdata/bundle-redis-0.0.1.golden dir +func TestLoaderLoadSuccess(t *testing.T) { + // given + fixBaseDir := "../../tmp" + var fixDirName string + createTmpDirFake := func(dir, prefix string) (string, error) { + assert.Equal(t, fixBaseDir, dir) + + name, err := ioutil.TempDir(dir, prefix) + fixDirName = name + return name, err + } + + bundleLoader := ybundle.NewLoader(fixBaseDir, spy.NewLogDummy()) + bundleLoader.SetCreateTmpDir(createTmpDirFake) + + expChart, err := chartutil.Load("testdata/bundle-redis-0.0.1.golden/chart/redis") + require.NoError(t, err) + + fd, err := os.Open("testdata/bundle-redis-0.0.1.input.tgz") + require.NoError(t, err) + defer fd.Close() + + // when + yb, c, err := bundleLoader.Load(fd) + + // then + require.NoError(t, err) + + require.Len(t, c, 1) + assert.Equal(t, expChart, c[0]) + + require.NotNil(t, yb) + assert.Equal(t, fixtureBundle(t, "./testdata/bundle-redis-0.0.1.golden/"), *yb) + + assertDirNotExits(t, filepath.Join("../tmp/", fixDirName)) +} + +func TestLoaderLoadFailure(t *testing.T) { + for tn, tc := range map[string]struct { + tgzPath string + errMsg string + }{ + "missing plans": { + tgzPath: "./testdata/bundle-missing-plans.input.tgz", + errMsg: "while mapping buffered files to form: bundle does not contains any plans, please check if bundle contains \"plans\" directory", + }, + "missing chart directory": { + tgzPath: "./testdata/bundle-missing-chart-dir.input.tgz", + errMsg: "while loading chart: bundle does not contains \"chart\" directory", + }, + "multiple charts in chart directory": { + tgzPath: "./testdata/bundle-multiple-charts-in-chart-dir.input.tgz", + errMsg: "while loading chart: \"chart\" directory MUST contain one folder", + }, + "missing chart in chart directory": { + tgzPath: "./testdata/bundle-no-chart-in-chart-dir.input.tgz", + errMsg: "while loading chart: \"chart\" directory MUST contain one folder", + }, + "missing meta.yaml file for micro plan": { + tgzPath: "./testdata/bundle-missing-plan-meta-file.input.tgz", + errMsg: "while mapping buffered files to form: while unmarshaling plan \"meta.yaml\" file: \"meta.yaml\" is required but is not present", + }, + "missing bind.yaml file for micro plan which is marked as bindable": { + tgzPath: "./testdata/bundle-missing-bind-yaml-when-bindable.input.tgz", + errMsg: "while validating form: while validating \"micro\" plan: plans is marked as bindable but bind.yaml file was not found in plan micro", + }, + "incorrect create schema": { + tgzPath: "./testdata/bundle-incorrect-create-schema.input.tgz", + errMsg: "while mapping buffered files to form: while unmarshaling plan \"create-instance-schema.json\" file: while loading plan shcema: invalid character '}' after object key", + }, + "big schema": { + tgzPath: "./testdata/bundle-big-schema.input.tgz", + errMsg: "while mapping buffered files to form: while unmarshaling plan \"update-instance-schema.json\" file: schema update-instance-schema.json is larger than 64 kB", + }, + } { + t.Run(tn, func(t *testing.T) { + // given + bundleLoader := ybundle.NewLoader("", spy.NewLogDummy()) + + fd, err := os.Open(tc.tgzPath) + require.NoError(t, err) + defer fd.Close() + + // when + yb, c, err := bundleLoader.Load(fd) + + // then + require.EqualError(t, err, tc.errMsg) + assert.Nil(t, yb) + assert.Nil(t, c) + }) + } + +} diff --git a/components/helm-broker/internal/ybundle/local_repository.go b/components/helm-broker/internal/ybundle/local_repository.go new file mode 100644 index 000000000000..c4ed5c79969b --- /dev/null +++ b/components/helm-broker/internal/ybundle/local_repository.go @@ -0,0 +1,32 @@ +package ybundle + +import ( + "fmt" + "io" + "os" +) + +// NewLocalRepository creates structure which allow us to access local repository +func NewLocalRepository(path string) *LocalRepository { + return &LocalRepository{path: path} +} + +// LocalRepository provide access to bundles repository +type LocalRepository struct { + path string +} + +// GetIndexFile returns index.yaml file from local repository +func (p *LocalRepository) GetIndexFile() (io.Reader, func(), error) { + fName := fmt.Sprintf("%s/%s", p.path, "index.yaml") + f, err := os.Open(fName) + if err != nil { + return nil, nil, err + } + return f, func() { f.Close() }, nil +} + +// GetBundlePath returns path to the bundle from local repository +func (p *LocalRepository) GetBundlePath(name string) (string, error) { + return fmt.Sprintf("%s/%s", p.path, name), nil +} diff --git a/components/helm-broker/internal/ybundle/populator.go b/components/helm-broker/internal/ybundle/populator.go new file mode 100644 index 000000000000..72f63df7c8dd --- /dev/null +++ b/components/helm-broker/internal/ybundle/populator.go @@ -0,0 +1,126 @@ +package ybundle + +import ( + "io" + "io/ioutil" + + "github.com/ghodss/yaml" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "k8s.io/helm/pkg/proto/hapi/chart" + + "github.com/kyma-project/kyma/components/helm-broker/internal" +) + +type ( + // BundleName represents name of the Bundle + BundleName string + // BundleVersion represents version of the Bundle + BundleVersion string +) + +type indexDTO struct { + Entries map[BundleName][]entryDTO `yaml:"entries"` +} + +type entryDTO struct { + Name BundleName `yaml:"name"` + Description string `yaml:"description"` + Version BundleVersion `yaml:"version"` +} + +// Populator is responsible for populating bundles and charts into storage. +// Source data is provided by bundleLoader. +type Populator struct { + repo repository + bundleLoader bundleLoader + bundleInterface bundleUpserter + chartInterface chartUpserter + log *logrus.Entry +} + +// NewPopulator creates new instance of Populator. +func NewPopulator(p repository, bundleLoader bundleLoader, bundleInterface bundleUpserter, chartInterface chartUpserter, log *logrus.Entry) *Populator { + return &Populator{ + repo: p, + bundleLoader: bundleLoader, + bundleInterface: bundleInterface, + chartInterface: chartInterface, + log: log.WithField("service", "populator"), + } +} + +// Init triggers population process. +func (b *Populator) Init() error { + idx, err := b.getIndex() + if err != nil { + return err + } + + for entryName, versions := range idx.Entries { + for _, v := range versions { + bundleReader, bundleCloser, err := b.repo.BundleReader(entryName, v.Version) + if err != nil { + return errors.Wrapf(err, "while reading bundle archive for name [%s] and version [%v]", entryName, v.Version) + } + defer bundleCloser() + + bundle, charts, err := b.bundleLoader.Load(bundleReader) + if err != nil { + return errors.Wrapf(err, "while loading bundle and charts for bundle [%s] and version [%s]", entryName, v.Version) + } + + for _, ch := range charts { + if _, err := b.chartInterface.Upsert(ch); err != nil { + return errors.Wrapf(err, "while storing chart [%s] for bundle [%s] with version [%s]", ch.String(), entryName, v.Version) + } + } + + if _, err := b.bundleInterface.Upsert(bundle); err != nil { + return errors.Wrapf(err, "while storing bundle [%s] with version [%s]", entryName, v.Version) + } + b.log.Infof("Bundle with name [%s] and version [%s] successfully stored", entryName, v.Version) + } + } + return nil +} + +func (b *Populator) getIndex() (*indexDTO, error) { + idxReader, idxCloser, err := b.repo.IndexReader() + if err != nil { + return nil, errors.Wrap(err, "while getting index.yaml") + } + defer idxCloser() + + bytes, err := ioutil.ReadAll(idxReader) + if err != nil { + return nil, errors.Wrap(err, "while reading all index.yaml") + } + idx := indexDTO{} + if err = yaml.Unmarshal(bytes, &idx); err != nil { + return nil, errors.Wrap(err, "while unmarshaling index") + } + return &idx, nil +} + +// be aware that after regenerating mocks, manual steps are required +//go:generate mockery -name=repository -output=automock -outpkg=automock -case=underscore +type repository interface { + IndexReader() (r io.Reader, closer func(), err error) + BundleReader(name BundleName, version BundleVersion) (r io.Reader, closer func(), err error) +} + +//go:generate mockery -name=bundleUpserter -output=automock -outpkg=automock -case=underscore +type bundleUpserter interface { + Upsert(*internal.Bundle) (replace bool, err error) +} + +//go:generate mockery -name=chartUpserter -output=automock -outpkg=automock -case=underscore +type chartUpserter interface { + Upsert(c *chart.Chart) (replace bool, err error) +} + +//go:generate mockery -name=bundleLoader -output=automock -outpkg=automock -case=underscore +type bundleLoader interface { + Load(io.Reader) (*internal.Bundle, []*chart.Chart, error) +} diff --git a/components/helm-broker/internal/ybundle/populator_dto_test.go b/components/helm-broker/internal/ybundle/populator_dto_test.go new file mode 100644 index 000000000000..c16b8e3bdc57 --- /dev/null +++ b/components/helm-broker/internal/ybundle/populator_dto_test.go @@ -0,0 +1,34 @@ +package ybundle + +import ( + "testing" + + "github.com/ghodss/yaml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDTO(t *testing.T) { + // GIVEN + data := ` +apiVersion: v1 +entries: + redis: + - name: redis + description: Redis service + version: 0.0.1 +` + dto := indexDTO{} + // WHEN + yaml.Unmarshal([]byte(data), &dto) + // THEN + require.Len(t, dto.Entries, 1) + redis, ex := dto.Entries["redis"] + assert.True(t, ex) + assert.Len(t, redis, 1) + v001 := redis[0] + assert.Equal(t, BundleName("redis"), v001.Name) + assert.Equal(t, BundleVersion("0.0.1"), v001.Version) + assert.Equal(t, "Redis service", v001.Description) + +} diff --git a/components/helm-broker/internal/ybundle/populator_test.go b/components/helm-broker/internal/ybundle/populator_test.go new file mode 100644 index 000000000000..deef3f2aef9d --- /dev/null +++ b/components/helm-broker/internal/ybundle/populator_test.go @@ -0,0 +1,213 @@ +package ybundle_test + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "k8s.io/helm/pkg/proto/hapi/chart" + + "github.com/kyma-project/kyma/components/helm-broker/internal" + "github.com/kyma-project/kyma/components/helm-broker/internal/ybundle" + "github.com/kyma-project/kyma/components/helm-broker/internal/ybundle/automock" + "github.com/kyma-project/kyma/components/helm-broker/platform/logger/spy" +) + +func TestPopulatorInitHappyPath(t *testing.T) { + // GIVEN + ts := newTestSuite() + defer ts.AssertExpectationsOnMock(t) + + fixBundleReader := strings.NewReader(fixBundleContent()) + + ts.mockRepository.ExpectOnIndexReader(fixIndexContent()) + ts.mockRepository.ExpectOnBundleReader(fixBundleName(), fixBundleVersion(), fixBundleReader) + ts.mockLoader.ExpectOnLoad(fixBundleReader, fixBundle(), fixCharts()) + ts.mockBundleUpserter.ExpectOnUpsert(fixBundle()) + ts.mockChartUpserter.ExpectOnUpsert(fixChart()) + + logSink := spy.NewLogSink() + + populator := ybundle.NewPopulator(ts.mockRepository, ts.mockLoader, ts.mockBundleUpserter, ts.mockChartUpserter, logSink.Logger) + // WHEN + err := populator.Init() + // THEN + assert.NoError(t, err) + + logSink.AssertLogged(t, logrus.InfoLevel, "Bundle with name [meme] and version [0.10.0] successfully stored") + +} + +func TestPopulatorErrorOnGetIndexFile(t *testing.T) { + // GIVEN + ts := newTestSuite() + defer ts.AssertExpectationsOnMock(t) + + ts.mockRepository.ExpectErrorOnIndexReader(fixError()) + + logSink := spy.NewLogSink() + + populator := ybundle.NewPopulator(ts.mockRepository, ts.mockLoader, ts.mockBundleUpserter, ts.mockChartUpserter, logSink.Logger) + // WHEN + err := populator.Init() + // THEN + assert.EqualError(t, err, fmt.Sprintf("while getting index.yaml: %v", fixError())) + assert.Empty(t, logSink.DumpAll()) +} + +func TestPopulatorErrorOnGetBundleArchive(t *testing.T) { + // GIVEN + ts := newTestSuite() + defer ts.AssertExpectationsOnMock(t) + + ts.mockRepository.ExpectOnIndexReader(fixIndexContent()) + ts.mockRepository.ExpectErrorOnBundleReader(fixError()) + + logSink := spy.NewLogSink() + + populator := ybundle.NewPopulator(ts.mockRepository, ts.mockLoader, ts.mockBundleUpserter, ts.mockChartUpserter, logSink.Logger) + // WHEN + err := populator.Init() + // THEN + assert.EqualError(t, err, fmt.Sprintf("while reading bundle archive for name [meme] and version [0.10.0]: %v", fixError())) + assert.Empty(t, logSink.DumpAll()) +} + +func TestPopulatorErrorOnLoading(t *testing.T) { + // GIVEN + ts := newTestSuite() + defer ts.AssertExpectationsOnMock(t) + + fixBundleReader := strings.NewReader(fixBundleContent()) + + ts.mockRepository.ExpectOnIndexReader(fixIndexContent()) + ts.mockRepository.ExpectOnBundleReader(fixBundleName(), fixBundleVersion(), fixBundleReader) + ts.mockLoader.ExpectErrorOnLoad(fixError()) + + logSink := spy.NewLogSink() + + populator := ybundle.NewPopulator(ts.mockRepository, ts.mockLoader, ts.mockBundleUpserter, ts.mockChartUpserter, logSink.Logger) + // WHEN + err := populator.Init() + // THEN + assert.EqualError(t, err, fmt.Sprintf("while loading bundle and charts for bundle [meme] and version [0.10.0]: %v", fixError())) + assert.Empty(t, logSink.DumpAll()) +} + +func TestPopulatorErrorOnUpsertingChart(t *testing.T) { + // GIVEN + ts := newTestSuite() + defer ts.AssertExpectationsOnMock(t) + + fixBundleReader := strings.NewReader(fixBundleContent()) + + ts.mockRepository.ExpectOnIndexReader(fixIndexContent()) + ts.mockRepository.ExpectOnBundleReader(fixBundleName(), fixBundleVersion(), fixBundleReader) + ts.mockLoader.ExpectOnLoad(fixBundleReader, fixBundle(), fixCharts()) + ts.mockChartUpserter.ExpectErrorOnUpsert(fixError()) + + logSink := spy.NewLogSink() + + populator := ybundle.NewPopulator(ts.mockRepository, ts.mockLoader, ts.mockBundleUpserter, ts.mockChartUpserter, logSink.Logger) + // WHEN + err := populator.Init() + // THEN + assert.EqualError(t, err, fmt.Sprintf("while storing chart [values: ] for bundle [meme] with version [0.10.0]: %v", fixError())) + assert.Empty(t, logSink.DumpAll()) +} + +func TestPopulatorErrorOnUpsertingBundle(t *testing.T) { + // GIVEN + ts := newTestSuite() + defer ts.AssertExpectationsOnMock(t) + + fixBundleReader := strings.NewReader(fixBundleContent()) + + ts.mockRepository.ExpectOnIndexReader(fixIndexContent()) + ts.mockRepository.ExpectOnBundleReader(fixBundleName(), fixBundleVersion(), fixBundleReader) + ts.mockLoader.ExpectOnLoad(fixBundleReader, fixBundle(), fixCharts()) + ts.mockChartUpserter.ExpectOnUpsert(fixChart()) + ts.mockBundleUpserter.ExpectErrorOnUpsert(fixError()) + + logSink := spy.NewLogSink() + + populator := ybundle.NewPopulator(ts.mockRepository, ts.mockLoader, ts.mockBundleUpserter, ts.mockChartUpserter, logSink.Logger) + // WHEN + err := populator.Init() + // THEN + assert.EqualError(t, err, fmt.Sprintf("while storing bundle [meme] with version [0.10.0]: %v", fixError())) + assert.Empty(t, logSink.DumpAll()) +} + +func fixError() error { + return errors.New("some error") +} + +func fixBundleName() ybundle.BundleName { + return "meme" +} + +func fixBundleVersion() ybundle.BundleVersion { + return "0.10.0" +} + +func fixBundleContent() string { + return "data" +} + +func fixBundle() *internal.Bundle { + return &internal.Bundle{} +} + +func fixCharts() []*chart.Chart { + return []*chart.Chart{fixChart()} +} + +func fixChart() *chart.Chart { + return &chart.Chart{ + Values: &chart.Config{ + Raw: "value", + }, + } +} + +func fixIndexContent() string { + return ` +apiVersion: v1 +entries: + meme: + - name: meme + created: 2016-10-06T16:23:20.499814565-06:00 + description: Meme service + version: 0.10.0 +` +} + +type testSuite struct { + mockRepository *automock.Repository + mockLoader *automock.BundleLoader + mockBundleUpserter *automock.BundleUpserter + mockChartUpserter *automock.ChartUpserter +} + +func newTestSuite() testSuite { + return testSuite{ + mockRepository: &automock.Repository{}, + mockLoader: &automock.BundleLoader{}, + mockBundleUpserter: &automock.BundleUpserter{}, + mockChartUpserter: &automock.ChartUpserter{}, + } + +} + +func (ts *testSuite) AssertExpectationsOnMock(t *testing.T) { + t.Helper() + ts.mockRepository.AssertExpectations(t) + ts.mockLoader.AssertExpectations(t) + ts.mockBundleUpserter.AssertExpectations(t) + ts.mockChartUpserter.AssertExpectations(t) + +} diff --git a/components/helm-broker/internal/ybundle/repository.go b/components/helm-broker/internal/ybundle/repository.go new file mode 100644 index 000000000000..9fe342809951 --- /dev/null +++ b/components/helm-broker/internal/ybundle/repository.go @@ -0,0 +1,75 @@ +package ybundle + +import ( + "fmt" + "io" + "io/ioutil" + "net/http" + "strings" + + "github.com/pkg/errors" +) + +// RepositoryConfig provides configuration for HTTP Repository. +type RepositoryConfig struct { + BaseURL string `json:"baseUrl" valid:"required"` +} + +// NewHTTPRepository creates new instance of HTTPRepository. +func NewHTTPRepository(cfg RepositoryConfig) *HTTPRepository { + return &HTTPRepository{ + BaseURL: cfg.BaseURL, + Client: http.DefaultClient, + } +} + +// HTTPRepository represents remote bundle repository which is accessed via HTTP. +type HTTPRepository struct { + BaseURL string + Client interface { + Do(req *http.Request) (*http.Response, error) + } +} + +// IndexReader acquire repository index. +func (p *HTTPRepository) IndexReader() (r io.Reader, closer func(), err error) { + return p.doGetCall("index.yaml") +} + +// BundleReader calls repository for a specific bundle and returns means to read bundle content. +func (p *HTTPRepository) BundleReader(name BundleName, version BundleVersion) (r io.Reader, closer func(), err error) { + bundleFileName := func(n BundleName, v BundleVersion) string { + return fmt.Sprintf("%s-%s.tgz", n, v) + } + + return p.doGetCall(bundleFileName(name, version)) +} + +func (p *HTTPRepository) doGetCall(urlPart string) (r io.Reader, closer func(), err error) { + req, err := http.NewRequest(http.MethodGet, p.fullPath(urlPart), http.NoBody) + if err != nil { + return nil, nil, errors.Wrap(err, "while preparing request") + } + + resp, err := p.Client.Do(req) + if err != nil { + return nil, nil, errors.Wrap(err, "while calling") + } + + if resp.StatusCode >= 400 { + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("got http error: status=%d body unavailable due to error %s", resp.StatusCode, err.Error()) + } + return nil, nil, fmt.Errorf("got http error: status=%d body='%s'", resp.StatusCode, string(body)) + } + + return resp.Body, func() { resp.Body.Close() }, nil +} + +func (p *HTTPRepository) fullPath(part string) string { + normalisedBaseURL := strings.TrimRight(p.BaseURL, "/") + urlParts := []string{normalisedBaseURL, part} + return strings.Join(urlParts, "/") +} diff --git a/components/helm-broker/internal/ybundle/repository_test.go b/components/helm-broker/internal/ybundle/repository_test.go new file mode 100644 index 000000000000..81c2786d7fea --- /dev/null +++ b/components/helm-broker/internal/ybundle/repository_test.go @@ -0,0 +1,66 @@ +package ybundle_test + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kyma-project/kyma/components/helm-broker/internal/ybundle" +) + +func TestHTTPRepository_IndexReader(t *testing.T) { + // GIVEN: + expContentGen := func() string { return "expected content - index" } + + mux := http.NewServeMux() + mux.HandleFunc("/index.yaml", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, expContentGen()) + }) + ts := httptest.NewServer(mux) + defer ts.Close() + + rep := ybundle.NewHTTPRepository(ybundle.RepositoryConfig{BaseURL: ts.URL}) + + // WHEN: + r, clo, err := rep.IndexReader() + + // THEN: + require.NoError(t, err) + defer clo() + + got, err := ioutil.ReadAll(r) + require.NoError(t, err) + + assert.EqualValues(t, expContentGen(), string(got)) +} + +func TestHTTPRepository_BundleReader(t *testing.T) { + const ( + expBundleName ybundle.BundleName = "bundle_name" + expBundleVer ybundle.BundleVersion = "1.2.3" + ) + expContentGen := func() string { return "expected content - bundle" } + + mux := http.NewServeMux() + mux.HandleFunc(fmt.Sprintf("/%s-%s.tgz", expBundleName, expBundleVer), func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, expContentGen()) + }) + ts := httptest.NewServer(mux) + defer ts.Close() + + rep := ybundle.NewHTTPRepository(ybundle.RepositoryConfig{BaseURL: ts.URL}) + + r, clo, err := rep.BundleReader(expBundleName, expBundleVer) + require.NoError(t, err) + defer clo() + + got, err := ioutil.ReadAll(r) + require.NoError(t, err) + + assert.EqualValues(t, expContentGen(), string(got)) +} diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-big-schema.input.tgz b/components/helm-broker/internal/ybundle/testdata/bundle-big-schema.input.tgz new file mode 100644 index 0000000000000000000000000000000000000000..4cf2332b74baa3d632a06a12c2ec26662d2f9580 GIT binary patch literal 11081 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 Q=NCvRaLFOcC<@B|0jF-GnE(I) literal 0 HcmV?d00001 diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-missing-bind-yaml-when-bindable.input.tgz b/components/helm-broker/internal/ybundle/testdata/bundle-missing-bind-yaml-when-bindable.input.tgz new file mode 100644 index 0000000000000000000000000000000000000000..d5bcc56ae6abfc41355bdad5c35684d866d0991f GIT binary patch literal 6435 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 literal 0 HcmV?d00001 diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-missing-chart-dir.input.tgz b/components/helm-broker/internal/ybundle/testdata/bundle-missing-chart-dir.input.tgz new file mode 100644 index 0000000000000000000000000000000000000000..aaacc4f448345a288a6880228cb874a595de3ce0 GIT binary patch literal 1410 zcmV-|1%3J-iwFP(_V!r-1MOOEZ`(Ey&S(7!LK)Bvh~6w&N(>ZR(X?2wZV8;O`%n}G zEz$}bNmNM6ff3}t?>a-EC&M9Xo^kv12T}W6 z00m(HeUd>EC&&+ia1b1z;r>}WIH;w<0$5>T7##b@U+f3nrqi#?vslOS`9Fc{6;xq& zj*EP6G+;mCDEhMeFS6HQ@{_ia$aVqt8`j2@Q+J`$qbh%CFq*Yof*z++D-{= z1WCmOwFpn^f|d&F(SMHLjL?-*l^l6q#?)0kaZ_G+3u4{VfkD?)IPF%*x~jNP%ds$2 z=CDeaOX)5~G_zQ&fR#U>QCB&~#Jy$Ltboa|%SGntQl(2CExp)_CtkCrk8gO!T{+9F z2`dSnED*g)w`u#r)herN=;Gz8WOKA78;HLO5fkG$$06O}?FboqD|MdY0 zkyC-+b&=mJu&fk9Ab1@xoR2Jo9Mp!0zn)*dg(5;t$TdQDcSz9!lt)oyH$js-t@B)W zwvmhO?_KEOj%iLI>T`8`Cm~Fvx_i;(Ue7#rb%J%xl4mQBy=nQZZvG z9n)!7gBD{pva;;|N%FG7>S~0Z0dumVsjI_0`Tpl0e{psH=g->-?4Ld~E$IH4b$90= zHbw3`&5?9#NiVRJw_K2R>W=mCtJAZ~j~6d4FaJ6ppMGf(u)FFvI{#10fXH_dvpdy- zm4-`NHY{x;|A+Pu%mH<$RSH+{)-H5xgZ&3V5;yiAgi#nl2VegTjs5q5J5Vb7hgRS= zBy@{pM~l8w1D(DQfv3vxBd@JHw2$;SHLs{1#3!F?$f0qY_%~8kMzYReDC2 zMW|RwBK7!p7cbG-DPkqMy<+K=wvt*fHAk7?b%kyrX@kZIuS@bG&-pDSXSz1e(FBse zMrVoJkZ)%1*pf=HGP9fN`?l3ypXMuEon4?QnC^n7Ij@%_b)JS42NQ(E6OB(V(OC)l z0a@uk z;!&h z>;7A7zrjQQUsvQi@&Cht@&A3m_j@PlRiG}6 zdxWRb19v2xtY(CIDKDxTB8hHB>A`qwS<Z_WO9eh9dA{x{Hn#{T<&$^T6LXY#+Ni%0zb z&MyRR%>VrL`+sqmnE0<3w4ujm={L9jaO@|?$;q*k_&9K)IEfrQJ@FlIL17#b660aK zb`kK^at5RoYV9iE=T>X&qT9D#|5L{!_P_Iw0RJZb)AzrP{qG+p{xk8PiT|E19`XM> z{|K=8{6AVd|BsW{#DBfudprLh27}e}e@G3?2LJ;D0|NsC0|NsC0|NsC0|NsC0|NsC Q1A}ewH?SK21prV00QGsmegFUf literal 0 HcmV?d00001 diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-missing-plan-meta-file.input.tgz b/components/helm-broker/internal/ybundle/testdata/bundle-missing-plan-meta-file.input.tgz new file mode 100644 index 0000000000000000000000000000000000000000..87b5cbca0e07721e22ca22cd4b1f159d3715fd73 GIT binary patch literal 6397 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 G5dH_MahOa1 literal 0 HcmV?d00001 diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-missing-plans.input.tgz b/components/helm-broker/internal/ybundle/testdata/bundle-missing-plans.input.tgz new file mode 100644 index 0000000000000000000000000000000000000000..76853b12effa4ea17d7a70b66be1e79d84266362 GIT binary patch literal 5558 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 literal 0 HcmV?d00001 diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-no-chart-in-chart-dir.input.tgz b/components/helm-broker/internal/ybundle/testdata/bundle-no-chart-in-chart-dir.input.tgz new file mode 100644 index 0000000000000000000000000000000000000000..5e04abb94b880d77adff129c15b209e41842a438 GIT binary patch literal 1200 zcmV;h1W)@PiwFQ-3g%e=1MQhxZ`(E$$MdXDK{$Ji?l;G8L$ikf?ND@=m!TMFk(Sv= zqC!#$jG^DXM;AM>=f=rks~K{B2_W-M>QG029y#vW$?-fdjbqYXlm6-jQGXpEe(0xu z8Eyf)@VZ&OhtsDKlP^Htq0W>izoUS*0H(%PjK99NtHS2E_i|c zV?PMN{y~_AX#X*A+>#sWtY}%BYz(*`NfdqG{z*94AFS>r3D`gM{rHS{C!5hD-njiM zTJjlDoC$5+aixW}3iyKiwXVj4@ciU9m zrd(dRiJQ#bZcTr`m$`KGU2e@q#pt|%yI@qqt+DLyxN!R`{VM#w=SOLhcK$yK(EpFZ z+Wzpga8)az`SG{Hv)}(4{~slukMzuEnhf@j!U!sO?avVP-x&O35ia)33KdD_@wR3uAkc z+vlB6F4}fxRr9uyoPXh&>GMF?)m$0DHI%fyL*8ATU9(LRG9HP@w*8+U-;wJpA}Vsf z6xot!o;6CC70H!sYH|;v4GQP7so2j&A@4c6ZgY!*%t8EhdzLxf@LligX=$TGl~20t zuYKucx0@$sb^Vbnlq`oitz@$qvejwCNRZ7SJjti4-^g_Z`vX+zGiSl8TvYstv400W z+e&jH7sSBtwR|Rh%XV;>N25UoFQkGhtvo(Bhl_{N7PKi$TU1MJas}4f6!NMmq2#Y2 z4LP(?QqfHf$SGI)wwMg*&;8o0VSN+DZyNzRy6t4J{l%U;|JnNbqjx{D?f*{`3pjNC z_Y;5T{U3)>fc}3B`tyHh{qDd|6EB^nms30SsBcF}3P=CNWqba=Op=I&35|DS03X`M zq}@h40f4T++Bw<&=mjHNY(M`^&0dNAi}Cp%1=N42|4{$EUAFyy7f#p5R)B}}Ul4^m z@!vT06ZHRMaDGnS$!a0;Mse_GS~e%2$FOQ}{f;s?_YqRYwZW=3MK z9PLxcn`BS?|L8%$7wf;E{ln*f3{d}}{zLuucG=_qj~)a(sQ>=;{V$0;{Qffvef;mi z`cDJ_JsVt5m_&>vG){JcfWK^d*cStfIIfAk>Wf6;$|kI(-Yp#DSshx+gB zvd8})JqUO>{uk}U|B^Jp_}?hJw)kJ{2an@_a5TUGAc7zWf*=TjAP9mW2!bF8f*=Tj OJeP0hU1R(JPyhgIPkFEa literal 0 HcmV?d00001 diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/.helmignore b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/.helmignore new file mode 100644 index 000000000000..6b8710a711f3 --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/.helmignore @@ -0,0 +1 @@ +.git diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/Chart.yaml b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/Chart.yaml new file mode 100644 index 000000000000..4159b7dcdb21 --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/Chart.yaml @@ -0,0 +1,16 @@ +name: redis +version: 0.10.1 +appVersion: 3.2.9 +description: Open source, advanced key-value store. It is often referred to as a data structure server since keys can contain strings, hashes, lists, sets and sorted sets. +keywords: +- redis +- keyvalue +- database +home: http://redis.io/ +icon: https://bitnami.com/assets/stacks/redis/img/redis-stack-220x234.png +sources: +- https://github.com/bitnami/bitnami-docker-redis +maintainers: +- name: bitnami-bot + email: containers@bitnami.com +engine: gotpl diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/README.md b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/README.md new file mode 100644 index 000000000000..a92fe50db3a3 --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/README.md @@ -0,0 +1,120 @@ +# Redis + +[Redis](http://redis.io/) is an advanced key-value cache and store. It is often referred to as a data structure server since keys can contain strings, hashes, lists, sets, sorted sets, bitmaps and hyperloglogs. + +## TL;DR; + +```bash +$ helm install stable/redis +``` + +## Introduction + +This chart bootstraps a [Redis](https://github.com/bitnami/bitnami-docker-redis) deployment on a [Kubernetes](http://kubernetes.io) cluster using the [Helm](https://helm.sh) package manager. + +## Prerequisites + +- Kubernetes 1.4+ with Beta APIs enabled +- PV provisioner support in the underlying infrastructure + +## Installing the Chart + +To install the chart with the release name `my-release`: + +```bash +$ helm install --name my-release stable/redis +``` + +The command deploys Redis on the Kubernetes cluster in the default configuration. The [configuration](#configuration) section lists the parameters that can be configured during installation. + +> **Tip**: List all releases using `helm list` + +## Uninstalling the Chart + +To uninstall/delete the `my-release` deployment: + +```bash +$ helm delete my-release +``` + +The command removes all the Kubernetes components associated with the chart and deletes the release. + +## Configuration + +The following tables lists the configurable parameters of the Redis chart and their default values. + +| Parameter | Description | Default | +| -------------------------- | ------------------------------------- | --------------------------------------------------------- | +| `image` | Redis image | `bitnami/redis:{VERSION}` | +| `imagePullPolicy` | Image pull policy | `IfNotPresent` | +| `usePassword` | Use password | `true` | +| `redisPassword` | Redis password | Randomly generated | +| `args` | Redis command-line args | [] | +| `persistence.enabled` | Use a PVC to persist data | `true` | +| `persistence.existingClaim`| Use an existing PVC to persist data | `nil` | +| `persistence.storageClass` | Storage class of backing PVC | `generic` | +| `persistence.accessMode` | Use volume as ReadOnly or ReadWrite | `ReadWriteOnce` | +| `persistence.size` | Size of data volume | `8Gi` | +| `resources` | CPU/Memory resource requests/limits | Memory: `256Mi`, CPU: `100m` | +| `metrics.enabled` | Start a side-car prometheus exporter | `false` | +| `metrics.image` | Exporter image | `oliver006/redis_exporter` | +| `metrics.imageTag` | Exporter image | `v0.11` | +| `metrics.imagePullPolicy` | Exporter image pull policy | `IfNotPresent` | +| `metrics.resources` | Exporter resource requests/limit | Memory: `256Mi`, CPU: `100m` | +| `nodeSelector` | Node labels for pod assignment | {} | +| `tolerations` | Toleration labels for pod assignment | [] | +| `networkPolicy.enabled` | Enable NetworkPolicy | `false` | +| `networkPolicy.allowExternal` | Don't require client label for connections | `true` | + +The above parameters map to the env variables defined in [bitnami/redis](http://github.com/bitnami/bitnami-docker-redis). For more information please refer to the [bitnami/redis](http://github.com/bitnami/bitnami-docker-redis) image documentation. + +Specify each parameter using the `--set key=value[,key=value]` argument to `helm install`. For example, + +```bash +$ helm install --name my-release \ + --set redisPassword=secretpassword \ + stable/redis +``` + +The above command sets the Redis server password to `secretpassword`. + +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/redis +``` + +> **Tip**: You can use the default [values.yaml](values.yaml) + +## NetworkPolicy + +To enable network policy for Redis, install +[a networking plugin that implements the Kubernetes NetworkPolicy spec](https://kubernetes.io/docs/tasks/administer-cluster/declare-network-policy#before-you-begin), +and set `networkPolicy.enabled` to `true`. + +For Kubernetes v1.5 & v1.6, you must also turn on NetworkPolicy by setting +the DefaultDeny namespace annotation. Note: this will enforce policy for _all_ pods in the namespace: + + kubectl annotate namespace default "net.beta.kubernetes.io/network-policy={\"ingress\":{\"isolation\":\"DefaultDeny\"}}" + +With NetworkPolicy enabled, only pods with the generated client label will be +able to connect to Redis. This label will be displayed in the output +after a successful install. + +## Persistence + +The [Bitnami Redis](https://github.com/bitnami/bitnami-docker-redis) image stores the Redis data and configurations at the `/bitnami/redis` path of the container. + +By default, the chart mounts a [Persistent Volume](http://kubernetes.io/docs/user-guide/persistent-volumes/) volume at this location. The volume is created using dynamic volume provisioning. If a Persistent Volume Claim already exists, specify it during installation. + +### Existing PersistentVolumeClaim + +1. Create the PersistentVolume +1. Create the PersistentVolumeClaim +1. Install the chart +```bash +$ helm install --set persistence.existingClaim=PVC_NAME redis +``` + +## Metrics +The chart optionally can start a metrics exporter for [prometheus](https://prometheus.io). The metrics endpoint (port 9121) is not exposed and it is expected that the metrics are collected from inside the k8s cluster using something similar as the described in the [example Prometheus scrape configuration](https://github.com/prometheus/prometheus/blob/master/documentation/examples/prometheus-kubernetes.yml). diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/NOTES.txt b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/NOTES.txt new file mode 100644 index 000000000000..1110b6ba8b49 --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/NOTES.txt @@ -0,0 +1,28 @@ +Redis can be accessed via port 6379 on the following DNS name from within your cluster: +{{ template "redis.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local + +{{- if .Values.usePassword }} +To get your password run: + + REDIS_PASSWORD=$(kubectl get secret --namespace {{ .Release.Namespace }} {{ template "redis.fullname" . }} -o jsonpath="{.data.redis-password}" | base64 --decode) +{{- end }} + +To connect to your Redis server: + +1. Run a Redis pod that you can use as a client: + + kubectl run {{ template "redis.fullname" . }}-client --rm --tty -i \ + {{ if .Values.usePassword }} --env REDIS_PASSWORD=$REDIS_PASSWORD{{ end }} + {{- if and (.Values.networkPolicy.enabled) (not .Values.networkPolicy.allowExternal) }}--labels="{{ template "redis.fullname" . }}-client=true" \{{- end }} + --image {{ .Values.image }} -- bash + +2. Connect using the Redis CLI: + + redis-cli -h {{ template "redis.fullname" . }}{{ if .Values.usePassword }} -a $REDIS_PASSWORD{{ end }} + +{{ if and (.Values.networkPolicy.enabled) (not .Values.networkPolicy.allowExternal) }} +Note: Since NetworkPolicy is enabled, only pods with label +{{ template "redis.fullname" . }}-client=true" +will be able to connect to redis. +{{- end }} + diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/_helpers.tpl b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/_helpers.tpl new file mode 100644 index 000000000000..f96369929fa2 --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/_helpers.tpl @@ -0,0 +1,27 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "redis.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). +*/}} +{{- define "redis.fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Return the appropriate apiVersion for networkpolicy. +*/}} +{{- define "networkPolicy.apiVersion" -}} +{{- if and (ge .Capabilities.KubeVersion.Minor "4") (le .Capabilities.KubeVersion.Minor "6") -}} +{{- print "extensions/v1beta1" -}} +{{- else if ge .Capabilities.KubeVersion.Minor "7" -}} +{{- print "networking.k8s.io/v1" -}} +{{- end -}} +{{- end -}} \ No newline at end of file diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/deployment.yaml b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/deployment.yaml new file mode 100644 index 000000000000..4a9bca2996e6 --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/deployment.yaml @@ -0,0 +1,92 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: {{ template "redis.fullname" . }} + labels: + app: {{ template "redis.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +spec: + template: + metadata: + labels: + app: {{ template "redis.fullname" . }} + spec: + {{- if .Values.nodeSelector }} + nodeSelector: +{{ toYaml .Values.nodeSelector | indent 8 }} + {{- end }} + {{- if .Values.tolerations }} + tolerations: +{{ toYaml .Values.tolerations | indent 8 }} + {{- end }} + containers: + - name: {{ template "redis.fullname" . }} + image: "{{ .Values.image }}" + imagePullPolicy: {{ default "" .Values.imagePullPolicy | quote }} + {{- if .Values.args }} + args: +{{ toYaml .Values.args | indent 10 }} + {{- end }} + env: + {{- if .Values.usePassword }} + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "redis.fullname" . }} + key: redis-password + {{- else }}f + - name: ALLOW_EMPTY_PASSWORD + value: "yes" + {{- end }} + ports: + - name: redis + containerPort: 6379 + livenessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 30 + timeoutSeconds: 5 + readinessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 5 + timeoutSeconds: 1 + resources: +{{ toYaml .Values.resources | indent 10 }} + volumeMounts: + - name: redis-data + mountPath: /bitnami/redis +{{- if .Values.metrics.enabled }} + - name: metrics + image: "{{ .Values.metrics.image }}:{{ .Values.metrics.imageTag }}" + imagePullPolicy: {{ .Values.metrics.imagePullPolicy | quote }} + env: + - name: REDIS_ALIAS + value: {{ template "redis.fullname" . }} + {{- if .Values.usePassword }} + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "redis.fullname" . }} + key: redis-password + {{- end }} + ports: + - name: metrics + containerPort: 9121 + resources: +{{ toYaml .Values.metrics.resources | indent 10 }} +{{- end }} + volumes: + - name: redis-data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ .Values.persistence.existingClaim | default (include "redis.fullname" .) }} + {{- else }} + emptyDir: {} + {{- end -}} diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/networkpolicy.yaml b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/networkpolicy.yaml new file mode 100644 index 000000000000..eb6640f073ac --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/networkpolicy.yaml @@ -0,0 +1,30 @@ +{{- if .Values.networkPolicy.enabled }} +kind: NetworkPolicy +apiVersion: {{ template "networkPolicy.apiVersion" . }} +metadata: + name: "{{ template "redis.fullname" . }}" + labels: + app: {{ template "redis.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +spec: + podSelector: + matchLabels: + app: {{ template "redis.fullname" . }} + ingress: + # Allow inbound connections + - ports: + - port: 6379 + {{- if not .Values.networkPolicy.allowExternal }} + from: + - podSelector: + matchLabels: + {{ template "redis.fullname" . }}-client: "true" + {{- end }} + {{- if .Values.metrics.enabled }} + # Allow prometheus scrapes for metrics + - ports: + - port: 9121 + {{- end }} +{{- end }} diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/pvc.yaml b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/pvc.yaml new file mode 100644 index 000000000000..27de14b46d64 --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/pvc.yaml @@ -0,0 +1,24 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +kind: PersistentVolumeClaim +apiVersion: v1 +metadata: + name: {{ template "redis.fullname" . }} + labels: + app: {{ template "redis.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +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/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/secrets.yaml b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/secrets.yaml new file mode 100644 index 000000000000..6ab6b053e52c --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/secrets.yaml @@ -0,0 +1,18 @@ +{{- if .Values.usePassword -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ template "redis.fullname" . }} + labels: + app: {{ template "redis.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +type: Opaque +data: + {{- if .Values.redisPassword }} + redis-password: {{ .Values.redisPassword | b64enc | quote }} + {{- else }} + redis-password: {{ randAlphaNum 10 | b64enc | quote }} + {{- end }} +{{- end -}} diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/svc.yaml b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/svc.yaml new file mode 100644 index 000000000000..5081e1ebae5b --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/templates/svc.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ template "redis.fullname" . }} + labels: + app: {{ template "redis.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +{{- if .Values.metrics.enabled }} + annotations: +{{ toYaml .Values.metrics.annotations | indent 4 }} +{{- end }} +spec: + ports: + - name: redis + port: 6379 + targetPort: redis + selector: + app: {{ template "redis.fullname" . }} diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/values.yaml b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/values.yaml new file mode 100644 index 000000000000..1bc35d537907 --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/chart/redis/values.yaml @@ -0,0 +1,86 @@ +## Bitnami Redis image version +## ref: https://hub.docker.com/r/bitnami/redis/tags/ +## +image: bitnami/redis:3.2.9-r2 + +## Specify a imagePullPolicy +## ref: http://kubernetes.io/docs/user-guide/images/#pre-pulling-images +## +imagePullPolicy: IfNotPresent + +## Use password authentication +usePassword: true + +## Redis password +## Defaults to a random 10-character alphanumeric string if not set and usePassword is true +## ref: https://github.com/bitnami/bitnami-docker-redis#setting-the-server-password-on-first-run +## +# redisPassword: + +## Redis command arguments +## +## Can be used to specify command line arguments, for example: +## +## args: +## - "redis-server" +## - "--maxmemory-policy volatile-ttl" +args: + +## Enable persistence using Persistent Volume Claims +## ref: http://kubernetes.io/docs/user-guide/persistent-volumes/ +## +persistence: + enabled: true + + ## A manually managed Persistent Volume and Claim + ## Requires persistence.enabled: true + ## If defined, PVC must be created manually before volume will be bound + # existingClaim: + + ## redis 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 + +metrics: + enabled: false + image: oliver006/redis_exporter + imageTag: v0.11 + imagePullPolicy: IfNotPresent + resources: {} + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9121" + +## Configure resource requests and limits +## ref: http://kubernetes.io/docs/user-guide/compute-resources/ +## +resources: + requests: + memory: 256Mi + cpu: 100m + +## Node labels and tolerations for pod assignment +## ref: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#nodeselector +## ref: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#taints-and-tolerations-beta-feature +nodeSelector: {} +tolerations: [] + +networkPolicy: + ## Enable creation of NetworkPolicy resources. + ## + enabled: false + + ## The Policy model to apply. When set to false, only pods with the correct + ## client label will have network access to the port PostgreSQL is listening + ## on. When true, Redis will accept connections from any source + ## (with the correct destination port). + ## + allowExternal: true + diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/meta.yaml b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/meta.yaml new file mode 100644 index 000000000000..54e31d93db08 --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/meta.yaml @@ -0,0 +1,13 @@ +name: redis +version: 0.0.1 +id: id-09834-abcd-234 +description: "Redis package" +displayName: Redis + +tags: database, cache +providerDisplayName: bitnami +longDescription: Redis is an advanced key-value cache and store +documentationURL: https://github.com/bitnami/bitnami-docker-redis +supportURL: http://slack.oss.bitnami.com/ +imageURL: https://upload.wikimedia.org/wikipedia/en/6/6b/Redis_Logo.svg +bindable: true diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/enterprise/bind.yaml b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/enterprise/bind.yaml new file mode 100644 index 000000000000..21e64f5c010f --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/enterprise/bind.yaml @@ -0,0 +1,15 @@ +credential: + - name: HOST + value: {{ template "redis.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local + - name: PORT + valueFrom: + serviceRef: + name: {{ template "redis.fullname" . }} + jsonpath: '{ .spec.ports[?(@.name=="redis")].port }' +{{- if .Values.usePassword }} + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "redis.fullname" . }} + key: redis-password +{{- end }} diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/enterprise/create-instance-schema.json b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/enterprise/create-instance-schema.json new file mode 100644 index 000000000000..90ede26b3eb0 --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/enterprise/create-instance-schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "vpcId": { + "description": "VPC ID in which security group with inboundAllowedIp will be created.", + "type": "string" + }, + "inboundAllowedIp": { + "description": "IP from which you will be able to reach RDS Instance.", + "type": "string" + }, + "engine": { + "description": "The name of the database engine to be used for this instance.", + "type": "string", + "default": "postgres", + "enum": [ + "aurora", + "mysql" + ] + } + }, + "required": ["vpcId", "inboundAllowedIp"] +} \ No newline at end of file diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/enterprise/meta.yaml b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/enterprise/meta.yaml new file mode 100644 index 000000000000..dbdc52981c77 --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/enterprise/meta.yaml @@ -0,0 +1,6 @@ +name: enterprise +id: a6078798-70a1-4674-af90-aba364dd6a56 +description: "Enterprise plan" +displayName: Enterprise + +bindable: true \ No newline at end of file diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/enterprise/update-instance-schema.json b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/enterprise/update-instance-schema.json new file mode 100644 index 000000000000..90ede26b3eb0 --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/enterprise/update-instance-schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "vpcId": { + "description": "VPC ID in which security group with inboundAllowedIp will be created.", + "type": "string" + }, + "inboundAllowedIp": { + "description": "IP from which you will be able to reach RDS Instance.", + "type": "string" + }, + "engine": { + "description": "The name of the database engine to be used for this instance.", + "type": "string", + "default": "postgres", + "enum": [ + "aurora", + "mysql" + ] + } + }, + "required": ["vpcId", "inboundAllowedIp"] +} \ No newline at end of file diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/enterprise/values.yaml b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/enterprise/values.yaml new file mode 100644 index 000000000000..0815ef636f56 --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/enterprise/values.yaml @@ -0,0 +1,7 @@ +## Configure resource requests and limits +## ref: http://kubernetes.io/docs/user-guide/compute-resources/ +## +resources: + requests: + memory: 512Mi + cpu: 200m diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/micro/bind.yaml b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/micro/bind.yaml new file mode 100644 index 000000000000..21e64f5c010f --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/micro/bind.yaml @@ -0,0 +1,15 @@ +credential: + - name: HOST + value: {{ template "redis.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local + - name: PORT + valueFrom: + serviceRef: + name: {{ template "redis.fullname" . }} + jsonpath: '{ .spec.ports[?(@.name=="redis")].port }' +{{- if .Values.usePassword }} + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "redis.fullname" . }} + key: redis-password +{{- end }} diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/micro/create-instance-schema.json b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/micro/create-instance-schema.json new file mode 100644 index 000000000000..90ede26b3eb0 --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/micro/create-instance-schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "vpcId": { + "description": "VPC ID in which security group with inboundAllowedIp will be created.", + "type": "string" + }, + "inboundAllowedIp": { + "description": "IP from which you will be able to reach RDS Instance.", + "type": "string" + }, + "engine": { + "description": "The name of the database engine to be used for this instance.", + "type": "string", + "default": "postgres", + "enum": [ + "aurora", + "mysql" + ] + } + }, + "required": ["vpcId", "inboundAllowedIp"] +} \ No newline at end of file diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/micro/meta.yaml b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/micro/meta.yaml new file mode 100644 index 000000000000..c459e43ebd09 --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/micro/meta.yaml @@ -0,0 +1,6 @@ +name: micro +id: a6078798-70a1-4674-af94-ab9664d36a54 +description: "Micro plan" +displayName: Micro + +bindable: true \ No newline at end of file diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/micro/update-instance-schema.json b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/micro/update-instance-schema.json new file mode 100644 index 000000000000..90ede26b3eb0 --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/micro/update-instance-schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "vpcId": { + "description": "VPC ID in which security group with inboundAllowedIp will be created.", + "type": "string" + }, + "inboundAllowedIp": { + "description": "IP from which you will be able to reach RDS Instance.", + "type": "string" + }, + "engine": { + "description": "The name of the database engine to be used for this instance.", + "type": "string", + "default": "postgres", + "enum": [ + "aurora", + "mysql" + ] + } + }, + "required": ["vpcId", "inboundAllowedIp"] +} \ No newline at end of file diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/micro/values.yaml b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/micro/values.yaml new file mode 100644 index 000000000000..1a0694f7bd58 --- /dev/null +++ b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.golden/plans/micro/values.yaml @@ -0,0 +1,7 @@ +## Configure resource requests and limits +## ref: http://kubernetes.io/docs/user-guide/compute-resources/ +## +resources: + requests: + memory: 128Mi + cpu: 50m diff --git a/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.input.tgz b/components/helm-broker/internal/ybundle/testdata/bundle-redis-0.0.1.input.tgz new file mode 100644 index 0000000000000000000000000000000000000000..6181dbcdcf8b63171cce5fc97955e14ade56583f GIT binary patch literal 7651 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 ... + + the generators comma separated to run (deepcopy,defaulter,client,lister,informer) or "all". + the output package name (e.g. github.com/example/project/pkg/generated). + the external types dir (e.g. github.com/example/api or github.com/example/project/pkg/apis). + the groups and their versions in the format "groupA:v1,v2 groupB:v1 groupC:v2", relative + to . + ... arbitrary flags passed to all generator binaries. + + +Examples: + $(basename $0) all github.com/example/project/pkg/client github.com/example/project/pkg/apis "foo:v1 bar:v1alpha1,v1beta1" + $(basename $0) deepcopy,client github.com/example/project/pkg/client github.com/example/project/pkg/apis "foo:v1 bar:v1alpha1,v1beta1" +EOF + exit 0 +fi + +GENS="$1" +OUTPUT_PKG="$2" +APIS_PKG="$3" +GROUPS_WITH_VERSIONS="$4" +shift 4 + +go install ./vendor/k8s.io/code-generator/cmd/{defaulter-gen,client-gen,lister-gen,informer-gen,deepcopy-gen} +function codegen::join() { local IFS="$1"; shift; echo "$*"; } + +# enumerate group versions +FQ_APIS=() # e.g. k8s.io/api/apps/v1 +for GVs in ${GROUPS_WITH_VERSIONS}; do + IFS=: read G Vs <<<"${GVs}" + + # enumerate versions + for V in ${Vs//,/ }; do + FQ_APIS+=(${APIS_PKG}/${G}/${V}) + done +done + +if [ "${GENS}" = "all" ] || grep -qw "deepcopy" <<<"${GENS}"; then + echo "Generating deepcopy funcs" + ${GOPATH}/bin/deepcopy-gen --input-dirs $(codegen::join , "${FQ_APIS[@]}") -O zz_generated.deepcopy --bounding-dirs ${APIS_PKG} "$@" +fi + +if [ "${GENS}" = "all" ] || grep -qw "client" <<<"${GENS}"; then + echo "Generating clientset for ${GROUPS_WITH_VERSIONS} at ${OUTPUT_PKG}/clientset" + ${GOPATH}/bin/client-gen --clientset-name versioned --input-base "" --input $(codegen::join , "${FQ_APIS[@]}") --clientset-path ${OUTPUT_PKG}/clientset "$@" +fi + +if [ "${GENS}" = "all" ] || grep -qw "lister" <<<"${GENS}"; then + echo "Generating listers for ${GROUPS_WITH_VERSIONS} at ${OUTPUT_PKG}/listers" + ${GOPATH}/bin/lister-gen --input-dirs $(codegen::join , "${FQ_APIS[@]}") --output-package ${OUTPUT_PKG}/listers "$@" +fi + +if [ "${GENS}" = "all" ] || grep -qw "informer" <<<"${GENS}"; then + echo "Generating informers for ${GROUPS_WITH_VERSIONS} at ${OUTPUT_PKG}/informers" + ${GOPATH}/bin/informer-gen \ + --input-dirs $(codegen::join , "${FQ_APIS[@]}") \ + --versioned-clientset-package ${OUTPUT_PKG}/clientset/versioned \ + --listers-package ${OUTPUT_PKG}/listers \ + --output-package ${OUTPUT_PKG}/informers \ + "$@" +fi \ No newline at end of file diff --git a/components/idppreset/hack/update-codegen.sh b/components/idppreset/hack/update-codegen.sh new file mode 100755 index 000000000000..cea3a6042994 --- /dev/null +++ b/components/idppreset/hack/update-codegen.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -o errexit +set -o nounset +set -o pipefail + +SCRIPT_ROOT=$(dirname ${BASH_SOURCE})/.. +CODEGEN_PKG=${CODEGEN_PKG:-$(cd ${SCRIPT_ROOT}; ls -d -1 ./vendor/k8s.io/code-generator 2>/dev/null || echo ${GOPATH}/src/k8s.io/code-generator)} +REB_ROOT_PKG="github.com/kyma-project/kyma/components/idppreset/pkg" + +./hack/generate-groups.sh all \ + ${REB_ROOT_PKG}/client ${REB_ROOT_PKG}/apis \ + ui:v1alpha1 \ + --go-header-file ${SCRIPT_ROOT}/hack/boilerplate.go.txt diff --git a/components/idppreset/pkg/apis/ui/v1alpha1/doc.go b/components/idppreset/pkg/apis/ui/v1alpha1/doc.go new file mode 100644 index 000000000000..340d79da691f --- /dev/null +++ b/components/idppreset/pkg/apis/ui/v1alpha1/doc.go @@ -0,0 +1,5 @@ +// +k8s:deepcopy-gen=package,register + +// +groupName=ui.kyma.cx + +package v1alpha1 diff --git a/components/idppreset/pkg/apis/ui/v1alpha1/register.go b/components/idppreset/pkg/apis/ui/v1alpha1/register.go new file mode 100644 index 000000000000..a643fea5e0f9 --- /dev/null +++ b/components/idppreset/pkg/apis/ui/v1alpha1/register.go @@ -0,0 +1,39 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// SchemeGroupVersion is group version used to register these objects +var SchemeGroupVersion = schema.GroupVersion{Group: "ui.kyma.cx", Version: "v1alpha1"} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} + +var ( + // localSchemeBuilder and AddToScheme will stay in k8s.io/kubernetes. + SchemeBuilder runtime.SchemeBuilder + localSchemeBuilder = &SchemeBuilder + AddToScheme = localSchemeBuilder.AddToScheme +) + +func init() { + // We only register manually written functions here. The registration of the + // generated functions takes place in the generated files. The separation + // makes the code compile even when the generated files are missing. + localSchemeBuilder.Register(addKnownTypes) +} + +// Adds the list of known types to api.Scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &IDPPreset{}, + &IDPPresetList{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/components/idppreset/pkg/apis/ui/v1alpha1/types.go b/components/idppreset/pkg/apis/ui/v1alpha1/types.go new file mode 100644 index 000000000000..9cb4afe5e10b --- /dev/null +++ b/components/idppreset/pkg/apis/ui/v1alpha1/types.go @@ -0,0 +1,36 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// +genclient +// +genclient:noStatus +// +genclient:nonNamespaced +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +type IDPPreset struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` + Spec IDPPresetSpec `json:"spec"` +} + +func (rem *IDPPreset) GetObjectKind() schema.ObjectKind { + return &IDPPreset{} +} + +type IDPPresetSpec struct { + Name string `json:"name"` + Issuer string `json:"issuer"` + JwksUri string `json:"jwksUri"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +type IDPPresetList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + Items []IDPPreset `json:"items"` +} diff --git a/components/idppreset/pkg/apis/ui/v1alpha1/zz_generated.deepcopy.go b/components/idppreset/pkg/apis/ui/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 000000000000..114df405f000 --- /dev/null +++ b/components/idppreset/pkg/apis/ui/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,85 @@ +// +build !ignore_autogenerated + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + 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 *IDPPreset) DeepCopyInto(out *IDPPreset) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IDPPreset. +func (in *IDPPreset) DeepCopy() *IDPPreset { + if in == nil { + return nil + } + out := new(IDPPreset) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *IDPPreset) 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 *IDPPresetList) DeepCopyInto(out *IDPPresetList) { + *out = *in + out.TypeMeta = in.TypeMeta + out.ListMeta = in.ListMeta + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]IDPPreset, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IDPPresetList. +func (in *IDPPresetList) DeepCopy() *IDPPresetList { + if in == nil { + return nil + } + out := new(IDPPresetList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *IDPPresetList) 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 *IDPPresetSpec) DeepCopyInto(out *IDPPresetSpec) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IDPPresetSpec. +func (in *IDPPresetSpec) DeepCopy() *IDPPresetSpec { + if in == nil { + return nil + } + out := new(IDPPresetSpec) + in.DeepCopyInto(out) + return out +} diff --git a/components/idppreset/pkg/client/clientset/versioned/clientset.go b/components/idppreset/pkg/client/clientset/versioned/clientset.go new file mode 100644 index 000000000000..04b201632b98 --- /dev/null +++ b/components/idppreset/pkg/client/clientset/versioned/clientset.go @@ -0,0 +1,84 @@ +// Code generated by client-gen. DO NOT EDIT. + +package versioned + +import ( + glog "github.com/golang/glog" + uiv1alpha1 "github.com/kyma-project/kyma/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1" + discovery "k8s.io/client-go/discovery" + rest "k8s.io/client-go/rest" + flowcontrol "k8s.io/client-go/util/flowcontrol" +) + +type Interface interface { + Discovery() discovery.DiscoveryInterface + UiV1alpha1() uiv1alpha1.UiV1alpha1Interface + // Deprecated: please explicitly pick a version if possible. + Ui() uiv1alpha1.UiV1alpha1Interface +} + +// Clientset contains the clients for groups. Each group has exactly one +// version included in a Clientset. +type Clientset struct { + *discovery.DiscoveryClient + uiV1alpha1 *uiv1alpha1.UiV1alpha1Client +} + +// UiV1alpha1 retrieves the UiV1alpha1Client +func (c *Clientset) UiV1alpha1() uiv1alpha1.UiV1alpha1Interface { + return c.uiV1alpha1 +} + +// Deprecated: Ui retrieves the default version of UiClient. +// Please explicitly pick a version. +func (c *Clientset) Ui() uiv1alpha1.UiV1alpha1Interface { + return c.uiV1alpha1 +} + +// Discovery retrieves the DiscoveryClient +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + if c == nil { + return nil + } + return c.DiscoveryClient +} + +// NewForConfig creates a new Clientset for the given config. +func NewForConfig(c *rest.Config) (*Clientset, error) { + configShallowCopy := *c + if configShallowCopy.RateLimiter == nil && configShallowCopy.QPS > 0 { + configShallowCopy.RateLimiter = flowcontrol.NewTokenBucketRateLimiter(configShallowCopy.QPS, configShallowCopy.Burst) + } + var cs Clientset + var err error + cs.uiV1alpha1, err = uiv1alpha1.NewForConfig(&configShallowCopy) + if err != nil { + return nil, err + } + + cs.DiscoveryClient, err = discovery.NewDiscoveryClientForConfig(&configShallowCopy) + if err != nil { + glog.Errorf("failed to create the DiscoveryClient: %v", err) + return nil, err + } + return &cs, nil +} + +// NewForConfigOrDie creates a new Clientset for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *Clientset { + var cs Clientset + cs.uiV1alpha1 = uiv1alpha1.NewForConfigOrDie(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClientForConfigOrDie(c) + return &cs +} + +// New creates a new Clientset for the given RESTClient. +func New(c rest.Interface) *Clientset { + var cs Clientset + cs.uiV1alpha1 = uiv1alpha1.New(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClient(c) + return &cs +} diff --git a/components/idppreset/pkg/client/clientset/versioned/doc.go b/components/idppreset/pkg/client/clientset/versioned/doc.go new file mode 100644 index 000000000000..0e0c2a8900e2 --- /dev/null +++ b/components/idppreset/pkg/client/clientset/versioned/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated clientset. +package versioned diff --git a/components/idppreset/pkg/client/clientset/versioned/fake/clientset_generated.go b/components/idppreset/pkg/client/clientset/versioned/fake/clientset_generated.go new file mode 100644 index 000000000000..e6ba55b69ffd --- /dev/null +++ b/components/idppreset/pkg/client/clientset/versioned/fake/clientset_generated.go @@ -0,0 +1,65 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + clientset "github.com/kyma-project/kyma/components/idppreset/pkg/client/clientset/versioned" + uiv1alpha1 "github.com/kyma-project/kyma/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1" + fakeuiv1alpha1 "github.com/kyma-project/kyma/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/fake" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/discovery" + fakediscovery "k8s.io/client-go/discovery/fake" + "k8s.io/client-go/testing" +) + +// NewSimpleClientset returns a clientset that will respond with the provided objects. +// It's backed by a very simple object tracker that processes creates, updates and deletions as-is, +// without applying any validations and/or defaults. It shouldn't be considered a replacement +// for a real clientset and is mostly useful in simple unit tests. +func NewSimpleClientset(objects ...runtime.Object) *Clientset { + o := testing.NewObjectTracker(scheme, codecs.UniversalDecoder()) + for _, obj := range objects { + if err := o.Add(obj); err != nil { + panic(err) + } + } + + fakePtr := testing.Fake{} + fakePtr.AddReactor("*", "*", testing.ObjectReaction(o)) + fakePtr.AddWatchReactor("*", func(action testing.Action) (handled bool, ret watch.Interface, err error) { + gvr := action.GetResource() + ns := action.GetNamespace() + watch, err := o.Watch(gvr, ns) + if err != nil { + return false, nil, err + } + return true, watch, nil + }) + + return &Clientset{fakePtr, &fakediscovery.FakeDiscovery{Fake: &fakePtr}} +} + +// Clientset implements clientset.Interface. Meant to be embedded into a +// struct to get a default implementation. This makes faking out just the method +// you want to test easier. +type Clientset struct { + testing.Fake + discovery *fakediscovery.FakeDiscovery +} + +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + return c.discovery +} + +var _ clientset.Interface = &Clientset{} + +// UiV1alpha1 retrieves the UiV1alpha1Client +func (c *Clientset) UiV1alpha1() uiv1alpha1.UiV1alpha1Interface { + return &fakeuiv1alpha1.FakeUiV1alpha1{Fake: &c.Fake} +} + +// Ui retrieves the UiV1alpha1Client +func (c *Clientset) Ui() uiv1alpha1.UiV1alpha1Interface { + return &fakeuiv1alpha1.FakeUiV1alpha1{Fake: &c.Fake} +} diff --git a/components/idppreset/pkg/client/clientset/versioned/fake/doc.go b/components/idppreset/pkg/client/clientset/versioned/fake/doc.go new file mode 100644 index 000000000000..3630ed1cd17d --- /dev/null +++ b/components/idppreset/pkg/client/clientset/versioned/fake/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated fake clientset. +package fake diff --git a/components/idppreset/pkg/client/clientset/versioned/fake/register.go b/components/idppreset/pkg/client/clientset/versioned/fake/register.go new file mode 100644 index 000000000000..780d1e87e59e --- /dev/null +++ b/components/idppreset/pkg/client/clientset/versioned/fake/register.go @@ -0,0 +1,38 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + uiv1alpha1 "github.com/kyma-project/kyma/components/idppreset/pkg/apis/ui/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var scheme = runtime.NewScheme() +var codecs = serializer.NewCodecFactory(scheme) +var parameterCodec = runtime.NewParameterCodec(scheme) + +func init() { + v1.AddToGroupVersion(scheme, schema.GroupVersion{Version: "v1"}) + AddToScheme(scheme) +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +func AddToScheme(scheme *runtime.Scheme) { + uiv1alpha1.AddToScheme(scheme) +} diff --git a/components/idppreset/pkg/client/clientset/versioned/scheme/doc.go b/components/idppreset/pkg/client/clientset/versioned/scheme/doc.go new file mode 100644 index 000000000000..14db57a58f8d --- /dev/null +++ b/components/idppreset/pkg/client/clientset/versioned/scheme/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package contains the scheme of the automatically generated clientset. +package scheme diff --git a/components/idppreset/pkg/client/clientset/versioned/scheme/register.go b/components/idppreset/pkg/client/clientset/versioned/scheme/register.go new file mode 100644 index 000000000000..090c1531503a --- /dev/null +++ b/components/idppreset/pkg/client/clientset/versioned/scheme/register.go @@ -0,0 +1,38 @@ +// Code generated by client-gen. DO NOT EDIT. + +package scheme + +import ( + uiv1alpha1 "github.com/kyma-project/kyma/components/idppreset/pkg/apis/ui/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var Scheme = runtime.NewScheme() +var Codecs = serializer.NewCodecFactory(Scheme) +var ParameterCodec = runtime.NewParameterCodec(Scheme) + +func init() { + v1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"}) + AddToScheme(Scheme) +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +func AddToScheme(scheme *runtime.Scheme) { + uiv1alpha1.AddToScheme(scheme) +} diff --git a/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/doc.go b/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/doc.go new file mode 100644 index 000000000000..93a7ca4e0e2b --- /dev/null +++ b/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated typed clients. +package v1alpha1 diff --git a/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/fake/doc.go b/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/fake/doc.go new file mode 100644 index 000000000000..2b5ba4c8e442 --- /dev/null +++ b/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/fake/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// Package fake has the automatically generated clients. +package fake diff --git a/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/fake/fake_idppreset.go b/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/fake/fake_idppreset.go new file mode 100644 index 000000000000..e18df634284c --- /dev/null +++ b/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/fake/fake_idppreset.go @@ -0,0 +1,104 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/idppreset/pkg/apis/ui/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeIDPPresets implements IDPPresetInterface +type FakeIDPPresets struct { + Fake *FakeUiV1alpha1 +} + +var idppresetsResource = schema.GroupVersionResource{Group: "ui.kyma.cx", Version: "v1alpha1", Resource: "idppresets"} + +var idppresetsKind = schema.GroupVersionKind{Group: "ui.kyma.cx", Version: "v1alpha1", Kind: "IDPPreset"} + +// Get takes name of the iDPPreset, and returns the corresponding iDPPreset object, and an error if there is any. +func (c *FakeIDPPresets) Get(name string, options v1.GetOptions) (result *v1alpha1.IDPPreset, err error) { + obj, err := c.Fake. + Invokes(testing.NewRootGetAction(idppresetsResource, name), &v1alpha1.IDPPreset{}) + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.IDPPreset), err +} + +// List takes label and field selectors, and returns the list of IDPPresets that match those selectors. +func (c *FakeIDPPresets) List(opts v1.ListOptions) (result *v1alpha1.IDPPresetList, err error) { + obj, err := c.Fake. + Invokes(testing.NewRootListAction(idppresetsResource, idppresetsKind, opts), &v1alpha1.IDPPresetList{}) + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha1.IDPPresetList{} + for _, item := range obj.(*v1alpha1.IDPPresetList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested iDPPresets. +func (c *FakeIDPPresets) Watch(opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewRootWatchAction(idppresetsResource, opts)) +} + +// Create takes the representation of a iDPPreset and creates it. Returns the server's representation of the iDPPreset, and an error, if there is any. +func (c *FakeIDPPresets) Create(iDPPreset *v1alpha1.IDPPreset) (result *v1alpha1.IDPPreset, err error) { + obj, err := c.Fake. + Invokes(testing.NewRootCreateAction(idppresetsResource, iDPPreset), &v1alpha1.IDPPreset{}) + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.IDPPreset), err +} + +// Update takes the representation of a iDPPreset and updates it. Returns the server's representation of the iDPPreset, and an error, if there is any. +func (c *FakeIDPPresets) Update(iDPPreset *v1alpha1.IDPPreset) (result *v1alpha1.IDPPreset, err error) { + obj, err := c.Fake. + Invokes(testing.NewRootUpdateAction(idppresetsResource, iDPPreset), &v1alpha1.IDPPreset{}) + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.IDPPreset), err +} + +// Delete takes name of the iDPPreset and deletes it. Returns an error if one occurs. +func (c *FakeIDPPresets) Delete(name string, options *v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewRootDeleteAction(idppresetsResource, name), &v1alpha1.IDPPreset{}) + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeIDPPresets) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + action := testing.NewRootDeleteCollectionAction(idppresetsResource, listOptions) + + _, err := c.Fake.Invokes(action, &v1alpha1.IDPPresetList{}) + return err +} + +// Patch applies the patch and returns the patched iDPPreset. +func (c *FakeIDPPresets) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.IDPPreset, err error) { + obj, err := c.Fake. + Invokes(testing.NewRootPatchSubresourceAction(idppresetsResource, name, data, subresources...), &v1alpha1.IDPPreset{}) + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.IDPPreset), err +} diff --git a/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/fake/fake_ui_client.go b/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/fake/fake_ui_client.go new file mode 100644 index 000000000000..0fd3df5f5399 --- /dev/null +++ b/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/fake/fake_ui_client.go @@ -0,0 +1,24 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1" + rest "k8s.io/client-go/rest" + testing "k8s.io/client-go/testing" +) + +type FakeUiV1alpha1 struct { + *testing.Fake +} + +func (c *FakeUiV1alpha1) IDPPresets() v1alpha1.IDPPresetInterface { + return &FakeIDPPresets{c} +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *FakeUiV1alpha1) RESTClient() rest.Interface { + var ret *rest.RESTClient + return ret +} diff --git a/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/generated_expansion.go b/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/generated_expansion.go new file mode 100644 index 000000000000..d89b8f7d5ce3 --- /dev/null +++ b/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/generated_expansion.go @@ -0,0 +1,5 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +type IDPPresetExpansion interface{} diff --git a/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/idppreset.go b/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/idppreset.go new file mode 100644 index 000000000000..ea817fe7c741 --- /dev/null +++ b/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/idppreset.go @@ -0,0 +1,131 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/idppreset/pkg/apis/ui/v1alpha1" + scheme "github.com/kyma-project/kyma/components/idppreset/pkg/client/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// IDPPresetsGetter has a method to return a IDPPresetInterface. +// A group's client should implement this interface. +type IDPPresetsGetter interface { + IDPPresets() IDPPresetInterface +} + +// IDPPresetInterface has methods to work with IDPPreset resources. +type IDPPresetInterface interface { + Create(*v1alpha1.IDPPreset) (*v1alpha1.IDPPreset, error) + Update(*v1alpha1.IDPPreset) (*v1alpha1.IDPPreset, error) + Delete(name string, options *v1.DeleteOptions) error + DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error + Get(name string, options v1.GetOptions) (*v1alpha1.IDPPreset, error) + List(opts v1.ListOptions) (*v1alpha1.IDPPresetList, error) + Watch(opts v1.ListOptions) (watch.Interface, error) + Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.IDPPreset, err error) + IDPPresetExpansion +} + +// iDPPresets implements IDPPresetInterface +type iDPPresets struct { + client rest.Interface +} + +// newIDPPresets returns a IDPPresets +func newIDPPresets(c *UiV1alpha1Client) *iDPPresets { + return &iDPPresets{ + client: c.RESTClient(), + } +} + +// Get takes name of the iDPPreset, and returns the corresponding iDPPreset object, and an error if there is any. +func (c *iDPPresets) Get(name string, options v1.GetOptions) (result *v1alpha1.IDPPreset, err error) { + result = &v1alpha1.IDPPreset{} + err = c.client.Get(). + Resource("idppresets"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of IDPPresets that match those selectors. +func (c *iDPPresets) List(opts v1.ListOptions) (result *v1alpha1.IDPPresetList, err error) { + result = &v1alpha1.IDPPresetList{} + err = c.client.Get(). + Resource("idppresets"). + VersionedParams(&opts, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested iDPPresets. +func (c *iDPPresets) Watch(opts v1.ListOptions) (watch.Interface, error) { + opts.Watch = true + return c.client.Get(). + Resource("idppresets"). + VersionedParams(&opts, scheme.ParameterCodec). + Watch() +} + +// Create takes the representation of a iDPPreset and creates it. Returns the server's representation of the iDPPreset, and an error, if there is any. +func (c *iDPPresets) Create(iDPPreset *v1alpha1.IDPPreset) (result *v1alpha1.IDPPreset, err error) { + result = &v1alpha1.IDPPreset{} + err = c.client.Post(). + Resource("idppresets"). + Body(iDPPreset). + Do(). + Into(result) + return +} + +// Update takes the representation of a iDPPreset and updates it. Returns the server's representation of the iDPPreset, and an error, if there is any. +func (c *iDPPresets) Update(iDPPreset *v1alpha1.IDPPreset) (result *v1alpha1.IDPPreset, err error) { + result = &v1alpha1.IDPPreset{} + err = c.client.Put(). + Resource("idppresets"). + Name(iDPPreset.Name). + Body(iDPPreset). + Do(). + Into(result) + return +} + +// Delete takes name of the iDPPreset and deletes it. Returns an error if one occurs. +func (c *iDPPresets) Delete(name string, options *v1.DeleteOptions) error { + return c.client.Delete(). + Resource("idppresets"). + Name(name). + Body(options). + Do(). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *iDPPresets) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + return c.client.Delete(). + Resource("idppresets"). + VersionedParams(&listOptions, scheme.ParameterCodec). + Body(options). + Do(). + Error() +} + +// Patch applies the patch and returns the patched iDPPreset. +func (c *iDPPresets) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.IDPPreset, err error) { + result = &v1alpha1.IDPPreset{} + err = c.client.Patch(pt). + Resource("idppresets"). + SubResource(subresources...). + Name(name). + Body(data). + Do(). + Into(result) + return +} diff --git a/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/ui_client.go b/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/ui_client.go new file mode 100644 index 000000000000..14c04366e3f5 --- /dev/null +++ b/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/ui_client.go @@ -0,0 +1,74 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/idppreset/pkg/apis/ui/v1alpha1" + "github.com/kyma-project/kyma/components/idppreset/pkg/client/clientset/versioned/scheme" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + rest "k8s.io/client-go/rest" +) + +type UiV1alpha1Interface interface { + RESTClient() rest.Interface + IDPPresetsGetter +} + +// UiV1alpha1Client is used to interact with features provided by the ui.kyma.cx group. +type UiV1alpha1Client struct { + restClient rest.Interface +} + +func (c *UiV1alpha1Client) IDPPresets() IDPPresetInterface { + return newIDPPresets(c) +} + +// NewForConfig creates a new UiV1alpha1Client for the given config. +func NewForConfig(c *rest.Config) (*UiV1alpha1Client, error) { + config := *c + if err := setConfigDefaults(&config); err != nil { + return nil, err + } + client, err := rest.RESTClientFor(&config) + if err != nil { + return nil, err + } + return &UiV1alpha1Client{client}, nil +} + +// NewForConfigOrDie creates a new UiV1alpha1Client for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *UiV1alpha1Client { + client, err := NewForConfig(c) + if err != nil { + panic(err) + } + return client +} + +// New creates a new UiV1alpha1Client for the given RESTClient. +func New(c rest.Interface) *UiV1alpha1Client { + return &UiV1alpha1Client{c} +} + +func setConfigDefaults(config *rest.Config) error { + gv := v1alpha1.SchemeGroupVersion + config.GroupVersion = &gv + config.APIPath = "/apis" + config.NegotiatedSerializer = serializer.DirectCodecFactory{CodecFactory: scheme.Codecs} + + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } + + return nil +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *UiV1alpha1Client) RESTClient() rest.Interface { + if c == nil { + return nil + } + return c.restClient +} diff --git a/components/idppreset/pkg/client/informers/externalversions/factory.go b/components/idppreset/pkg/client/informers/externalversions/factory.go new file mode 100644 index 000000000000..0bee369e4249 --- /dev/null +++ b/components/idppreset/pkg/client/informers/externalversions/factory.go @@ -0,0 +1,115 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + reflect "reflect" + sync "sync" + time "time" + + versioned "github.com/kyma-project/kyma/components/idppreset/pkg/client/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/idppreset/pkg/client/informers/externalversions/internalinterfaces" + ui "github.com/kyma-project/kyma/components/idppreset/pkg/client/informers/externalversions/ui" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +type sharedInformerFactory struct { + client versioned.Interface + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc + lock sync.Mutex + defaultResync time.Duration + + informers map[reflect.Type]cache.SharedIndexInformer + // startedInformers is used for tracking which informers have been started. + // This allows Start() to be called multiple times safely. + startedInformers map[reflect.Type]bool +} + +// NewSharedInformerFactory constructs a new instance of sharedInformerFactory +func NewSharedInformerFactory(client versioned.Interface, defaultResync time.Duration) SharedInformerFactory { + return NewFilteredSharedInformerFactory(client, defaultResync, v1.NamespaceAll, nil) +} + +// NewFilteredSharedInformerFactory constructs a new instance of sharedInformerFactory. +// Listers obtained via this SharedInformerFactory will be subject to the same filters +// as specified here. +func NewFilteredSharedInformerFactory(client versioned.Interface, defaultResync time.Duration, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) SharedInformerFactory { + return &sharedInformerFactory{ + client: client, + namespace: namespace, + tweakListOptions: tweakListOptions, + defaultResync: defaultResync, + informers: make(map[reflect.Type]cache.SharedIndexInformer), + startedInformers: make(map[reflect.Type]bool), + } +} + +// Start initializes all requested informers. +func (f *sharedInformerFactory) Start(stopCh <-chan struct{}) { + f.lock.Lock() + defer f.lock.Unlock() + + for informerType, informer := range f.informers { + if !f.startedInformers[informerType] { + go informer.Run(stopCh) + f.startedInformers[informerType] = true + } + } +} + +// WaitForCacheSync waits for all started informers' cache were synced. +func (f *sharedInformerFactory) WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool { + informers := func() map[reflect.Type]cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informers := map[reflect.Type]cache.SharedIndexInformer{} + for informerType, informer := range f.informers { + if f.startedInformers[informerType] { + informers[informerType] = informer + } + } + return informers + }() + + res := map[reflect.Type]bool{} + for informType, informer := range informers { + res[informType] = cache.WaitForCacheSync(stopCh, informer.HasSynced) + } + return res +} + +// InternalInformerFor returns the SharedIndexInformer for obj using an internal +// client. +func (f *sharedInformerFactory) InformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informerType := reflect.TypeOf(obj) + informer, exists := f.informers[informerType] + if exists { + return informer + } + informer = newFunc(f.client, f.defaultResync) + f.informers[informerType] = informer + + return informer +} + +// SharedInformerFactory provides shared informers for resources in all known +// API group versions. +type SharedInformerFactory interface { + internalinterfaces.SharedInformerFactory + ForResource(resource schema.GroupVersionResource) (GenericInformer, error) + WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool + + Ui() ui.Interface +} + +func (f *sharedInformerFactory) Ui() ui.Interface { + return ui.New(f, f.namespace, f.tweakListOptions) +} diff --git a/components/idppreset/pkg/client/informers/externalversions/generic.go b/components/idppreset/pkg/client/informers/externalversions/generic.go new file mode 100644 index 000000000000..b01de74866ac --- /dev/null +++ b/components/idppreset/pkg/client/informers/externalversions/generic.go @@ -0,0 +1,46 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + "fmt" + + v1alpha1 "github.com/kyma-project/kyma/components/idppreset/pkg/apis/ui/v1alpha1" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +// GenericInformer is type of SharedIndexInformer which will locate and delegate to other +// sharedInformers based on type +type GenericInformer interface { + Informer() cache.SharedIndexInformer + Lister() cache.GenericLister +} + +type genericInformer struct { + informer cache.SharedIndexInformer + resource schema.GroupResource +} + +// Informer returns the SharedIndexInformer. +func (f *genericInformer) Informer() cache.SharedIndexInformer { + return f.informer +} + +// Lister returns the GenericLister. +func (f *genericInformer) Lister() cache.GenericLister { + return cache.NewGenericLister(f.Informer().GetIndexer(), f.resource) +} + +// ForResource gives generic access to a shared informer of the matching type +// TODO extend this to unknown resources with a client pool +func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { + switch resource { + // Group=ui.kyma.cx, Version=v1alpha1 + case v1alpha1.SchemeGroupVersion.WithResource("idppresets"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Ui().V1alpha1().IDPPresets().Informer()}, nil + + } + + return nil, fmt.Errorf("no informer found for %v", resource) +} diff --git a/components/idppreset/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go b/components/idppreset/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go new file mode 100644 index 000000000000..8bfb5a1681ed --- /dev/null +++ b/components/idppreset/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go @@ -0,0 +1,22 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package internalinterfaces + +import ( + time "time" + + versioned "github.com/kyma-project/kyma/components/idppreset/pkg/client/clientset/versioned" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + cache "k8s.io/client-go/tools/cache" +) + +type NewInformerFunc func(versioned.Interface, time.Duration) cache.SharedIndexInformer + +// SharedInformerFactory a small interface to allow for adding an informer without an import cycle +type SharedInformerFactory interface { + Start(stopCh <-chan struct{}) + InformerFor(obj runtime.Object, newFunc NewInformerFunc) cache.SharedIndexInformer +} + +type TweakListOptionsFunc func(*v1.ListOptions) diff --git a/components/idppreset/pkg/client/informers/externalversions/ui/interface.go b/components/idppreset/pkg/client/informers/externalversions/ui/interface.go new file mode 100644 index 000000000000..e9d165df2dd3 --- /dev/null +++ b/components/idppreset/pkg/client/informers/externalversions/ui/interface.go @@ -0,0 +1,30 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package ui + +import ( + internalinterfaces "github.com/kyma-project/kyma/components/idppreset/pkg/client/informers/externalversions/internalinterfaces" + v1alpha1 "github.com/kyma-project/kyma/components/idppreset/pkg/client/informers/externalversions/ui/v1alpha1" +) + +// Interface provides access to each of this group's versions. +type Interface interface { + // V1alpha1 provides access to shared informers for resources in V1alpha1. + V1alpha1() v1alpha1.Interface +} + +type group struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// V1alpha1 returns a new v1alpha1.Interface. +func (g *group) V1alpha1() v1alpha1.Interface { + return v1alpha1.New(g.factory, g.namespace, g.tweakListOptions) +} diff --git a/components/idppreset/pkg/client/informers/externalversions/ui/v1alpha1/idppreset.go b/components/idppreset/pkg/client/informers/externalversions/ui/v1alpha1/idppreset.go new file mode 100644 index 000000000000..106f570412df --- /dev/null +++ b/components/idppreset/pkg/client/informers/externalversions/ui/v1alpha1/idppreset.go @@ -0,0 +1,72 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + time "time" + + ui_v1alpha1 "github.com/kyma-project/kyma/components/idppreset/pkg/apis/ui/v1alpha1" + versioned "github.com/kyma-project/kyma/components/idppreset/pkg/client/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/idppreset/pkg/client/informers/externalversions/internalinterfaces" + v1alpha1 "github.com/kyma-project/kyma/components/idppreset/pkg/client/listers/ui/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// IDPPresetInformer provides access to a shared informer and lister for +// IDPPresets. +type IDPPresetInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha1.IDPPresetLister +} + +type iDPPresetInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// NewIDPPresetInformer constructs a new informer for IDPPreset type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewIDPPresetInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredIDPPresetInformer(client, resyncPeriod, indexers, nil) +} + +// NewFilteredIDPPresetInformer constructs a new informer for IDPPreset type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredIDPPresetInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.UiV1alpha1().IDPPresets().List(options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.UiV1alpha1().IDPPresets().Watch(options) + }, + }, + &ui_v1alpha1.IDPPreset{}, + resyncPeriod, + indexers, + ) +} + +func (f *iDPPresetInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredIDPPresetInformer(client, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *iDPPresetInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&ui_v1alpha1.IDPPreset{}, f.defaultInformer) +} + +func (f *iDPPresetInformer) Lister() v1alpha1.IDPPresetLister { + return v1alpha1.NewIDPPresetLister(f.Informer().GetIndexer()) +} diff --git a/components/idppreset/pkg/client/informers/externalversions/ui/v1alpha1/interface.go b/components/idppreset/pkg/client/informers/externalversions/ui/v1alpha1/interface.go new file mode 100644 index 000000000000..1c7200bdd8e5 --- /dev/null +++ b/components/idppreset/pkg/client/informers/externalversions/ui/v1alpha1/interface.go @@ -0,0 +1,29 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + internalinterfaces "github.com/kyma-project/kyma/components/idppreset/pkg/client/informers/externalversions/internalinterfaces" +) + +// Interface provides access to all the informers in this group version. +type Interface interface { + // IDPPresets returns a IDPPresetInformer. + IDPPresets() IDPPresetInformer +} + +type version struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// IDPPresets returns a IDPPresetInformer. +func (v *version) IDPPresets() IDPPresetInformer { + return &iDPPresetInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} +} diff --git a/components/idppreset/pkg/client/listers/ui/v1alpha1/expansion_generated.go b/components/idppreset/pkg/client/listers/ui/v1alpha1/expansion_generated.go new file mode 100644 index 000000000000..2ef5c6246190 --- /dev/null +++ b/components/idppreset/pkg/client/listers/ui/v1alpha1/expansion_generated.go @@ -0,0 +1,7 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +// IDPPresetListerExpansion allows custom methods to be added to +// IDPPresetLister. +type IDPPresetListerExpansion interface{} diff --git a/components/idppreset/pkg/client/listers/ui/v1alpha1/idppreset.go b/components/idppreset/pkg/client/listers/ui/v1alpha1/idppreset.go new file mode 100644 index 000000000000..23da4dc1e045 --- /dev/null +++ b/components/idppreset/pkg/client/listers/ui/v1alpha1/idppreset.go @@ -0,0 +1,49 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/idppreset/pkg/apis/ui/v1alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// IDPPresetLister helps list IDPPresets. +type IDPPresetLister interface { + // List lists all IDPPresets in the indexer. + List(selector labels.Selector) (ret []*v1alpha1.IDPPreset, err error) + // Get retrieves the IDPPreset from the index for a given name. + Get(name string) (*v1alpha1.IDPPreset, error) + IDPPresetListerExpansion +} + +// iDPPresetLister implements the IDPPresetLister interface. +type iDPPresetLister struct { + indexer cache.Indexer +} + +// NewIDPPresetLister returns a new IDPPresetLister. +func NewIDPPresetLister(indexer cache.Indexer) IDPPresetLister { + return &iDPPresetLister{indexer: indexer} +} + +// List lists all IDPPresets in the indexer. +func (s *iDPPresetLister) List(selector labels.Selector) (ret []*v1alpha1.IDPPreset, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.IDPPreset)) + }) + return ret, err +} + +// Get retrieves the IDPPreset from the index for a given name. +func (s *iDPPresetLister) Get(name string) (*v1alpha1.IDPPreset, error) { + obj, exists, err := s.indexer.GetByKey(name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha1.Resource("idppreset"), name) + } + return obj.(*v1alpha1.IDPPreset), nil +} diff --git a/components/installer/.gitignore b/components/installer/.gitignore new file mode 100644 index 000000000000..77a3f16127b2 --- /dev/null +++ b/components/installer/.gitignore @@ -0,0 +1,22 @@ +### LOCAL +# info.json is always generated by before-commit.sh +/info.json + +/.idea +/.vscode +installer + +# Binaries for programs and plugins +*.exe +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 +.glide/ diff --git a/components/installer/Gopkg.lock b/components/installer/Gopkg.lock new file mode 100644 index 000000000000..4193fb1068af --- /dev/null +++ b/components/installer/Gopkg.lock @@ -0,0 +1,273 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/Azure/azure-sdk-for-go" + packages = ["services/keyvault/2016-10-01/keyvault","version"] + revision = "fad8443a79b0e755c18c3bec29a8d2bedab0b421" + version = "v15.3.0" + +[[projects]] + name = "github.com/Azure/go-autorest" + packages = ["autorest","autorest/adal","autorest/azure","autorest/date","autorest/to","autorest/validation"] + revision = "1ff28809256a84bb6966640ff3d0371af82ccba4" + version = "v10.6.2" + +[[projects]] + name = "github.com/BurntSushi/toml" + packages = ["."] + revision = "b26d9c308763d68093482582cea63d69be07a0f0" + version = "v0.3.0" + +[[projects]] + name = "github.com/Masterminds/semver" + packages = ["."] + revision = "15d8430ab86497c5c0da827b748823945e1cf1e1" + version = "v1.4.0" + +[[projects]] + name = "github.com/davecgh/go-spew" + packages = ["spew"] + revision = "346938d642f2ec3594ed81d874461961cd0faa76" + version = "v1.1.0" + +[[projects]] + name = "github.com/dgrijalva/jwt-go" + packages = ["."] + revision = "06ea1031745cb8b3dab3f6a236daf2b0aa468b7e" + version = "v3.2.0" + +[[projects]] + name = "github.com/ghodss/yaml" + packages = ["."] + revision = "0ca9ea5df5451ffdf184b4428c902747c2c11cd7" + version = "v1.0.0" + +[[projects]] + name = "github.com/gobwas/glob" + packages = [".","compiler","match","syntax","syntax/ast","syntax/lexer","util/runes","util/strings"] + revision = "5ccd90ef52e1e632236f7326478d4faa74f99438" + version = "v0.2.3" + +[[projects]] + name = "github.com/gogo/protobuf" + packages = ["proto","sortkeys"] + revision = "1adfc126b41513cc696b209667c8656ea7aac67c" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "github.com/golang/glog" + packages = ["."] + revision = "23def4e6c14b4da8ac2ed8007337bc5eb5007998" + +[[projects]] + branch = "master" + name = "github.com/golang/groupcache" + packages = ["lru"] + revision = "66deaeb636dff1ac7d938ce666d090556056a4b0" + +[[projects]] + name = "github.com/golang/protobuf" + packages = ["proto","ptypes","ptypes/any","ptypes/duration","ptypes/timestamp"] + revision = "925541529c1fa6821df4e44ce2723319eb2be768" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "github.com/google/gofuzz" + packages = ["."] + revision = "24818f796faf91cd76ec7bddd72458fbced7a6c1" + +[[projects]] + name = "github.com/googleapis/gnostic" + packages = ["OpenAPIv2","compiler","extensions"] + revision = "ee43cbb60db7bd22502942cccbc39059117352ab" + version = "v0.1.0" + +[[projects]] + branch = "master" + name = "github.com/gopherjs/gopherjs" + packages = ["js"] + revision = "82b322028c96512b15077093b16a5f1c7ea897ac" + +[[projects]] + branch = "master" + name = "github.com/hashicorp/golang-lru" + packages = [".","simplelru"] + revision = "0fb14efe8c47ae851c0034ed7a448854d3d34cf3" + +[[projects]] + branch = "master" + name = "github.com/howeyc/gopass" + packages = ["."] + revision = "bf9dde6d0d2c004a008c27aaee91170c786f6db8" + +[[projects]] + name = "github.com/imdario/mergo" + packages = ["."] + revision = "163f41321a19dd09362d4c63cc2489db2015f1f4" + version = "0.3.2" + +[[projects]] + name = "github.com/json-iterator/go" + packages = ["."] + revision = "3353055b2a1a5ae1b6a8dfde887a524e7088f3a2" + version = "1.1.2" + +[[projects]] + name = "github.com/jtolds/gls" + packages = ["."] + revision = "77f18212c9c7edc9bd6a33d383a7b545ce62f064" + version = "v4.2.1" + +[[projects]] + name = "github.com/kubernetes-incubator/service-catalog" + packages = ["pkg/apis/servicecatalog","pkg/apis/servicecatalog/v1beta1","pkg/apis/settings","pkg/apis/settings/v1alpha1","pkg/client/clientset_generated/clientset","pkg/client/clientset_generated/clientset/scheme","pkg/client/clientset_generated/clientset/typed/servicecatalog/v1beta1","pkg/client/clientset_generated/clientset/typed/settings/v1alpha1"] + revision = "04914a423a04cd895cf7cb785d0f0b5bd433d47d" + version = "v0.1.14" + +[[projects]] + name = "github.com/modern-go/concurrent" + packages = ["."] + revision = "bacd9c7ef1dd9b15be4a9909b8ac7a4e313eec94" + version = "1.0.3" + +[[projects]] + name = "github.com/modern-go/reflect2" + packages = ["."] + revision = "1df9eeb2bb81f327b96228865c5687bc2194af3f" + version = "1.0.0" + +[[projects]] + name = "github.com/pborman/uuid" + packages = ["."] + revision = "e790cca94e6cc75c7064b1332e63811d4aae1a53" + version = "v1.1" + +[[projects]] + name = "github.com/smartystreets/assertions" + packages = [".","internal/go-render/render","internal/oglematchers"] + revision = "7678a5452ebea5b7090a6b163f844c133f523da2" + version = "1.8.3" + +[[projects]] + name = "github.com/smartystreets/goconvey" + packages = ["convey","convey/gotest","convey/reporting"] + revision = "9e8dc3f972df6c8fcc0375ef492c24d0bb204857" + version = "1.6.3" + +[[projects]] + name = "github.com/spf13/pflag" + packages = ["."] + revision = "e57e3eeb33f795204c1ca35f56c44f83227c6e66" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = ["ssh/terminal"] + revision = "c7dcf104e3a7a1417abc0230cb0d5240d764159d" + +[[projects]] + branch = "master" + name = "golang.org/x/net" + packages = ["context","http2","http2/hpack","idna","internal/timeseries","lex/httplex","trace"] + revision = "d0aafc73d5cdc42264b0af071c261abac580695e" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = ["unix","windows"] + revision = "7dca6fe1f43775aa6d1334576870ff63f978f539" + +[[projects]] + name = "golang.org/x/text" + packages = ["collate","collate/build","internal/colltab","internal/gen","internal/tag","internal/triegen","internal/ucd","language","secure/bidirule","transform","unicode/bidi","unicode/cldr","unicode/norm","unicode/rangetable"] + revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" + version = "v0.3.0" + +[[projects]] + branch = "master" + name = "golang.org/x/time" + packages = ["rate"] + revision = "fbb02b2291d28baffd63558aa44b4b56f178d650" + +[[projects]] + branch = "master" + name = "golang.org/x/tools" + packages = ["go/ast/astutil","imports"] + revision = "059bec968c61383b574810040ba9410712de36c5" + +[[projects]] + branch = "master" + name = "google.golang.org/genproto" + packages = ["googleapis/rpc/status"] + revision = "df60624c1e9b9d2973e889c7a1cff73155da81c4" + +[[projects]] + name = "google.golang.org/grpc" + packages = [".","balancer","balancer/base","balancer/roundrobin","codes","connectivity","credentials","encoding","encoding/proto","grpclb/grpc_lb_v1/messages","grpclog","internal","keepalive","metadata","naming","peer","resolver","resolver/dns","resolver/passthrough","stats","status","tap","transport"] + revision = "8e4536a86ab602859c20df5ebfd0bd4228d08655" + version = "v1.10.0" + +[[projects]] + name = "gopkg.in/inf.v0" + packages = ["."] + revision = "3887ee99ecf07df5b447e9b00d9c0b2adaa9f3e4" + version = "v0.9.0" + +[[projects]] + name = "gopkg.in/yaml.v2" + packages = ["."] + revision = "7f97868eec74b32b0982dd158a51a446d1da7eb5" + version = "v2.1.1" + +[[projects]] + name = "k8s.io/api" + packages = ["admissionregistration/v1alpha1","admissionregistration/v1beta1","apps/v1","apps/v1beta1","apps/v1beta2","authentication/v1","authentication/v1beta1","authorization/v1","authorization/v1beta1","autoscaling/v1","autoscaling/v2beta1","batch/v1","batch/v1beta1","batch/v2alpha1","certificates/v1beta1","core/v1","events/v1beta1","extensions/v1beta1","networking/v1","policy/v1beta1","rbac/v1","rbac/v1alpha1","rbac/v1beta1","scheduling/v1alpha1","settings/v1alpha1","storage/v1","storage/v1alpha1","storage/v1beta1"] + revision = "73d903622b7391f3312dcbac6483fed484e185f8" + version = "kubernetes-1.10.0" + +[[projects]] + name = "k8s.io/apimachinery" + packages = ["pkg/api/errors","pkg/api/meta","pkg/api/resource","pkg/apis/meta/internalversion","pkg/apis/meta/v1","pkg/apis/meta/v1/unstructured","pkg/apis/meta/v1beta1","pkg/conversion","pkg/conversion/queryparams","pkg/fields","pkg/labels","pkg/runtime","pkg/runtime/schema","pkg/runtime/serializer","pkg/runtime/serializer/json","pkg/runtime/serializer/protobuf","pkg/runtime/serializer/recognizer","pkg/runtime/serializer/streaming","pkg/runtime/serializer/versioning","pkg/selection","pkg/types","pkg/util/cache","pkg/util/clock","pkg/util/diff","pkg/util/errors","pkg/util/framer","pkg/util/intstr","pkg/util/json","pkg/util/mergepatch","pkg/util/net","pkg/util/runtime","pkg/util/sets","pkg/util/strategicpatch","pkg/util/uuid","pkg/util/validation","pkg/util/validation/field","pkg/util/wait","pkg/util/yaml","pkg/version","pkg/watch","third_party/forked/golang/json","third_party/forked/golang/reflect"] + revision = "302974c03f7e50f16561ba237db776ab93594ef6" + version = "kubernetes-1.10.0" + +[[projects]] + name = "k8s.io/client-go" + packages = ["discovery","discovery/fake","informers","informers/admissionregistration","informers/admissionregistration/v1alpha1","informers/admissionregistration/v1beta1","informers/apps","informers/apps/v1","informers/apps/v1beta1","informers/apps/v1beta2","informers/autoscaling","informers/autoscaling/v1","informers/autoscaling/v2beta1","informers/batch","informers/batch/v1","informers/batch/v1beta1","informers/batch/v2alpha1","informers/certificates","informers/certificates/v1beta1","informers/core","informers/core/v1","informers/events","informers/events/v1beta1","informers/extensions","informers/extensions/v1beta1","informers/internalinterfaces","informers/networking","informers/networking/v1","informers/policy","informers/policy/v1beta1","informers/rbac","informers/rbac/v1","informers/rbac/v1alpha1","informers/rbac/v1beta1","informers/scheduling","informers/scheduling/v1alpha1","informers/settings","informers/settings/v1alpha1","informers/storage","informers/storage/v1","informers/storage/v1alpha1","informers/storage/v1beta1","kubernetes","kubernetes/scheme","kubernetes/typed/admissionregistration/v1alpha1","kubernetes/typed/admissionregistration/v1beta1","kubernetes/typed/apps/v1","kubernetes/typed/apps/v1beta1","kubernetes/typed/apps/v1beta2","kubernetes/typed/authentication/v1","kubernetes/typed/authentication/v1beta1","kubernetes/typed/authorization/v1","kubernetes/typed/authorization/v1beta1","kubernetes/typed/autoscaling/v1","kubernetes/typed/autoscaling/v2beta1","kubernetes/typed/batch/v1","kubernetes/typed/batch/v1beta1","kubernetes/typed/batch/v2alpha1","kubernetes/typed/certificates/v1beta1","kubernetes/typed/core/v1","kubernetes/typed/events/v1beta1","kubernetes/typed/extensions/v1beta1","kubernetes/typed/networking/v1","kubernetes/typed/policy/v1beta1","kubernetes/typed/rbac/v1","kubernetes/typed/rbac/v1alpha1","kubernetes/typed/rbac/v1beta1","kubernetes/typed/scheduling/v1alpha1","kubernetes/typed/settings/v1alpha1","kubernetes/typed/storage/v1","kubernetes/typed/storage/v1alpha1","kubernetes/typed/storage/v1beta1","listers/admissionregistration/v1alpha1","listers/admissionregistration/v1beta1","listers/apps/v1","listers/apps/v1beta1","listers/apps/v1beta2","listers/autoscaling/v1","listers/autoscaling/v2beta1","listers/batch/v1","listers/batch/v1beta1","listers/batch/v2alpha1","listers/certificates/v1beta1","listers/core/v1","listers/events/v1beta1","listers/extensions/v1beta1","listers/networking/v1","listers/policy/v1beta1","listers/rbac/v1","listers/rbac/v1alpha1","listers/rbac/v1beta1","listers/scheduling/v1alpha1","listers/settings/v1alpha1","listers/storage/v1","listers/storage/v1alpha1","listers/storage/v1beta1","pkg/apis/clientauthentication","pkg/apis/clientauthentication/v1alpha1","pkg/version","plugin/pkg/client/auth/exec","rest","rest/watch","testing","tools/auth","tools/cache","tools/clientcmd","tools/clientcmd/api","tools/clientcmd/api/latest","tools/clientcmd/api/v1","tools/metrics","tools/pager","tools/record","tools/reference","transport","util/buffer","util/cert","util/flowcontrol","util/homedir","util/integer","util/retry","util/workqueue"] + revision = "989be4278f353e42f26c416c53757d16fcff77db" + version = "kubernetes-1.10.1" + +[[projects]] + name = "k8s.io/code-generator" + packages = ["cmd/client-gen","cmd/client-gen/args","cmd/client-gen/generators","cmd/client-gen/generators/fake","cmd/client-gen/generators/scheme","cmd/client-gen/generators/util","cmd/client-gen/path","cmd/client-gen/types","pkg/util"] + revision = "7ead8f38b01cf8653249f5af80ce7b2c8aba12e2" + version = "kubernetes-1.10.0" + +[[projects]] + branch = "master" + name = "k8s.io/gengo" + packages = ["args","generator","namer","parser","types"] + revision = "01a732e01d00cb9a81bb0ca050d3e6d2b947927b" + +[[projects]] + name = "k8s.io/helm" + packages = ["pkg/chartutil","pkg/helm","pkg/ignore","pkg/proto/hapi/chart","pkg/proto/hapi/release","pkg/proto/hapi/services","pkg/proto/hapi/version","pkg/sympath","pkg/version"] + revision = "6af75a8fd72e2aa18a2b278cfe5c7a1c5feca7f2" + version = "v2.8.1" + +[[projects]] + branch = "master" + name = "k8s.io/kube-openapi" + packages = ["pkg/util/proto"] + revision = "50ae88d24ede7b8bad68e23c805b5d3da5c8abaf" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "aa8d37515e21dc8b3f0597af73f177c7928d81c1ed054b3440aea86b73c14758" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/components/installer/Gopkg.toml b/components/installer/Gopkg.toml new file mode 100644 index 000000000000..6c39c4b21384 --- /dev/null +++ b/components/installer/Gopkg.toml @@ -0,0 +1,53 @@ +required = ["k8s.io/code-generator/cmd/client-gen"] + +[[constraint]] + name = "k8s.io/client-go" + version = "kubernetes-1.10.1" + +[[constraint]] + name = "k8s.io/code-generator" + branch = "kubernetes-1.10.1" + +[[constraint]] + name = "github.com/kubernetes-incubator/service-catalog" + version = "v0.1.11" + +[[constraint]] + name = "k8s.io/apimachinery" + version = "kubernetes-1.10.1" + +[[constraint]] + name = "k8s.io/api" + version = "kubernetes-1.10.1" + +[[constraint]] + name = "k8s.io/helm" + version = "v2.8.1" + +[[constraint]] + name = "github.com/smartystreets/goconvey" + version = "1.6.3" + +[[constraint]] + name = "github.com/Azure/go-autorest" + version = "v10.6.2" + +[[constraint]] + name = "github.com/Azure/azure-sdk-for-go" + version = "v15.0.1" + +[[override]] + name = "k8s.io/api" + version = "kubernetes-1.10.1" + +[[override]] + name = "k8s.io/apimachinery" + version = "kubernetes-1.10.1" + +[[override]] + name = "k8s.io/client-go" + version = "kubernetes-1.10.1" + +[[override]] + name = "k8s.io/code-generator" + version = "kubernetes-1.10.1" \ No newline at end of file diff --git a/components/installer/Jenkinsfile b/components/installer/Jenkinsfile new file mode 100644 index 000000000000..4bcf95b3b494 --- /dev/null +++ b/components/installer/Jenkinsfile @@ -0,0 +1,97 @@ +#!/usr/bin/env groovy +def label = "kyma-${UUID.randomUUID().toString()}" +def application = 'installer' +def isMaster = params.GIT_BRANCH == 'master' + +def dockerPushRoot = isMaster + ? "${env.DOCKER_REGISTRY}" + : "${env.DOCKER_REGISTRY}snapshot/" + +def dockerImageTag = isMaster + ? params.APP_VERSION + : params.GIT_BRANCH + +echo """ +******************************** +Job started with the following parameters: +DOCKER_REGISTRY=${env.DOCKER_REGISTRY} +DOCKER_CREDENTIALS=${env.DOCKER_CREDENTIALS} +GIT_REVISION=${params.GIT_REVISION} +GIT_BRANCH=${params.GIT_BRANCH} +APP_VERSION=${params.APP_VERSION} +APP_FOLDER=${env.APP_FOLDER} +******************************** +""" + +podTemplate(label: label) { + node(label) { + try { + timestamps { + timeout(time:20, unit:"MINUTES") { + ansiColor('xterm') { + stage("setup") { + checkout scm + + if(dockerImageTag == ""){ + error("No version for docker tag defined, please set APP_VERSION parameter for master branch or GIT_BRANCH parameter for any branch") + } + + withCredentials([usernamePassword(credentialsId: env.DOCKER_CREDENTIALS, passwordVariable: 'pwd', usernameVariable: 'uname')]) { + sh "docker login -u $uname -p '$pwd' $env.DOCKER_REGISTRY" + } + } + + stage("install dependencies $application") { + execute("make resolve") + } + + stage("code quality $application") { + execute("gometalinter --vendor --deadline=2m --disable-all --enable=vet --skip=pkg/client/clientset/versioned/fake ./...") + } + + stage("build $application") { + execute("CGO_ENABLED=0 go build -o installer cmd/operator/main.go") + } + + stage("test $application") { + execute("make test-report") + junit '**/unit-tests.xml' + } + + if (isMaster) { + stage("IP scan $application (Sourceclear)"){ + withCredentials([string(credentialsId: 'SRCCLR_API_TOKEN', variable: 'SRCCLR_API_TOKEN')]) { + execute("make scan","SRCCLR_API_TOKEN=$SRCCLR_API_TOKEN") + } + } + } + + stage("build image $application") { + dir(env.APP_FOLDER){ + sh "cp ./$application deploy/installer/$application" + sh "docker build -t $application:latest deploy/installer" + } + } + + stage("push image") { + sh "docker tag ${application}:latest ${dockerPushRoot}${application}:${dockerImageTag}" + sh "docker push ${dockerPushRoot}${application}:${dockerImageTag}" + } + } + } + } + } catch (ex) { + echo "Got exception: ${ex}" + currentBuild.result = "FAILURE" + def body = "${currentBuild.currentResult} ${env.JOB_NAME}${env.BUILD_DISPLAY_NAME}: on branch: ${params.GIT_BRANCH}. See details: ${env.BUILD_URL}" + emailext body: body, recipientProviders: [[$class: 'DevelopersRecipientProvider'], [$class: 'CulpritsRecipientProvider'], [$class: 'RequesterRecipientProvider']], subject: "${currentBuild.currentResult}: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'" + } + } +} + +def execute(command, envs = '') { + def buildpack = 'golang-buildpack:0.0.8' + def envText = envs=='' ? '' : "--env $envs" + workDir = pwd() + sh "docker run --rm -v $workDir:/go/src/github.com/kyma-project/kyma/ -w /go/src/github.com/kyma-project/kyma/$env.APP_FOLDER $envText ${env.DOCKER_REGISTRY}$buildpack /bin/bash -c '$command'" +} diff --git a/components/installer/README.md b/components/installer/README.md new file mode 100644 index 000000000000..247b75874503 --- /dev/null +++ b/components/installer/README.md @@ -0,0 +1,117 @@ +# Installer + +Installer is a tool for installing all Kyma components. +The project is based on the Kubernetes operator pattern. It tracks changes of the `Installation` type instance of the custom resource. It also installs, uninstalls, and updates Kyma accordingly. + +## Prerequisites + +- Minikube 0.26.1 +- kubectl 1.9.0 +- Docker + +## Development + +Before each commit, use the [before-commit.sh](./before-commit.sh) script to test your changes: +``` +./before-commit.sh +``` + +### Build a Docker image + +Run the [build.sh](./scripts/build.sh) script to build a Docker image: + +``` +./scripts/build.sh +``` + +### Run on Minikube + +#### Run with local Kyma resources +``` +export KYMA_PATH={PATH_TO_KYMA_SOURCES} +``` +This environment variable is used to trigger shell scripts located in Kyma. +``` +./scripts/run.sh --local --cr {PATH_TO_CR} +``` + +It will run Minikube, set up the installer, and install Kyma from local sources. If Minikube was started before, it will be restarted. + +See the [Custom Resource file](#custom-resource-file) section in this document to learn how to generate a Custom Resource file. + +To track progress of the installation, run: + +``` +../../installation/scripts/is-installed.sh +``` + +#### Rerun without restarting Minikube + +This scenario is useful when you want to reuse cached Docker images. + +Run the following script to clear running Minikube: +``` +../../installation/scripts/clean-up.sh +``` + +Execute the `run.sh` script with the `--skip-minikube-start` flag to rerun Kyma installation without stopping your Minikube: +``` +./scripts/run.sh --skip-minikube-start +``` + +### Update the Kyma cluster + +Connect to the cluster that hosts your Kyma installation. Prepare the URL to the updated Kyma `tar.gz` package. Run the following command to edit the installation CR: +``` +kubectl edit installation/{CR_NAME} +``` +Change the `url` property in **spec** to `{URL TO KYMA TAR GZ}`. Trigger the update by overriding the **action** label in CR: +``` +kubectl label installation/{CR_NAME} action=update --overwrite +``` + +### Update the local Kyma installation + +Prepare local changes in Kyma sources. Run the following command to copy the updated sources to the Installer Pod and trigger the update action: +``` +../../installation/scripts/update.sh --local --cr-name {CR_NAME} +``` + +> **NOTE:** You do not have to restart Minikube. + +### Uninstall Kyma + +Run the following command to completely uninstall Kyma: +``` +kubectl label installation/kyma-installation action=uninstall +``` + +### Custom Resource file + +The Custom Resource file for installer provides the basic information for Kyma installation. + +The required properties iclude: + +- `url` which is the URL address to a Kyma charts package. Only `tar.gz` is supported and it is required for non-local installations only. +- `version` which is the version of Kyma. + + +### Generate the Custom Resource file for installer + +Generate a Custom Resource file using the [create-cr.sh](../../installation/scripts/create-cr.sh) script. It accepts the following arguments: + +- `--output` is a mandatory parameter which indicates the location of the Custom Resource output file. +- `--url` is the URL to the Kyma package. +- `--version` is the version of Kyma. + +Example: +``` +../../installation/scripts/create-cr.sh --output installer-cr.yaml --url {URL TO KYMA TAR GZ} --version 0.0.1 +``` + +To run the installer with the Azure Broker enabled, specify the Azure credentials in the Custom Resource file. Edit the file by providing the following properties along with their values in the **spec** definition: `az_subscription_id`, `az_tenant_id`, `az_client_id` and `az_client_secret`. + +## Static overrides for cluster installations + +You can define cluster-specific overrides for each root chart. In the cluster deployment scenario, the installer reads the `cluster.yaml` file in each root chart and appends its content to the overrides applied during the +Helm installation. diff --git a/components/installer/before-commit.sh b/components/installer/before-commit.sh new file mode 100755 index 000000000000..6353e9b512ca --- /dev/null +++ b/components/installer/before-commit.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash + +readonly CI_FLAG=ci + +RED='\033[0;31m' +GREEN='\033[0;32m' +INVERTED='\033[7m' +NC='\033[0m' # No Color + +echo -e "${INVERTED}" +echo "USER: " + $USER +echo "PATH: " + $PATH +echo "GOPATH:" + $GOPATH +echo -e "${NC}" + +## +# GO BUILD +## +buildEnv="" +if [ "$1" == "$CI_FLAG" ]; then + # build binary statically + buildEnv="env CGO_ENABLED=0" +fi + +${buildEnv} go build -o installer ./cmd/operator + +goBuildResult=$? +if [ ${goBuildResult} != 0 ]; then + echo -e "${RED}✗ go build${NC}\n$goBuildResult${NC}" + exit 1 +else echo -e "${GREEN}√ go build${NC}" +fi + +## +# DEP STATUS +## +echo "? dep status" +depResult=$(dep status -v) +if [ $? != 0 ]; then + echo -e "${RED}✗ dep status\n$depResult${NC}" + exit 1 +else echo -e "${GREEN}√ dep status${NC}" +fi + +## +# GO TEST +## +echo "? go test" +go test ./... +# Check if tests passed +if [ $? != 0 ]; then + echo -e "${RED}✗ go test\n${NC}" + exit 1 +else echo -e "${GREEN}√ go test${NC}" +fi + +goFilesToCheck=$(find . -type f -name "*.go" | egrep -v "/vendor") + +## +# GO IMPORTS & FMT +## +go build -o goimports-vendored ./vendor/golang.org/x/tools/cmd/goimports +buildGoImportResult=$? +if [ ${buildGoImportResult} != 0 ]; then + echo -e "${RED}✗ go build goimports${NC}\n$buildGoImportResult${NC}" + exit 1 +fi + +goImportsResult=$(echo "${goFilesToCheck}" | xargs -L1 ./goimports-vendored -w -l) +rm goimports-vendored + +if [ $(echo ${#goImportsResult}) != 0 ]; then + echo -e "${RED}✗ goimports and fmt${NC}\n$goImportsResult${NC}" + exit 1 +else echo -e "${GREEN}√ goimports and fmt${NC}" +fi + +## +# GO VET +## +packagesToVet=($(go list ./... | grep -v /vendor/ | grep -v '/pkg/client')) + +for vPackage in "${packagesToVet[@]}"; do + vetResult=$(go vet ${vPackage}) + if [ $(echo ${#vetResult}) != 0 ]; then + echo -e "${RED}✗ go vet ${vPackage} ${NC}\n$vetResult${NC}" + exit 1 + else echo -e "${GREEN}√ go vet ${vPackage} ${NC}" + fi +done + +## +# INFO.JSON +## +author=$(git show -s --pretty=%an) +# the branch calcualtion looks as follow, because the git checkout process works differently on jenkins and our local machine +branch=${GIT_BRANCH} +if [$branch == ""]; then + branch=$(git rev-parse --abbrev-ref HEAD) +fi +commit=$(git rev-parse --verify HEAD) +commitMsg=$(git show -s --pretty=%s) +commitDate=$(git log -1 --format=%cd) +printf "{\n\t\"author\": \"""$author""\",\n\t\"commit\": \""$commit"\",\n\t\"branch\": \""$branch"\",\n\t\"commitDate\":\"""$commitDate""\",\n\t\"commitMessage\":\"""$commitMsg""\",\n\t\"deployDate\": \"""$(date)""\"\n}" >info.json +echo -e "${GREEN}√ info.json ${NC}" $(cat info.json) diff --git a/components/installer/cmd/operator/main.go b/components/installer/cmd/operator/main.go new file mode 100644 index 000000000000..dee32b84b202 --- /dev/null +++ b/components/installer/cmd/operator/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "flag" + "log" + "time" + + "github.com/kyma-project/kyma/components/installer/pkg/actionmanager" + "github.com/kyma-project/kyma/components/installer/pkg/conditionmanager" + "github.com/kyma-project/kyma/components/installer/pkg/consts" + "github.com/kyma-project/kyma/components/installer/pkg/finalizer" + "github.com/kyma-project/kyma/components/installer/pkg/installation" + "github.com/kyma-project/kyma/components/installer/pkg/kymahelm" + "github.com/kyma-project/kyma/components/installer/pkg/release" + "github.com/kyma-project/kyma/components/installer/pkg/servicecatalog" + "github.com/kyma-project/kyma/components/installer/pkg/toolkit" + + "github.com/kyma-project/kyma/components/installer/pkg/statusmanager" + + "github.com/kyma-project/kyma/components/installer/pkg/steps" + + kubeinformers "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + + clientset "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned" + informers "github.com/kyma-project/kyma/components/installer/pkg/client/informers/externalversions" +) + +func main() { + + log.Println("starting operator...") + + stop := make(chan struct{}) + + kubeconfig := flag.String("kubeconfig", "", "Path to a kubeconfig file") + helmHost := flag.String("helmhost", "tiller-deploy.kube-system.svc.cluster.local:44134", "Helm host") + kymaDir := flag.String("kymadir", "/kyma", "Chart directory") + + flag.Parse() + + config, err := getClientConfig(*kubeconfig) + + if err != nil { + log.Fatalf("Unable to build kubernetes configuration. Error: %v", err) + } + + kubeClient, err := kubernetes.NewForConfig(config) + if err != nil { + log.Fatalf("Unable to create kubernetes client. Error: %v", err) + } + + internalClient, err := clientset.NewForConfig(config) + if err != nil { + log.Fatalf("Unable to create internal client. Error: %v", err) + } + + helmClient := kymahelm.NewClient(*helmHost) + serviceCatalogClient := servicecatalog.NewClient(config) + + kymaPackageClient := &steps.KymaPackageClient{} + + kubeInformerFactory := kubeinformers.NewSharedInformerFactory(kubeClient, time.Second*30) + internalInformerFactory := informers.NewSharedInformerFactory(internalClient, time.Second*30) + installationLister := internalInformerFactory.Installer().V1alpha1().Installations().Lister() + + kymaStatusManager := statusmanager.NewKymaStatusManager(internalClient, installationLister) + kymaActionManager := actionmanager.NewKymaActionManager(internalClient, installationLister) + conditionManager := conditionmanager.New(internalClient, installationLister) + + installationFinalizerManager := finalizer.NewManager(consts.INST_FINALIZER) + releaseFinalizerManager := finalizer.NewManager(consts.REL_FINALIZER) + + kymaCommandExecutor := &toolkit.KymaCommandExecutor{} + installationSteps := steps.New(helmClient, kubeClient, serviceCatalogClient, *kymaDir, kymaStatusManager, kymaActionManager, kymaCommandExecutor, kymaPackageClient) + + installationController := installation.NewController(kubeClient, kubeInformerFactory, internalInformerFactory, *kymaDir, installationSteps, conditionManager, installationFinalizerManager, internalClient) + releaseController := release.NewController(kubeClient, internalInformerFactory, releaseFinalizerManager) + + kubeInformerFactory.Start(stop) + internalInformerFactory.Start(stop) + + installationController.Run(2, stop) + releaseController.Run(2, stop) +} + +func getClientConfig(kubeconfig string) (*rest.Config, error) { + if kubeconfig != "" { + return clientcmd.BuildConfigFromFlags("", kubeconfig) + } + return rest.InClusterConfig() +} diff --git a/components/installer/hack/custom-boilerplate.go.txt b/components/installer/hack/custom-boilerplate.go.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/components/installer/hack/update-codegen.sh b/components/installer/hack/update-codegen.sh new file mode 100755 index 000000000000..da18e6a48f1e --- /dev/null +++ b/components/installer/hack/update-codegen.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -o errexit +set -o nounset +set -o pipefail + +SCRIPT_ROOT=$(dirname ${BASH_SOURCE})/.. +CODEGEN_PKG=${CODEGEN_PKG:-$(cd ${SCRIPT_ROOT}; ls -d -1 ./vendor/k8s.io/code-generator 2>/dev/null || echo ../code-generator)} + +# generate the code with: +# --output-base because this script should also be able to run inside the vendor dir of +# k8s.io/kubernetes. The output-base is needed for the generators to output into the vendor dir +# instead of the $GOPATH directly. For normal projects this can be dropped. +${CODEGEN_PKG}/generate-groups.sh "deepcopy,client,informer,lister" \ + github.com/kyma-project/kyma/components/installer/pkg/client github.com/kyma-project/kyma/components/installer/pkg/apis \ + "installer:v1alpha1 release:v1alpha1" \ + --output-base "$(dirname ${BASH_SOURCE})/../../../.." \ + --go-header-file ${SCRIPT_ROOT}/hack/custom-boilerplate.go.txt diff --git a/components/installer/pkg/actionmanager/action-manager.go b/components/installer/pkg/actionmanager/action-manager.go new file mode 100644 index 000000000000..da7605974aef --- /dev/null +++ b/components/installer/pkg/actionmanager/action-manager.go @@ -0,0 +1,56 @@ +package actionmanager + +import ( + "log" + + clientset "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned" + listers "github.com/kyma-project/kyma/components/installer/pkg/client/listers/installer/v1alpha1" + "k8s.io/client-go/util/retry" +) + +// ActionManager . +type ActionManager interface { + // RemoveActionLabel . + RemoveActionLabel(name string, namespace string, labelName string) error +} + +//KymaActionManager . +type KymaActionManager struct { + installationLister listers.InstallationLister + internalClientset *clientset.Clientset +} + +// NewKymaActionManager . +func NewKymaActionManager(internalClientset *clientset.Clientset, installationLister listers.InstallationLister) *KymaActionManager { + return &KymaActionManager{ + installationLister: installationLister, + internalClientset: internalClientset, + } +} + +//RemoveActionLabel . +func (am *KymaActionManager) RemoveActionLabel(name string, namespace string, labelName string) error { + retryErr := retry.RetryOnConflict(retry.DefaultRetry, func() error { + instObj, getErr := am.installationLister.Installations(namespace).Get(name) + if getErr != nil { + log.Println("Error on getting installation object") + log.Println(getErr) + } + + installationCopy := instObj.DeepCopy() + labels := installationCopy.GetLabels() + delete(labels, labelName) + installationCopy.SetLabels(labels) + + _, updateErr := am.internalClientset.InstallerV1alpha1().Installations(namespace).Update(installationCopy) + return updateErr + }) + + if retryErr != nil { + log.Println("Error on removing installation action") + log.Println(retryErr) + return retryErr + } + + return nil +} diff --git a/components/installer/pkg/apis/release/v1alpha1/doc.go b/components/installer/pkg/apis/release/v1alpha1/doc.go new file mode 100644 index 000000000000..a9efb75b1f32 --- /dev/null +++ b/components/installer/pkg/apis/release/v1alpha1/doc.go @@ -0,0 +1,4 @@ +// Package v1alpha1 . +// +k8s:deepcopy-gen=package +// +groupName=release.kyma.cx +package v1alpha1 diff --git a/components/installer/pkg/apis/release/v1alpha1/register.go b/components/installer/pkg/apis/release/v1alpha1/register.go new file mode 100644 index 000000000000..07b2194ebf8a --- /dev/null +++ b/components/installer/pkg/apis/release/v1alpha1/register.go @@ -0,0 +1,46 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + // Group . + Group = "release.kyma.cx" + // Version . + Version = "v1alpha1" +) + +var ( + // SchemeGroupVersion is group version used to register these objects + SchemeGroupVersion = schema.GroupVersion{ + Group: Group, + Version: Version, + } + // SchemeBuilder . + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + // AddToScheme . + AddToScheme = SchemeBuilder.AddToScheme +) + +// Kind takes an unqualified kind and returns back a Group qualified GroupKind +func Kind(kind string) schema.GroupKind { + return SchemeGroupVersion.WithKind(kind).GroupKind() +} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} + +// Adds the list of known types to Scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &Release{}, + &ReleaseList{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/components/installer/pkg/apis/release/v1alpha1/types.go b/components/installer/pkg/apis/release/v1alpha1/types.go new file mode 100644 index 000000000000..13daa31b5068 --- /dev/null +++ b/components/installer/pkg/apis/release/v1alpha1/types.go @@ -0,0 +1,41 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +genclient +// +genclient:noStatus +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// Release . +type Release struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ReleaseSpec `json:"spec"` + Status ReleaseStatus `json:"status"` +} + +// ReleaseSpec . +type ReleaseSpec struct { + Version string `json:"version"` + URL string `json:"url"` + Description string `json:"description"` + Notes string `json:"notes"` +} + +// ReleaseStatus . +type ReleaseStatus struct { + Installed bool `json:"installed"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// ReleaseList . +type ReleaseList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []Release `json:"items"` +} diff --git a/components/installer/pkg/apis/release/v1alpha1/zz_generated.deepcopy.go b/components/installer/pkg/apis/release/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 000000000000..714e87389e31 --- /dev/null +++ b/components/installer/pkg/apis/release/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,102 @@ +// +build !ignore_autogenerated + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + 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 *Release) DeepCopyInto(out *Release) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + out.Status = in.Status + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Release. +func (in *Release) DeepCopy() *Release { + if in == nil { + return nil + } + out := new(Release) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Release) 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 *ReleaseList) DeepCopyInto(out *ReleaseList) { + *out = *in + out.TypeMeta = in.TypeMeta + out.ListMeta = in.ListMeta + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Release, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReleaseList. +func (in *ReleaseList) DeepCopy() *ReleaseList { + if in == nil { + return nil + } + out := new(ReleaseList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ReleaseList) 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 *ReleaseSpec) DeepCopyInto(out *ReleaseSpec) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReleaseSpec. +func (in *ReleaseSpec) DeepCopy() *ReleaseSpec { + if in == nil { + return nil + } + out := new(ReleaseSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ReleaseStatus) DeepCopyInto(out *ReleaseStatus) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReleaseStatus. +func (in *ReleaseStatus) DeepCopy() *ReleaseStatus { + if in == nil { + return nil + } + out := new(ReleaseStatus) + in.DeepCopyInto(out) + return out +} diff --git a/components/installer/pkg/azure-vault/client.go b/components/installer/pkg/azure-vault/client.go new file mode 100644 index 000000000000..bdf1fe69babc --- /dev/null +++ b/components/installer/pkg/azure-vault/client.go @@ -0,0 +1,82 @@ +package azurevault + +import ( + "context" + "fmt" + + "github.com/Azure/azure-sdk-for-go/services/keyvault/2016-10-01/keyvault" + "github.com/Azure/go-autorest/autorest" + "github.com/Azure/go-autorest/autorest/adal" + "github.com/Azure/go-autorest/autorest/azure" +) + +type azureKeyvaultInterface interface { + GetSecret(secretName string) (keyvault.SecretBundle, error) + GetCertificate(certName string) (keyvault.CertificateBundle, error) + GetKey(keyName string) (keyvault.KeyBundle, error) +} + +type azureKeyvault struct { + client keyvault.BaseClient + vaultURL string +} + +func getAzureKeyvaultClient(azureClientID string, azureClientSecret string, vaultURL string) *azureKeyvault { + return &azureKeyvault{ + client: getClient(azureClientID, azureClientSecret), + vaultURL: vaultURL, + } +} + +func getClient(azureClientID string, azureClientSecret string) keyvault.BaseClient { + client := keyvault.New() + client.Sender = autorest.CreateSender() + + client.Authorizer = autorest.NewBearerAuthorizerCallback(client.Sender, func(tenantID, resource string) (*autorest.BearerAuthorizer, error) { + env := azure.PublicCloud + keyVaultOauthConfig, err := getAzureOAuthConfig(env.ActiveDirectoryEndpoint, tenantID) + if err != nil { + return nil, err + } + + keyVaultSpt, err := adal.NewServicePrincipalToken(*keyVaultOauthConfig, azureClientID, azureClientSecret, resource) + if err != nil { + return nil, err + } + + return autorest.NewBearerAuthorizer(keyVaultSpt), nil + }) + + return client +} + +func getAzureOAuthConfig(endpoint, tenantID string) (*adal.OAuthConfig, error) { + oauthConfig, err := adal.NewOAuthConfig(endpoint, tenantID) + if err != nil { + return nil, err + } + + if oauthConfig == nil { + return nil, fmt.Errorf("Unable to configure OAuthConfig for tenant %s", tenantID) + } + + return oauthConfig, nil +} + +//GetSecret . +func (ac *azureKeyvault) GetSecret(secretName string) (keyvault.SecretBundle, error) { + ctx := context.Background() + return ac.client.GetSecret(ctx, ac.vaultURL, secretName, "") +} + +//GetCertificate . +func (ac *azureKeyvault) GetCertificate(certName string) (keyvault.CertificateBundle, error) { + ctx := context.Background() + return ac.client.GetCertificate(ctx, ac.vaultURL, certName, "") +} + +//GetKey . +func (ac *azureKeyvault) GetKey(keyName string) (keyvault.KeyBundle, error) { + ctx := context.Background() + return ac.client.GetKey(ctx, ac.vaultURL, keyName, "") +} diff --git a/components/installer/pkg/azure-vault/secret-provider.go b/components/installer/pkg/azure-vault/secret-provider.go new file mode 100644 index 000000000000..9126a59631dd --- /dev/null +++ b/components/installer/pkg/azure-vault/secret-provider.go @@ -0,0 +1,61 @@ +package azurevault + +import ( + "strings" +) + +// SecretProviderInterface exposes functions to interact with azure-vault client +type SecretProviderInterface interface { + GetSecret(secretName string) (string, error) + GetCertificate(certName string) (string, error) + GetKey(keyName string) (string, error) +} + +// SecretProvider provides functions to interact with azure-vault client +// Implements SecretProviderInterface +type SecretProvider struct { + client azureKeyvaultInterface +} + +// NewSecretProvider . +func NewSecretProvider(azureClientID string, azureClientSecret string, vaultURL string) *SecretProvider { + return &SecretProvider{ + client: getAzureKeyvaultClient(azureClientID, azureClientSecret, vaultURL), + } +} + +// GetSecret parses the object returned by azure-vault client and returns its value converted to string +func (sp *SecretProvider) GetSecret(secretName string) (string, error) { + + secretBundle, err := sp.client.GetSecret(secretName) + if err != nil { + return "", err + } + + secret := removeNewLines(useString(secretBundle.Value)) + return secret, nil +} + +// GetCertificate . +func (sp *SecretProvider) GetCertificate(certName string) (string, error) { + return "", nil //todo +} + +// GetKey . +func (sp *SecretProvider) GetKey(keyName string) (string, error) { + return "", nil //todo +} + +func useString(s *string) string { + if s == nil { + return "" + } + return *s +} + +// We do that for two reasons: +// - Multiline string values can be used in yaml files with special syntax, but we don't support it (our templates are static). +// - K8s Helm client converts newline characters to spaces (in overrides), which breaks yaml deployments for secretes (base64 tokens contain spaces). +func removeNewLines(s string) string { + return strings.Replace(s, "\n", "", -1) +} diff --git a/components/installer/pkg/azure-vault/secret-provider_test.go b/components/installer/pkg/azure-vault/secret-provider_test.go new file mode 100644 index 000000000000..e9617a157cff --- /dev/null +++ b/components/installer/pkg/azure-vault/secret-provider_test.go @@ -0,0 +1,58 @@ +package azurevault + +import ( + "strings" + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestRemoveNewLines(t *testing.T) { + + Convey("Values fetched from vault", t, func() { + + testValue := ` +first +second +third +` + Convey("should be converted to one-line string", func() { + + processedValue := removeNewLines(testValue) + numOfNewlines := strings.Count(processedValue, "\n") + + expectedOutput := "firstsecondthird" + + So(numOfNewlines, ShouldEqual, 0) + So(processedValue, ShouldEqual, expectedOutput) + }) + }) +} + +func TestUseString(t *testing.T) { + + Convey("Pointer to a string", t, func() { + + var pointerToTestString *string + + Convey("should be dereferenced to empty string if it is nil", func() { + + pointerToTestString = nil + + emptyString := "" + dereferencedPointer := useString(pointerToTestString) + + So(dereferencedPointer, ShouldEqual, emptyString) + }) + + Convey("should be dereferenced to a string if it is not nil", func() { + + testString := "test string" + pointerToTestString = &testString + + dereferencedPointer := useString(pointerToTestString) + + So(dereferencedPointer, ShouldEqual, testString) + }) + }) +} diff --git a/components/installer/pkg/client/clientset/versioned/clientset.go b/components/installer/pkg/client/clientset/versioned/clientset.go new file mode 100644 index 000000000000..3907ab61b584 --- /dev/null +++ b/components/installer/pkg/client/clientset/versioned/clientset.go @@ -0,0 +1,106 @@ +// Code generated by client-gen. DO NOT EDIT. + +package versioned + +import ( + glog "github.com/golang/glog" + installerv1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned/typed/installer/v1alpha1" + releasev1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1" + discovery "k8s.io/client-go/discovery" + rest "k8s.io/client-go/rest" + flowcontrol "k8s.io/client-go/util/flowcontrol" +) + +type Interface interface { + Discovery() discovery.DiscoveryInterface + InstallerV1alpha1() installerv1alpha1.InstallerV1alpha1Interface + // Deprecated: please explicitly pick a version if possible. + Installer() installerv1alpha1.InstallerV1alpha1Interface + ReleaseV1alpha1() releasev1alpha1.ReleaseV1alpha1Interface + // Deprecated: please explicitly pick a version if possible. + Release() releasev1alpha1.ReleaseV1alpha1Interface +} + +// Clientset contains the clients for groups. Each group has exactly one +// version included in a Clientset. +type Clientset struct { + *discovery.DiscoveryClient + installerV1alpha1 *installerv1alpha1.InstallerV1alpha1Client + releaseV1alpha1 *releasev1alpha1.ReleaseV1alpha1Client +} + +// InstallerV1alpha1 retrieves the InstallerV1alpha1Client +func (c *Clientset) InstallerV1alpha1() installerv1alpha1.InstallerV1alpha1Interface { + return c.installerV1alpha1 +} + +// Deprecated: Installer retrieves the default version of InstallerClient. +// Please explicitly pick a version. +func (c *Clientset) Installer() installerv1alpha1.InstallerV1alpha1Interface { + return c.installerV1alpha1 +} + +// ReleaseV1alpha1 retrieves the ReleaseV1alpha1Client +func (c *Clientset) ReleaseV1alpha1() releasev1alpha1.ReleaseV1alpha1Interface { + return c.releaseV1alpha1 +} + +// Deprecated: Release retrieves the default version of ReleaseClient. +// Please explicitly pick a version. +func (c *Clientset) Release() releasev1alpha1.ReleaseV1alpha1Interface { + return c.releaseV1alpha1 +} + +// Discovery retrieves the DiscoveryClient +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + if c == nil { + return nil + } + return c.DiscoveryClient +} + +// NewForConfig creates a new Clientset for the given config. +func NewForConfig(c *rest.Config) (*Clientset, error) { + configShallowCopy := *c + if configShallowCopy.RateLimiter == nil && configShallowCopy.QPS > 0 { + configShallowCopy.RateLimiter = flowcontrol.NewTokenBucketRateLimiter(configShallowCopy.QPS, configShallowCopy.Burst) + } + var cs Clientset + var err error + cs.installerV1alpha1, err = installerv1alpha1.NewForConfig(&configShallowCopy) + if err != nil { + return nil, err + } + cs.releaseV1alpha1, err = releasev1alpha1.NewForConfig(&configShallowCopy) + if err != nil { + return nil, err + } + + cs.DiscoveryClient, err = discovery.NewDiscoveryClientForConfig(&configShallowCopy) + if err != nil { + glog.Errorf("failed to create the DiscoveryClient: %v", err) + return nil, err + } + return &cs, nil +} + +// NewForConfigOrDie creates a new Clientset for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *Clientset { + var cs Clientset + cs.installerV1alpha1 = installerv1alpha1.NewForConfigOrDie(c) + cs.releaseV1alpha1 = releasev1alpha1.NewForConfigOrDie(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClientForConfigOrDie(c) + return &cs +} + +// New creates a new Clientset for the given RESTClient. +func New(c rest.Interface) *Clientset { + var cs Clientset + cs.installerV1alpha1 = installerv1alpha1.New(c) + cs.releaseV1alpha1 = releasev1alpha1.New(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClient(c) + return &cs +} diff --git a/components/installer/pkg/client/clientset/versioned/doc.go b/components/installer/pkg/client/clientset/versioned/doc.go new file mode 100644 index 000000000000..0e0c2a8900e2 --- /dev/null +++ b/components/installer/pkg/client/clientset/versioned/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated clientset. +package versioned diff --git a/components/installer/pkg/client/clientset/versioned/fake/clientset_generated.go b/components/installer/pkg/client/clientset/versioned/fake/clientset_generated.go new file mode 100644 index 000000000000..91b6e28b69f1 --- /dev/null +++ b/components/installer/pkg/client/clientset/versioned/fake/clientset_generated.go @@ -0,0 +1,77 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + clientset "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned" + installerv1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned/typed/installer/v1alpha1" + fakeinstallerv1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned/typed/installer/v1alpha1/fake" + releasev1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1" + fakereleasev1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/fake" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/discovery" + fakediscovery "k8s.io/client-go/discovery/fake" + "k8s.io/client-go/testing" +) + +// NewSimpleClientset returns a clientset that will respond with the provided objects. +// It's backed by a very simple object tracker that processes creates, updates and deletions as-is, +// without applying any validations and/or defaults. It shouldn't be considered a replacement +// for a real clientset and is mostly useful in simple unit tests. +func NewSimpleClientset(objects ...runtime.Object) *Clientset { + o := testing.NewObjectTracker(scheme, codecs.UniversalDecoder()) + for _, obj := range objects { + if err := o.Add(obj); err != nil { + panic(err) + } + } + + fakePtr := testing.Fake{} + fakePtr.AddReactor("*", "*", testing.ObjectReaction(o)) + fakePtr.AddWatchReactor("*", func(action testing.Action) (handled bool, ret watch.Interface, err error) { + gvr := action.GetResource() + ns := action.GetNamespace() + watch, err := o.Watch(gvr, ns) + if err != nil { + return false, nil, err + } + return true, watch, nil + }) + + return &Clientset{fakePtr, &fakediscovery.FakeDiscovery{Fake: &fakePtr}} +} + +// Clientset implements clientset.Interface. Meant to be embedded into a +// struct to get a default implementation. This makes faking out just the method +// you want to test easier. +type Clientset struct { + testing.Fake + discovery *fakediscovery.FakeDiscovery +} + +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + return c.discovery +} + +var _ clientset.Interface = &Clientset{} + +// InstallerV1alpha1 retrieves the InstallerV1alpha1Client +func (c *Clientset) InstallerV1alpha1() installerv1alpha1.InstallerV1alpha1Interface { + return &fakeinstallerv1alpha1.FakeInstallerV1alpha1{Fake: &c.Fake} +} + +// Installer retrieves the InstallerV1alpha1Client +func (c *Clientset) Installer() installerv1alpha1.InstallerV1alpha1Interface { + return &fakeinstallerv1alpha1.FakeInstallerV1alpha1{Fake: &c.Fake} +} + +// ReleaseV1alpha1 retrieves the ReleaseV1alpha1Client +func (c *Clientset) ReleaseV1alpha1() releasev1alpha1.ReleaseV1alpha1Interface { + return &fakereleasev1alpha1.FakeReleaseV1alpha1{Fake: &c.Fake} +} + +// Release retrieves the ReleaseV1alpha1Client +func (c *Clientset) Release() releasev1alpha1.ReleaseV1alpha1Interface { + return &fakereleasev1alpha1.FakeReleaseV1alpha1{Fake: &c.Fake} +} diff --git a/components/installer/pkg/client/clientset/versioned/fake/doc.go b/components/installer/pkg/client/clientset/versioned/fake/doc.go new file mode 100644 index 000000000000..3630ed1cd17d --- /dev/null +++ b/components/installer/pkg/client/clientset/versioned/fake/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated fake clientset. +package fake diff --git a/components/installer/pkg/client/clientset/versioned/fake/register.go b/components/installer/pkg/client/clientset/versioned/fake/register.go new file mode 100644 index 000000000000..1879988a37cd --- /dev/null +++ b/components/installer/pkg/client/clientset/versioned/fake/register.go @@ -0,0 +1,40 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + installerv1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/apis/installer/v1alpha1" + releasev1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/apis/release/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var scheme = runtime.NewScheme() +var codecs = serializer.NewCodecFactory(scheme) +var parameterCodec = runtime.NewParameterCodec(scheme) + +func init() { + v1.AddToGroupVersion(scheme, schema.GroupVersion{Version: "v1"}) + AddToScheme(scheme) +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +func AddToScheme(scheme *runtime.Scheme) { + installerv1alpha1.AddToScheme(scheme) + releasev1alpha1.AddToScheme(scheme) +} diff --git a/components/installer/pkg/client/clientset/versioned/scheme/doc.go b/components/installer/pkg/client/clientset/versioned/scheme/doc.go new file mode 100644 index 000000000000..14db57a58f8d --- /dev/null +++ b/components/installer/pkg/client/clientset/versioned/scheme/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package contains the scheme of the automatically generated clientset. +package scheme diff --git a/components/installer/pkg/client/clientset/versioned/scheme/register.go b/components/installer/pkg/client/clientset/versioned/scheme/register.go new file mode 100644 index 000000000000..1a4edcde5c52 --- /dev/null +++ b/components/installer/pkg/client/clientset/versioned/scheme/register.go @@ -0,0 +1,40 @@ +// Code generated by client-gen. DO NOT EDIT. + +package scheme + +import ( + installerv1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/apis/installer/v1alpha1" + releasev1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/apis/release/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var Scheme = runtime.NewScheme() +var Codecs = serializer.NewCodecFactory(Scheme) +var ParameterCodec = runtime.NewParameterCodec(Scheme) + +func init() { + v1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"}) + AddToScheme(Scheme) +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +func AddToScheme(scheme *runtime.Scheme) { + installerv1alpha1.AddToScheme(scheme) + releasev1alpha1.AddToScheme(scheme) +} diff --git a/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/doc.go b/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/doc.go new file mode 100644 index 000000000000..93a7ca4e0e2b --- /dev/null +++ b/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated typed clients. +package v1alpha1 diff --git a/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/fake/doc.go b/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/fake/doc.go new file mode 100644 index 000000000000..2b5ba4c8e442 --- /dev/null +++ b/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/fake/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// Package fake has the automatically generated clients. +package fake diff --git a/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/fake/fake_release.go b/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/fake/fake_release.go new file mode 100644 index 000000000000..a2df9061d600 --- /dev/null +++ b/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/fake/fake_release.go @@ -0,0 +1,112 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/apis/release/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeReleases implements ReleaseInterface +type FakeReleases struct { + Fake *FakeReleaseV1alpha1 + ns string +} + +var releasesResource = schema.GroupVersionResource{Group: "release.kyma.cx", Version: "v1alpha1", Resource: "releases"} + +var releasesKind = schema.GroupVersionKind{Group: "release.kyma.cx", Version: "v1alpha1", Kind: "Release"} + +// Get takes name of the release, and returns the corresponding release object, and an error if there is any. +func (c *FakeReleases) Get(name string, options v1.GetOptions) (result *v1alpha1.Release, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(releasesResource, c.ns, name), &v1alpha1.Release{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Release), err +} + +// List takes label and field selectors, and returns the list of Releases that match those selectors. +func (c *FakeReleases) List(opts v1.ListOptions) (result *v1alpha1.ReleaseList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(releasesResource, releasesKind, c.ns, opts), &v1alpha1.ReleaseList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha1.ReleaseList{} + for _, item := range obj.(*v1alpha1.ReleaseList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested releases. +func (c *FakeReleases) Watch(opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(releasesResource, c.ns, opts)) + +} + +// Create takes the representation of a release and creates it. Returns the server's representation of the release, and an error, if there is any. +func (c *FakeReleases) Create(release *v1alpha1.Release) (result *v1alpha1.Release, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(releasesResource, c.ns, release), &v1alpha1.Release{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Release), err +} + +// Update takes the representation of a release and updates it. Returns the server's representation of the release, and an error, if there is any. +func (c *FakeReleases) Update(release *v1alpha1.Release) (result *v1alpha1.Release, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(releasesResource, c.ns, release), &v1alpha1.Release{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Release), err +} + +// Delete takes name of the release and deletes it. Returns an error if one occurs. +func (c *FakeReleases) Delete(name string, options *v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(releasesResource, c.ns, name), &v1alpha1.Release{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeReleases) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(releasesResource, c.ns, listOptions) + + _, err := c.Fake.Invokes(action, &v1alpha1.ReleaseList{}) + return err +} + +// Patch applies the patch and returns the patched release. +func (c *FakeReleases) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.Release, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(releasesResource, c.ns, name, data, subresources...), &v1alpha1.Release{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.Release), err +} diff --git a/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/fake/fake_release_client.go b/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/fake/fake_release_client.go new file mode 100644 index 000000000000..8070a7ebf71b --- /dev/null +++ b/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/fake/fake_release_client.go @@ -0,0 +1,24 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1" + rest "k8s.io/client-go/rest" + testing "k8s.io/client-go/testing" +) + +type FakeReleaseV1alpha1 struct { + *testing.Fake +} + +func (c *FakeReleaseV1alpha1) Releases(namespace string) v1alpha1.ReleaseInterface { + return &FakeReleases{c, namespace} +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *FakeReleaseV1alpha1) RESTClient() rest.Interface { + var ret *rest.RESTClient + return ret +} diff --git a/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/generated_expansion.go b/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/generated_expansion.go new file mode 100644 index 000000000000..f9bb612faf38 --- /dev/null +++ b/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/generated_expansion.go @@ -0,0 +1,5 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +type ReleaseExpansion interface{} diff --git a/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/release.go b/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/release.go new file mode 100644 index 000000000000..522ae615d691 --- /dev/null +++ b/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/release.go @@ -0,0 +1,141 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/apis/release/v1alpha1" + scheme "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// ReleasesGetter has a method to return a ReleaseInterface. +// A group's client should implement this interface. +type ReleasesGetter interface { + Releases(namespace string) ReleaseInterface +} + +// ReleaseInterface has methods to work with Release resources. +type ReleaseInterface interface { + Create(*v1alpha1.Release) (*v1alpha1.Release, error) + Update(*v1alpha1.Release) (*v1alpha1.Release, error) + Delete(name string, options *v1.DeleteOptions) error + DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error + Get(name string, options v1.GetOptions) (*v1alpha1.Release, error) + List(opts v1.ListOptions) (*v1alpha1.ReleaseList, error) + Watch(opts v1.ListOptions) (watch.Interface, error) + Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.Release, err error) + ReleaseExpansion +} + +// releases implements ReleaseInterface +type releases struct { + client rest.Interface + ns string +} + +// newReleases returns a Releases +func newReleases(c *ReleaseV1alpha1Client, namespace string) *releases { + return &releases{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the release, and returns the corresponding release object, and an error if there is any. +func (c *releases) Get(name string, options v1.GetOptions) (result *v1alpha1.Release, err error) { + result = &v1alpha1.Release{} + err = c.client.Get(). + Namespace(c.ns). + Resource("releases"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of Releases that match those selectors. +func (c *releases) List(opts v1.ListOptions) (result *v1alpha1.ReleaseList, err error) { + result = &v1alpha1.ReleaseList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("releases"). + VersionedParams(&opts, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested releases. +func (c *releases) Watch(opts v1.ListOptions) (watch.Interface, error) { + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("releases"). + VersionedParams(&opts, scheme.ParameterCodec). + Watch() +} + +// Create takes the representation of a release and creates it. Returns the server's representation of the release, and an error, if there is any. +func (c *releases) Create(release *v1alpha1.Release) (result *v1alpha1.Release, err error) { + result = &v1alpha1.Release{} + err = c.client.Post(). + Namespace(c.ns). + Resource("releases"). + Body(release). + Do(). + Into(result) + return +} + +// Update takes the representation of a release and updates it. Returns the server's representation of the release, and an error, if there is any. +func (c *releases) Update(release *v1alpha1.Release) (result *v1alpha1.Release, err error) { + result = &v1alpha1.Release{} + err = c.client.Put(). + Namespace(c.ns). + Resource("releases"). + Name(release.Name). + Body(release). + Do(). + Into(result) + return +} + +// Delete takes name of the release and deletes it. Returns an error if one occurs. +func (c *releases) Delete(name string, options *v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("releases"). + Name(name). + Body(options). + Do(). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *releases) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("releases"). + VersionedParams(&listOptions, scheme.ParameterCodec). + Body(options). + Do(). + Error() +} + +// Patch applies the patch and returns the patched release. +func (c *releases) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.Release, err error) { + result = &v1alpha1.Release{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("releases"). + SubResource(subresources...). + Name(name). + Body(data). + Do(). + Into(result) + return +} diff --git a/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/release_client.go b/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/release_client.go new file mode 100644 index 000000000000..0a4d737561d6 --- /dev/null +++ b/components/installer/pkg/client/clientset/versioned/typed/release/v1alpha1/release_client.go @@ -0,0 +1,74 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/apis/release/v1alpha1" + "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned/scheme" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + rest "k8s.io/client-go/rest" +) + +type ReleaseV1alpha1Interface interface { + RESTClient() rest.Interface + ReleasesGetter +} + +// ReleaseV1alpha1Client is used to interact with features provided by the release.kyma.cx group. +type ReleaseV1alpha1Client struct { + restClient rest.Interface +} + +func (c *ReleaseV1alpha1Client) Releases(namespace string) ReleaseInterface { + return newReleases(c, namespace) +} + +// NewForConfig creates a new ReleaseV1alpha1Client for the given config. +func NewForConfig(c *rest.Config) (*ReleaseV1alpha1Client, error) { + config := *c + if err := setConfigDefaults(&config); err != nil { + return nil, err + } + client, err := rest.RESTClientFor(&config) + if err != nil { + return nil, err + } + return &ReleaseV1alpha1Client{client}, nil +} + +// NewForConfigOrDie creates a new ReleaseV1alpha1Client for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *ReleaseV1alpha1Client { + client, err := NewForConfig(c) + if err != nil { + panic(err) + } + return client +} + +// New creates a new ReleaseV1alpha1Client for the given RESTClient. +func New(c rest.Interface) *ReleaseV1alpha1Client { + return &ReleaseV1alpha1Client{c} +} + +func setConfigDefaults(config *rest.Config) error { + gv := v1alpha1.SchemeGroupVersion + config.GroupVersion = &gv + config.APIPath = "/apis" + config.NegotiatedSerializer = serializer.DirectCodecFactory{CodecFactory: scheme.Codecs} + + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } + + return nil +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *ReleaseV1alpha1Client) RESTClient() rest.Interface { + if c == nil { + return nil + } + return c.restClient +} diff --git a/components/installer/pkg/client/informers/externalversions/factory.go b/components/installer/pkg/client/informers/externalversions/factory.go new file mode 100644 index 000000000000..bb76a06516de --- /dev/null +++ b/components/installer/pkg/client/informers/externalversions/factory.go @@ -0,0 +1,121 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + reflect "reflect" + sync "sync" + time "time" + + versioned "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned" + installer "github.com/kyma-project/kyma/components/installer/pkg/client/informers/externalversions/installer" + internalinterfaces "github.com/kyma-project/kyma/components/installer/pkg/client/informers/externalversions/internalinterfaces" + release "github.com/kyma-project/kyma/components/installer/pkg/client/informers/externalversions/release" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +type sharedInformerFactory struct { + client versioned.Interface + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc + lock sync.Mutex + defaultResync time.Duration + + informers map[reflect.Type]cache.SharedIndexInformer + // startedInformers is used for tracking which informers have been started. + // This allows Start() to be called multiple times safely. + startedInformers map[reflect.Type]bool +} + +// NewSharedInformerFactory constructs a new instance of sharedInformerFactory +func NewSharedInformerFactory(client versioned.Interface, defaultResync time.Duration) SharedInformerFactory { + return NewFilteredSharedInformerFactory(client, defaultResync, v1.NamespaceAll, nil) +} + +// NewFilteredSharedInformerFactory constructs a new instance of sharedInformerFactory. +// Listers obtained via this SharedInformerFactory will be subject to the same filters +// as specified here. +func NewFilteredSharedInformerFactory(client versioned.Interface, defaultResync time.Duration, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) SharedInformerFactory { + return &sharedInformerFactory{ + client: client, + namespace: namespace, + tweakListOptions: tweakListOptions, + defaultResync: defaultResync, + informers: make(map[reflect.Type]cache.SharedIndexInformer), + startedInformers: make(map[reflect.Type]bool), + } +} + +// Start initializes all requested informers. +func (f *sharedInformerFactory) Start(stopCh <-chan struct{}) { + f.lock.Lock() + defer f.lock.Unlock() + + for informerType, informer := range f.informers { + if !f.startedInformers[informerType] { + go informer.Run(stopCh) + f.startedInformers[informerType] = true + } + } +} + +// WaitForCacheSync waits for all started informers' cache were synced. +func (f *sharedInformerFactory) WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool { + informers := func() map[reflect.Type]cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informers := map[reflect.Type]cache.SharedIndexInformer{} + for informerType, informer := range f.informers { + if f.startedInformers[informerType] { + informers[informerType] = informer + } + } + return informers + }() + + res := map[reflect.Type]bool{} + for informType, informer := range informers { + res[informType] = cache.WaitForCacheSync(stopCh, informer.HasSynced) + } + return res +} + +// InternalInformerFor returns the SharedIndexInformer for obj using an internal +// client. +func (f *sharedInformerFactory) InformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informerType := reflect.TypeOf(obj) + informer, exists := f.informers[informerType] + if exists { + return informer + } + informer = newFunc(f.client, f.defaultResync) + f.informers[informerType] = informer + + return informer +} + +// SharedInformerFactory provides shared informers for resources in all known +// API group versions. +type SharedInformerFactory interface { + internalinterfaces.SharedInformerFactory + ForResource(resource schema.GroupVersionResource) (GenericInformer, error) + WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool + + Installer() installer.Interface + Release() release.Interface +} + +func (f *sharedInformerFactory) Installer() installer.Interface { + return installer.New(f, f.namespace, f.tweakListOptions) +} + +func (f *sharedInformerFactory) Release() release.Interface { + return release.New(f, f.namespace, f.tweakListOptions) +} diff --git a/components/installer/pkg/client/informers/externalversions/generic.go b/components/installer/pkg/client/informers/externalversions/generic.go new file mode 100644 index 000000000000..b9e6e8f1b5f1 --- /dev/null +++ b/components/installer/pkg/client/informers/externalversions/generic.go @@ -0,0 +1,51 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + "fmt" + + v1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/apis/installer/v1alpha1" + release_v1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/apis/release/v1alpha1" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +// GenericInformer is type of SharedIndexInformer which will locate and delegate to other +// sharedInformers based on type +type GenericInformer interface { + Informer() cache.SharedIndexInformer + Lister() cache.GenericLister +} + +type genericInformer struct { + informer cache.SharedIndexInformer + resource schema.GroupResource +} + +// Informer returns the SharedIndexInformer. +func (f *genericInformer) Informer() cache.SharedIndexInformer { + return f.informer +} + +// Lister returns the GenericLister. +func (f *genericInformer) Lister() cache.GenericLister { + return cache.NewGenericLister(f.Informer().GetIndexer(), f.resource) +} + +// ForResource gives generic access to a shared informer of the matching type +// TODO extend this to unknown resources with a client pool +func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { + switch resource { + // Group=installer.kyma.cx, Version=v1alpha1 + case v1alpha1.SchemeGroupVersion.WithResource("installations"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Installer().V1alpha1().Installations().Informer()}, nil + + // Group=release.kyma.cx, Version=v1alpha1 + case release_v1alpha1.SchemeGroupVersion.WithResource("releases"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Release().V1alpha1().Releases().Informer()}, nil + + } + + return nil, fmt.Errorf("no informer found for %v", resource) +} diff --git a/components/installer/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go b/components/installer/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go new file mode 100644 index 000000000000..83af6618f8b5 --- /dev/null +++ b/components/installer/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go @@ -0,0 +1,22 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package internalinterfaces + +import ( + time "time" + + versioned "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + cache "k8s.io/client-go/tools/cache" +) + +type NewInformerFunc func(versioned.Interface, time.Duration) cache.SharedIndexInformer + +// SharedInformerFactory a small interface to allow for adding an informer without an import cycle +type SharedInformerFactory interface { + Start(stopCh <-chan struct{}) + InformerFor(obj runtime.Object, newFunc NewInformerFunc) cache.SharedIndexInformer +} + +type TweakListOptionsFunc func(*v1.ListOptions) diff --git a/components/installer/pkg/client/informers/externalversions/release/interface.go b/components/installer/pkg/client/informers/externalversions/release/interface.go new file mode 100644 index 000000000000..c86ec3ae36fe --- /dev/null +++ b/components/installer/pkg/client/informers/externalversions/release/interface.go @@ -0,0 +1,30 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package release + +import ( + internalinterfaces "github.com/kyma-project/kyma/components/installer/pkg/client/informers/externalversions/internalinterfaces" + v1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/client/informers/externalversions/release/v1alpha1" +) + +// Interface provides access to each of this group's versions. +type Interface interface { + // V1alpha1 provides access to shared informers for resources in V1alpha1. + V1alpha1() v1alpha1.Interface +} + +type group struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// V1alpha1 returns a new v1alpha1.Interface. +func (g *group) V1alpha1() v1alpha1.Interface { + return v1alpha1.New(g.factory, g.namespace, g.tweakListOptions) +} diff --git a/components/installer/pkg/client/informers/externalversions/release/v1alpha1/interface.go b/components/installer/pkg/client/informers/externalversions/release/v1alpha1/interface.go new file mode 100644 index 000000000000..0a8b31a54b48 --- /dev/null +++ b/components/installer/pkg/client/informers/externalversions/release/v1alpha1/interface.go @@ -0,0 +1,29 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + internalinterfaces "github.com/kyma-project/kyma/components/installer/pkg/client/informers/externalversions/internalinterfaces" +) + +// Interface provides access to all the informers in this group version. +type Interface interface { + // Releases returns a ReleaseInformer. + Releases() ReleaseInformer +} + +type version struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// Releases returns a ReleaseInformer. +func (v *version) Releases() ReleaseInformer { + return &releaseInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} diff --git a/components/installer/pkg/client/informers/externalversions/release/v1alpha1/release.go b/components/installer/pkg/client/informers/externalversions/release/v1alpha1/release.go new file mode 100644 index 000000000000..8d2e607a638a --- /dev/null +++ b/components/installer/pkg/client/informers/externalversions/release/v1alpha1/release.go @@ -0,0 +1,73 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + time "time" + + release_v1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/apis/release/v1alpha1" + versioned "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/installer/pkg/client/informers/externalversions/internalinterfaces" + v1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/client/listers/release/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// ReleaseInformer provides access to a shared informer and lister for +// Releases. +type ReleaseInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha1.ReleaseLister +} + +type releaseInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewReleaseInformer constructs a new informer for Release type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewReleaseInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredReleaseInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredReleaseInformer constructs a new informer for Release type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredReleaseInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ReleaseV1alpha1().Releases(namespace).List(options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ReleaseV1alpha1().Releases(namespace).Watch(options) + }, + }, + &release_v1alpha1.Release{}, + resyncPeriod, + indexers, + ) +} + +func (f *releaseInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredReleaseInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *releaseInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&release_v1alpha1.Release{}, f.defaultInformer) +} + +func (f *releaseInformer) Lister() v1alpha1.ReleaseLister { + return v1alpha1.NewReleaseLister(f.Informer().GetIndexer()) +} diff --git a/components/installer/pkg/client/listers/release/v1alpha1/expansion_generated.go b/components/installer/pkg/client/listers/release/v1alpha1/expansion_generated.go new file mode 100644 index 000000000000..425cd7aa34a8 --- /dev/null +++ b/components/installer/pkg/client/listers/release/v1alpha1/expansion_generated.go @@ -0,0 +1,11 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +// ReleaseListerExpansion allows custom methods to be added to +// ReleaseLister. +type ReleaseListerExpansion interface{} + +// ReleaseNamespaceListerExpansion allows custom methods to be added to +// ReleaseNamespaceLister. +type ReleaseNamespaceListerExpansion interface{} diff --git a/components/installer/pkg/client/listers/release/v1alpha1/release.go b/components/installer/pkg/client/listers/release/v1alpha1/release.go new file mode 100644 index 000000000000..660e431dc95c --- /dev/null +++ b/components/installer/pkg/client/listers/release/v1alpha1/release.go @@ -0,0 +1,78 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/apis/release/v1alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// ReleaseLister helps list Releases. +type ReleaseLister interface { + // List lists all Releases in the indexer. + List(selector labels.Selector) (ret []*v1alpha1.Release, err error) + // Releases returns an object that can list and get Releases. + Releases(namespace string) ReleaseNamespaceLister + ReleaseListerExpansion +} + +// releaseLister implements the ReleaseLister interface. +type releaseLister struct { + indexer cache.Indexer +} + +// NewReleaseLister returns a new ReleaseLister. +func NewReleaseLister(indexer cache.Indexer) ReleaseLister { + return &releaseLister{indexer: indexer} +} + +// List lists all Releases in the indexer. +func (s *releaseLister) List(selector labels.Selector) (ret []*v1alpha1.Release, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.Release)) + }) + return ret, err +} + +// Releases returns an object that can list and get Releases. +func (s *releaseLister) Releases(namespace string) ReleaseNamespaceLister { + return releaseNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// ReleaseNamespaceLister helps list and get Releases. +type ReleaseNamespaceLister interface { + // List lists all Releases in the indexer for a given namespace. + List(selector labels.Selector) (ret []*v1alpha1.Release, err error) + // Get retrieves the Release from the indexer for a given namespace and name. + Get(name string) (*v1alpha1.Release, error) + ReleaseNamespaceListerExpansion +} + +// releaseNamespaceLister implements the ReleaseNamespaceLister +// interface. +type releaseNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all Releases in the indexer for a given namespace. +func (s releaseNamespaceLister) List(selector labels.Selector) (ret []*v1alpha1.Release, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.Release)) + }) + return ret, err +} + +// Get retrieves the Release from the indexer for a given namespace and name. +func (s releaseNamespaceLister) Get(name string) (*v1alpha1.Release, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha1.Resource("release"), name) + } + return obj.(*v1alpha1.Release), nil +} diff --git a/components/installer/pkg/conditionmanager/condition-manager.go b/components/installer/pkg/conditionmanager/condition-manager.go new file mode 100644 index 000000000000..27101002ecce --- /dev/null +++ b/components/installer/pkg/conditionmanager/condition-manager.go @@ -0,0 +1,284 @@ +package conditionmanager + +import ( + installationv1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/apis/installer/v1alpha1" + installationClientset "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned" + listers "github.com/kyma-project/kyma/components/installer/pkg/client/listers/installer/v1alpha1" + "github.com/kyma-project/kyma/components/installer/pkg/consts" + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/retry" +) + +// Interface . +type Interface interface { + InstallStart() error + InstallSuccess() error + InstallError() error + + UpdateStart() error + UpdateSuccess() error + UpdateError() error + + UninstallStart() error + UninstallSuccess() error + UninstallError() error +} + +type impl struct { + lister listers.InstallationLister + client installationClientset.Interface +} + +func (cm *impl) InstallStart() error { + installation, err := cm.getInstallation() + if err != nil { + return err + } + + cm.setCondition(installation, installationv1alpha1.CondtitionInstalled, v1.ConditionFalse) + cm.setCondition(installation, installationv1alpha1.ConditionInstalling, v1.ConditionTrue) + cm.setCondition(installation, installationv1alpha1.ConditionInProgress, v1.ConditionTrue) + cm.setCondition(installation, installationv1alpha1.ConditionError, v1.ConditionFalse) + + err = cm.update(installation) + if err != nil { + return err + } + + return nil +} + +func (cm *impl) InstallSuccess() error { + installation, err := cm.getInstallation() + if err != nil { + return err + } + + cm.setCondition(installation, installationv1alpha1.CondtitionInstalled, v1.ConditionTrue) + cm.setCondition(installation, installationv1alpha1.ConditionInstalling, v1.ConditionFalse) + cm.setCondition(installation, installationv1alpha1.ConditionInProgress, v1.ConditionFalse) + cm.setCondition(installation, installationv1alpha1.ConditionUpdated, v1.ConditionFalse) + cm.setCondition(installation, installationv1alpha1.ConditionUninstalled, v1.ConditionFalse) + cm.setCondition(installation, installationv1alpha1.ConditionError, v1.ConditionFalse) + + err = cm.update(installation) + if err != nil { + return err + } + + return nil +} + +func (cm *impl) InstallError() error { + installation, err := cm.getInstallation() + if err != nil { + return err + } + + cm.setCondition(installation, installationv1alpha1.CondtitionInstalled, v1.ConditionUnknown) + cm.setCondition(installation, installationv1alpha1.ConditionInstalling, v1.ConditionFalse) + cm.setCondition(installation, installationv1alpha1.ConditionInProgress, v1.ConditionFalse) + cm.setCondition(installation, installationv1alpha1.ConditionError, v1.ConditionTrue) + + err = cm.update(installation) + if err != nil { + return err + } + + return nil +} + +func (cm *impl) UpdateStart() error { + installation, err := cm.getInstallation() + if err != nil { + return err + } + + cm.setCondition(installation, installationv1alpha1.ConditionUpdating, v1.ConditionTrue) + cm.setCondition(installation, installationv1alpha1.ConditionInProgress, v1.ConditionTrue) + cm.setCondition(installation, installationv1alpha1.ConditionError, v1.ConditionFalse) + + err = cm.update(installation) + if err != nil { + return err + } + + return nil +} + +func (cm *impl) UpdateSuccess() error { + installation, err := cm.getInstallation() + if err != nil { + return err + } + + cm.setCondition(installation, installationv1alpha1.ConditionUpdated, v1.ConditionTrue) + cm.setCondition(installation, installationv1alpha1.ConditionUpdating, v1.ConditionFalse) + cm.setCondition(installation, installationv1alpha1.ConditionInProgress, v1.ConditionFalse) + cm.setCondition(installation, installationv1alpha1.ConditionError, v1.ConditionFalse) + + err = cm.update(installation) + if err != nil { + return err + } + + return nil +} + +func (cm *impl) UpdateError() error { + installation, err := cm.getInstallation() + if err != nil { + return err + } + + cm.setCondition(installation, installationv1alpha1.ConditionUpdated, v1.ConditionFalse) + cm.setCondition(installation, installationv1alpha1.ConditionUpdating, v1.ConditionFalse) + cm.setCondition(installation, installationv1alpha1.ConditionInProgress, v1.ConditionFalse) + cm.setCondition(installation, installationv1alpha1.ConditionError, v1.ConditionTrue) + + err = cm.update(installation) + if err != nil { + return err + } + + return nil +} + +func (cm *impl) UninstallStart() error { + installation, err := cm.getInstallation() + if err != nil { + return err + } + + cm.setCondition(installation, installationv1alpha1.ConditionUninstalling, v1.ConditionTrue) + cm.setCondition(installation, installationv1alpha1.ConditionInProgress, v1.ConditionTrue) + cm.setCondition(installation, installationv1alpha1.ConditionError, v1.ConditionFalse) + + err = cm.update(installation) + if err != nil { + return err + } + + return nil +} + +func (cm *impl) UninstallSuccess() error { + installation, err := cm.getInstallation() + if err != nil { + return err + } + + cm.setCondition(installation, installationv1alpha1.ConditionUpdated, v1.ConditionFalse) + cm.setCondition(installation, installationv1alpha1.ConditionUpdating, v1.ConditionFalse) + cm.setCondition(installation, installationv1alpha1.ConditionUninstalling, v1.ConditionFalse) + cm.setCondition(installation, installationv1alpha1.ConditionUninstalled, v1.ConditionTrue) + cm.setCondition(installation, installationv1alpha1.CondtitionInstalled, v1.ConditionFalse) + cm.setCondition(installation, installationv1alpha1.ConditionInstalling, v1.ConditionFalse) + cm.setCondition(installation, installationv1alpha1.ConditionInProgress, v1.ConditionFalse) + cm.setCondition(installation, installationv1alpha1.ConditionError, v1.ConditionFalse) + + err = cm.update(installation) + if err != nil { + return err + } + + return nil +} + +func (cm *impl) UninstallError() error { + installation, err := cm.getInstallation() + if err != nil { + return err + } + + cm.setCondition(installation, installationv1alpha1.ConditionUninstalling, v1.ConditionFalse) + cm.setCondition(installation, installationv1alpha1.ConditionUninstalled, v1.ConditionFalse) + cm.setCondition(installation, installationv1alpha1.ConditionInProgress, v1.ConditionFalse) + cm.setCondition(installation, installationv1alpha1.ConditionError, v1.ConditionTrue) + + err = cm.update(installation) + if err != nil { + return err + } + + return nil +} + +func (cm *impl) getInstallation() (*installationv1alpha1.Installation, error) { + installation, err := cm.lister.Installations(consts.INST_NAMESPACE).Get(consts.INST_RESOURCE) + if err != nil { + return nil, err + } + + return installation.DeepCopy(), nil +} + +func (cm *impl) getOrCreateCondition(installation *installationv1alpha1.Installation, conditionType installationv1alpha1.InstallationConditionType) *installationv1alpha1.InstallationCondition { + condition := cm.getCondition(installation, conditionType) + if condition != nil { + return condition + } + cm.createCondition(installation, conditionType) + return cm.getOrCreateCondition(installation, conditionType) +} + +func (cm *impl) getCondition(installation *installationv1alpha1.Installation, conditionType installationv1alpha1.InstallationConditionType) *installationv1alpha1.InstallationCondition { + for i := 0; i < len(installation.Status.Conditions); i++ { + if installation.Status.Conditions[i].Type != conditionType { + continue + } + + return &installation.Status.Conditions[i] + } + + return nil +} + +func (cm *impl) createCondition(installation *installationv1alpha1.Installation, conditionType installationv1alpha1.InstallationConditionType) { + condition := &installationv1alpha1.InstallationCondition{ + Type: conditionType, + Status: v1.ConditionUnknown, + } + + installation.Status.Conditions = append(installation.Status.Conditions, *condition) +} + +func (cm *impl) setCondition(installation *installationv1alpha1.Installation, conditionType installationv1alpha1.InstallationConditionType, status v1.ConditionStatus) { + condition := cm.getOrCreateCondition(installation, conditionType) + if condition.Status != status { + condition.Status = status + condition.LastTransitionTime = metav1.Now() + } + condition.LastProbeTime = metav1.Now() +} + +func (cm *impl) update(installation *installationv1alpha1.Installation) error { + retryErr := retry.RetryOnConflict(retry.DefaultRetry, func() error { + instObj, getErr := cm.lister.Installations(consts.INST_NAMESPACE).Get(consts.INST_RESOURCE) + if getErr != nil { + return getErr + } + + installationCopy := instObj.DeepCopy() + installationCopy.Status.Conditions = installation.Status.Conditions + + _, updateErr := cm.client.InstallerV1alpha1().Installations(consts.INST_NAMESPACE).Update(installationCopy) + + return updateErr + }) + + if retryErr != nil { + return retryErr + } + + return nil +} + +// New . +func New(installationClient installationClientset.Interface, installationLister listers.InstallationLister) Interface { + return &impl{ + client: installationClient, + lister: installationLister, + } +} diff --git a/components/installer/pkg/config/environment.go b/components/installer/pkg/config/environment.go new file mode 100644 index 000000000000..ba5704e88e70 --- /dev/null +++ b/components/installer/pkg/config/environment.go @@ -0,0 +1,47 @@ +package config + +import ( + "os" +) + +// Configuration of non-secret values in installer +type installationConfig struct { + ExternalIPAddress string + Domain string + RemoteEnvIP string + K8sApiserverUrl string + K8sApiserverCa string + AdminGroup string + AzureBrokerTenantID string + AzureBrokerClientID string + AzureBrokerSubscriptionID string + AzureBrokerClientSecret string + ClusterTLSKey string + ClusterTLSCert string + RemoteEnvCa string + RemoteEnvCaKey string + UITestUser string + UITestPassword string +} + +// GetInstallationConfig returns all non-secret installation parameters from the Installer environment variables +func GetInstallationConfig() *installationConfig { + return &installationConfig{ + ExternalIPAddress: os.Getenv("EXTERNAL_IP_ADDRESS"), + Domain: os.Getenv("DOMAIN"), + RemoteEnvIP: os.Getenv("REMOTE_ENV_IP"), + K8sApiserverUrl: os.Getenv("K8S_APISERVER_URL"), + K8sApiserverCa: os.Getenv("K8S_APISERVER_CA"), + AdminGroup: os.Getenv("ADMIN_GROUP"), + AzureBrokerTenantID: os.Getenv("AZURE_BROKER_TENANT_ID"), + AzureBrokerClientID: os.Getenv("AZURE_BROKER_CLIENT_ID"), + AzureBrokerSubscriptionID: os.Getenv("AZURE_BROKER_SUBSCRIPTION_ID"), + AzureBrokerClientSecret: os.Getenv("AZURE_BROKER_CLIENT_SECRET"), + ClusterTLSKey: os.Getenv("TLS_KEY"), + ClusterTLSCert: os.Getenv("TLS_CERT"), + RemoteEnvCa: os.Getenv("REMOTE_ENV_CA"), + RemoteEnvCaKey: os.Getenv("REMOTE_ENV_CA_KEY"), + UITestUser: os.Getenv("UI_TEST_USER"), + UITestPassword: os.Getenv("UI_TEST_PASSWORD"), + } +} diff --git a/components/installer/pkg/config/installation-data.go b/components/installer/pkg/config/installation-data.go new file mode 100644 index 000000000000..d1927903b6ca --- /dev/null +++ b/components/installer/pkg/config/installation-data.go @@ -0,0 +1,72 @@ +package config + +import ( + "github.com/kyma-project/kyma/components/installer/pkg/apis/installer/v1alpha1" +) + +// InstallationContext describes properties of K8S Installation object that triggers installation process +type InstallationContext struct { + Name string + Namespace string +} + +// InstallationData describes all installation attributes +type InstallationData struct { + Context InstallationContext + ExternalIPAddress string + Domain string + KymaVersion string + URL string + AzureBrokerTenantID string + AzureBrokerClientID string + AzureBrokerSubscriptionID string + AzureBrokerClientSecret string + ClusterTLSKey string + ClusterTLSCert string + RemoteEnvCa string + RemoteEnvCaKey string + RemoteEnvIP string + K8sApiserverURL string + K8sApiserverCa string + UITestUser string + UITestPassword string + AdminGroup string + IsLocalInstallation func() bool +} + +// NewInstallationData . +func NewInstallationData(installation *v1alpha1.Installation, installationConfig *installationConfig) (*InstallationData, error) { + + isLocalInstallationFunc := func() bool { + return installationConfig.ExternalIPAddress == "" + } + + ctx := InstallationContext{ + Name: installation.Name, + Namespace: installation.Namespace, + } + + res := &InstallationData{ + Context: ctx, + ExternalIPAddress: installationConfig.ExternalIPAddress, + Domain: installationConfig.Domain, + KymaVersion: installation.Spec.KymaVersion, + URL: installation.Spec.URL, + AzureBrokerTenantID: installationConfig.AzureBrokerTenantID, + AzureBrokerClientID: installationConfig.AzureBrokerClientID, + AzureBrokerSubscriptionID: installationConfig.AzureBrokerSubscriptionID, + AzureBrokerClientSecret: installationConfig.AzureBrokerClientSecret, + ClusterTLSKey: installationConfig.ClusterTLSKey, + ClusterTLSCert: installationConfig.ClusterTLSCert, + RemoteEnvCa: installationConfig.RemoteEnvCa, + RemoteEnvCaKey: installationConfig.RemoteEnvCaKey, + RemoteEnvIP: installationConfig.RemoteEnvIP, + K8sApiserverURL: installationConfig.K8sApiserverUrl, + K8sApiserverCa: installationConfig.K8sApiserverCa, + UITestUser: installationConfig.UITestUser, + UITestPassword: installationConfig.UITestPassword, + AdminGroup: installationConfig.AdminGroup, + IsLocalInstallation: isLocalInstallationFunc, + } + return res, nil +} diff --git a/components/installer/pkg/consts/const.go b/components/installer/pkg/consts/const.go new file mode 100644 index 000000000000..93b2a8b7e339 --- /dev/null +++ b/components/installer/pkg/consts/const.go @@ -0,0 +1,8 @@ +package consts + +const ( + INST_NAMESPACE = "default" + INST_RESOURCE = "kyma-installation" + INST_FINALIZER = "finalizer.installer.kyma.cx" + REL_FINALIZER = "finalizer.release.kyma.cx" +) diff --git a/components/installer/pkg/errors/errors.go b/components/installer/pkg/errors/errors.go new file mode 100644 index 000000000000..0846ce4ad75c --- /dev/null +++ b/components/installer/pkg/errors/errors.go @@ -0,0 +1,31 @@ +package errors + +import ( + "log" +) + +// ErrorHandlersInterface . +type ErrorHandlersInterface interface { + CheckError(msg string, err error) bool + LogError(msg string, err error) +} + +// ErrorHandlers . +type ErrorHandlers struct { +} + +// CheckError . +func (eh *ErrorHandlers) CheckError(msg string, err error) bool { + if err != nil { + log.Printf("%s Details: %s", msg, err.Error()) + return true + } + return false +} + +// LogError . +func (eh *ErrorHandlers) LogError(msg string, err error) { + if err != nil { + log.Printf("%s Details: %s", msg, err.Error()) + } +} diff --git a/components/installer/pkg/finalizer/manager.go b/components/installer/pkg/finalizer/manager.go new file mode 100644 index 000000000000..b23ec5334138 --- /dev/null +++ b/components/installer/pkg/finalizer/manager.go @@ -0,0 +1,72 @@ +package finalizer + +// Finalizable exposes function to modify finalizers in kubernetes objects +type Finalizable interface { + GetFinalizers() []string + SetFinalizers([]string) +} + +// Manager provides functions to modify finalizers in kubernetes objects +type Manager struct { + finalizerName string +} + +// NewManager returns an instance of Manager configured with given finalizerName +func NewManager(finalizerName string) *Manager { + return &Manager{ + finalizerName: finalizerName, + } +} + +// AddFinalizer adds finalizer to the object +func (m *Manager) AddFinalizer(object Finalizable) { + + finalizers := object.GetFinalizers() + + if !hasFinalizer(finalizers, m.finalizerName) { + object.SetFinalizers(append(finalizers, m.finalizerName)) + } +} + +// RemoveFinalizer removes finalizer from the object +func (m *Manager) RemoveFinalizer(object Finalizable) { + + finalizers := object.GetFinalizers() + object.SetFinalizers(removeFinalizer(finalizers, m.finalizerName)) +} + +// HasFinalizer returns true if the object has the finalizer specified in Manager +func (m *Manager) HasFinalizer(object Finalizable) bool { + return hasFinalizer(object.GetFinalizers(), m.finalizerName) +} + +func hasFinalizer(finalizers []string, element string) bool { + + if finalizers == nil || len(finalizers) == 0 { + return false + } + + for _, f := range finalizers { + if f == element { + return true + } + } + + return false +} + +func removeFinalizer(finalizers []string, element string) []string { + + if finalizers == nil || len(finalizers) == 0 { + return finalizers + } + + var result = []string{} + + for _, f := range finalizers { + if f != element { + result = append(result, f) + } + } + return result +} diff --git a/components/installer/pkg/finalizer/manager_test.go b/components/installer/pkg/finalizer/manager_test.go new file mode 100644 index 000000000000..2b73b89b85c4 --- /dev/null +++ b/components/installer/pkg/finalizer/manager_test.go @@ -0,0 +1,88 @@ +package finalizer + +import ( + "testing" + + "github.com/kyma-project/kyma/components/installer/pkg/apis/installer/v1alpha1" + . "github.com/smartystreets/goconvey/convey" + "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const finalizerName = "test.finalizer.kyma.cx" + +func TestAddFinalizer(t *testing.T) { + + Convey("AddFinalizer requires a Kubernetes object as parameter", t, func() { + + var inst *v1alpha1.Installation + m := NewManager(finalizerName) + + Convey("If the object has no finalizers, the function should update it by adding one", func() { + + inst = createInstallation() + + m.AddFinalizer(inst) + + So(len(inst.Finalizers), ShouldEqual, 1) + So(finalizerName, ShouldBeIn, inst.Finalizers) + }) + + Convey("If the object has any finalizers, the function should not override them", func() { + + inst = createInstallation("first.finalizer.kyma.cx") + + m.AddFinalizer(inst) + + So(len(inst.Finalizers), ShouldEqual, 2) + So(finalizerName, ShouldBeIn, inst.Finalizers) + }) + }) +} + +func TestRemoveFinalizer(t *testing.T) { + + Convey("RemoveFinalizer requires a Kubernetes object as parameter", t, func() { + + var inst *v1alpha1.Installation + m := NewManager(finalizerName) + + Convey("If the object has no finalizers at all, it should not be modified", func() { + + inst = createInstallation() + + m.RemoveFinalizer(inst) + + So(len(inst.Finalizers), ShouldEqual, 0) + So(finalizerName, ShouldNotBeIn, inst.Finalizers) + }) + + Convey("If the object does not have the finalizer specified in Manager, it should not be modified as well", func() { + + inst = createInstallation("random.finalizer.kyma.cx", "another.finalizer.kyma.cx") + + m.RemoveFinalizer(inst) + + So(len(inst.Finalizers), ShouldEqual, 2) + So(finalizerName, ShouldNotBeIn, inst.Finalizers) + }) + + Convey("If the object has the finalizer specified in Manager, it should be removed", func() { + + inst = createInstallation(finalizerName, "random.kyma.cx") + + m.RemoveFinalizer(inst) + + So(len(inst.Finalizers), ShouldEqual, 1) + So(finalizerName, ShouldNotBeIn, inst.Finalizers) + }) + }) +} + +func createInstallation(finalizers ...string) *v1alpha1.Installation { + + return &v1alpha1.Installation{ + ObjectMeta: v1.ObjectMeta{ + Finalizers: finalizers, + }, + } +} diff --git a/components/installer/pkg/installation/controller.go b/components/installer/pkg/installation/controller.go new file mode 100644 index 000000000000..8f8c2c41ec98 --- /dev/null +++ b/components/installer/pkg/installation/controller.go @@ -0,0 +1,293 @@ +package installation + +import ( + "log" + "time" + + corev1 "k8s.io/api/core/v1" + typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1" + + kubeerrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/wait" + + kubeinformers "k8s.io/client-go/informers" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/tools/record" + "k8s.io/client-go/util/retry" + "k8s.io/client-go/util/workqueue" + + "github.com/kyma-project/kyma/components/installer/pkg/apis/installer/v1alpha1" + internalClientset "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned" + internalscheme "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned/scheme" + informers "github.com/kyma-project/kyma/components/installer/pkg/client/informers/externalversions" + listers "github.com/kyma-project/kyma/components/installer/pkg/client/listers/installer/v1alpha1" + "github.com/kyma-project/kyma/components/installer/pkg/conditionmanager" + "github.com/kyma-project/kyma/components/installer/pkg/finalizer" + + "github.com/kyma-project/kyma/components/installer/pkg/config" + internalerrors "github.com/kyma-project/kyma/components/installer/pkg/errors" + "github.com/kyma-project/kyma/components/installer/pkg/steps" +) + +const ( + // SuccessSynced is used as part of the Event 'reason' when a Installation is synced + SuccessSynced = "Synced" + + // MessageResourceSynced is the message used for an Event fired when a Installation + // is synced successfully + MessageResourceSynced = "Installation synced successfully" +) + +// Controller . +type Controller struct { + kubeClientset *kubernetes.Clientset + installationLister listers.InstallationLister + installationSynced cache.InformerSynced + queue workqueue.RateLimitingInterface + recorder record.EventRecorder + errorHandlers internalerrors.ErrorHandlersInterface + kymaSteps *steps.InstallationSteps + conditionManager conditionmanager.Interface + finalizerManager *finalizer.Manager + internalClientset *internalClientset.Clientset +} + +// NewController . +func NewController(kubeClientset *kubernetes.Clientset, kubeInformerFactory kubeinformers.SharedInformerFactory, + internalInformerFactory informers.SharedInformerFactory, kymaDir string, installationSteps *steps.InstallationSteps, conditionManager conditionmanager.Interface, finalizerManager *finalizer.Manager, internalClientset *internalClientset.Clientset) *Controller { + + installationInformer := internalInformerFactory.Installer().V1alpha1().Installations() + + internalscheme.AddToScheme(scheme.Scheme) + + eventBroadcaster := record.NewBroadcaster() + eventBroadcaster.StartRecordingToSink(&typedcorev1.EventSinkImpl{Interface: kubeClientset.CoreV1().Events("")}) + recorder := eventBroadcaster.NewRecorder(scheme.Scheme, corev1.EventSource{Component: "kymaInstaller"}) + + c := &Controller{ + kubeClientset: kubeClientset, + installationLister: installationInformer.Lister(), + installationSynced: installationInformer.Informer().HasSynced, + queue: workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "kymaInstallerQueue"), + recorder: recorder, + errorHandlers: &internalerrors.ErrorHandlers{}, + kymaSteps: installationSteps, + conditionManager: conditionManager, + finalizerManager: finalizerManager, + internalClientset: internalClientset, + } + + installationInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: c.enqueueInstallation, + UpdateFunc: func(old, new interface{}) { + c.enqueueInstallation(new) + }, + }) + + return c +} + +// Run . +func (c *Controller) Run(workers int, stopCh <-chan struct{}) { + + defer func() { + + log.Println("Shutting down controller...") + c.queue.ShutDown() + }() + + for i := 0; i < workers; i++ { + // start workers + go wait.Until(c.worker, time.Second, stopCh) + } + + // wait until we receive a stop signal + <-stopCh +} + +func (c *Controller) worker() { + + // process until we're told to stop + for c.processNextWorkItem() { + } +} + +func (c *Controller) processNextWorkItem() bool { + + key, quit := c.queue.Get() + if quit { + + return false + } + + defer c.queue.Done(key) + + err := c.syncHandler(key.(string)) + c.handleErr(err, key) + return true +} + +func (c *Controller) syncHandler(key string) error { + + namespace, name, err := cache.SplitMetaNamespaceKey(key) + if err != nil { + return err + } + + installation, err := c.installationLister.Installations(namespace).Get(name) + if err != nil { + + if kubeerrors.IsNotFound(err) { + runtime.HandleError(err) + return nil + } + + return err + } + + //Handle Delete + if installation.IsBeingDeleted() { + if installation.CanBeDeleted() { + log.Println("Delete of Installation CR was requested, removing finalizer...") + err := c.deleteFinalizer(installation) + + if c.errorHandlers.CheckError("Error while removing finalizer", err) { + return err + } + + return nil + } else { + log.Println("Delete of Installation CR was requested but it's status does not allow for it - ignoring the request") + } + } + + //TODO: Fill it with proper data and install UpdateStatus func + installationData, err := config.NewInstallationData(installation, config.GetInstallationConfig()) + if c.errorHandlers.CheckError("Error while building installation data: ", err) { + return err + } + + //TODO: Validation happens here + domainName := installationData.Domain + if domainName == "" { + runtime.HandleError(err) + return nil + } + + if installation.ShouldInstall() { + err = c.conditionManager.InstallStart() + if err != nil { + return err + } + + err = c.kymaSteps.InstallKyma(installationData) + if err != nil { + c.conditionManager.InstallError() + + return err + } + + err = c.conditionManager.InstallSuccess() + if err != nil { + return err + } + } else if installation.ShouldUninstall() { + err = c.conditionManager.UninstallStart() + if err != nil { + return err + } + + err = c.kymaSteps.UninstallKyma(installationData) + if err != nil { + c.conditionManager.UninstallError() + + return err + } + + err = c.conditionManager.UninstallSuccess() + if err != nil { + return err + } + } else if installation.ShouldUpdate() { + err = c.conditionManager.UpdateStart() + if err != nil { + return err + } + + err = c.kymaSteps.UpdateKyma(installationData) + if err != nil { + c.conditionManager.UpdateError() + + return err + } + + err = c.conditionManager.UpdateSuccess() + if err != nil { + return err + } + } + + c.recorder.Event(installation, corev1.EventTypeNormal, SuccessSynced, MessageResourceSynced) + return nil +} + +func (c *Controller) deleteFinalizer(installation *v1alpha1.Installation) error { + if !c.finalizerManager.HasFinalizer(installation) { + return nil + } + + retryErr := retry.RetryOnConflict(retry.DefaultRetry, func() error { + instObj, getErr := c.installationLister.Installations(installation.Namespace).Get(installation.Name) + + if getErr != nil { + return getErr + } + + installationCopy := instObj.DeepCopy() + + c.finalizerManager.RemoveFinalizer(installationCopy) + _, updateErr := c.internalClientset.InstallerV1alpha1().Installations(installation.Namespace).Update(installationCopy) + return updateErr + }) + + if retryErr != nil { + return retryErr + } + + return nil +} + +func (c *Controller) handleErr(err error, key interface{}) { + + if err == nil { + c.queue.Forget(key) + return + } + + if c.queue.NumRequeues(key) < 5 { + + // Re-enqueue the key rate limited. Based on the rate limiter on the + // queue and the re-enqueue history, the key will be processed later again. + c.queue.AddRateLimited(key) + return + } + + c.queue.Forget(key) + + runtime.HandleError(err) +} + +func (c *Controller) enqueueInstallation(obj interface{}) { + + var key string + var err error + if key, err = cache.MetaNamespaceKeyFunc(obj); err != nil { + runtime.HandleError(err) + return + } + c.queue.AddRateLimited(key) +} diff --git a/components/installer/pkg/kymahelm/ysf-helm-client.go b/components/installer/pkg/kymahelm/ysf-helm-client.go new file mode 100644 index 000000000000..eb152640ce64 --- /dev/null +++ b/components/installer/pkg/kymahelm/ysf-helm-client.go @@ -0,0 +1,122 @@ +package kymahelm + +import ( + "fmt" + "log" + "strings" + + "k8s.io/helm/pkg/chartutil" + "k8s.io/helm/pkg/helm" + "k8s.io/helm/pkg/proto/hapi/release" + rls "k8s.io/helm/pkg/proto/hapi/services" +) + +// ClientInterface . +type ClientInterface interface { + ListReleases() (*rls.ListReleasesResponse, error) + ReleaseStatus(rname string) (string, error) + InstallReleaseFromChart(chartdir, ns, releaseName, overrides string) (*rls.InstallReleaseResponse, error) + InstallRelease(chartdir, ns, releasename, overrides string) (*rls.InstallReleaseResponse, error) + InstallReleaseWithoutWait(chartdir, ns, releasename, overrides string) (*rls.InstallReleaseResponse, error) + UpgradeRelease(chartDir, releaseName, overrides string) (*rls.UpdateReleaseResponse, error) + DeleteRelease(releaseName string) (*rls.UninstallReleaseResponse, error) + PrintRelease(release *release.Release) +} + +// Client . +type Client struct { + helm *helm.Client +} + +// NewClient . +func NewClient(host string) *Client { + return &Client{ + helm: helm.NewClient(helm.Host(host)), + } +} + +// ListReleases . +func (hc *Client) ListReleases() (*rls.ListReleasesResponse, error) { + return hc.helm.ListReleases() +} + +//ReleaseStatus returns roughly-formatted Release status (columns are separated with blanks but not adjusted) +func (hc *Client) ReleaseStatus(rname string) (string, error) { + status, err := hc.helm.ReleaseStatus(rname) + if err != nil { + return "", err + } + statusStr := fmt.Sprintf("%+v\n", status) + return strings.Replace(statusStr, `\n`, "\n", -1), nil +} + +// InstallReleaseFromChart . +func (hc *Client) InstallReleaseFromChart(chartdir, ns, releaseName, overrides string) (*rls.InstallReleaseResponse, error) { + chart, err := chartutil.Load(chartdir) + + if err != nil { + return nil, err + } + + return hc.helm.InstallReleaseFromChart( + chart, + ns, + helm.ReleaseName(string(releaseName)), + helm.ValueOverrides([]byte(overrides)), //Without it default "values.yaml" file is ignored! + helm.InstallWait(true), + helm.InstallTimeout(3600), + ) +} + +// InstallRelease . +func (hc *Client) InstallRelease(chartdir, ns, releasename, overrides string) (*rls.InstallReleaseResponse, error) { + return hc.helm.InstallRelease( + chartdir, + ns, + helm.ReleaseName(releasename), + helm.ValueOverrides([]byte(overrides)), //Without it default "values.yaml" file is ignored! + helm.InstallWait(true), + helm.InstallTimeout(3600), + ) +} + +// InstallReleaseWithoutWait . +func (hc *Client) InstallReleaseWithoutWait(chartdir, ns, releasename, overrides string) (*rls.InstallReleaseResponse, error) { + return hc.helm.InstallRelease( + chartdir, + ns, + helm.ReleaseName(releasename), + helm.ValueOverrides([]byte(overrides)), //Without it default "values.yaml" file is ignored! + helm.InstallWait(false), + helm.InstallTimeout(3600), + ) +} + +// UpgradeRelease . +func (hc *Client) UpgradeRelease(chartDir, releaseName, overrides string) (*rls.UpdateReleaseResponse, error) { + return hc.helm.UpdateRelease( + releaseName, + chartDir, + helm.UpdateValueOverrides([]byte(overrides+"\nglobal:\n install: true\n")), //Without it default "values.yaml" file is ignored! + helm.ReuseValues(true), + helm.UpgradeTimeout(3600), + ) +} + +// DeleteRelease . +func (hc *Client) DeleteRelease(releaseName string) (*rls.UninstallReleaseResponse, error) { + return hc.helm.DeleteRelease( + releaseName, + helm.DeletePurge(true), + helm.DeleteTimeout(3600), + ) +} + +//PrintRelease . +func (hc *Client) PrintRelease(release *release.Release) { + log.Printf("Name: %s", release.Name) + log.Printf("Namespace: %s", release.Namespace) + log.Printf("Version: %d", release.Version) + log.Printf("Status: %s", release.Info.Status.Code) + log.Printf("Description: %s", release.Info.Description) +} diff --git a/components/installer/pkg/overrides/azure-broker.go b/components/installer/pkg/overrides/azure-broker.go new file mode 100644 index 000000000000..d0bc1fb01e03 --- /dev/null +++ b/components/installer/pkg/overrides/azure-broker.go @@ -0,0 +1,71 @@ +package overrides + +import ( + "bytes" + "text/template" + + "github.com/kyma-project/kyma/components/installer/pkg/config" +) + +const tplStr = ` +azure-broker: + enabled: true + subscription_id: {{.AzSubscriptionID}} + tenant_id: {{.AzTenantID}} + client_id: {{.AzClientID}} + client_secret: {{.AzClientSecret}} +` + +type azureParams struct { + AzTenantID string + AzClientID string + AzSubscriptionID string + AzClientSecret string +} + +// EnableAzureBroker provides Azure parameters from Vault +func EnableAzureBroker(installationData *config.InstallationData) (string, error) { + + if !hasAzureParams(installationData) { + return "", nil + } + + azureParams, err := getAzureParams(installationData) + if err != nil { + return "", err + } + + tmpl, err := template.New("").Parse(tplStr) + if err != nil { + return "", err + } + + buf := new(bytes.Buffer) + err = tmpl.Execute(buf, azureParams) + if err != nil { + return "", err + } + + return buf.String(), nil +} + +func getAzureParams(installationData *config.InstallationData) (*azureParams, error) { + + azTenantID := installationData.AzureBrokerTenantID + azClientID := installationData.AzureBrokerClientID + azSubscriptionID := installationData.AzureBrokerSubscriptionID + azClientSecret := installationData.AzureBrokerClientSecret + + ap := &azureParams{ + AzTenantID: azTenantID, + AzClientID: azClientID, + AzSubscriptionID: azSubscriptionID, + AzClientSecret: azClientSecret, + } + + return ap, nil +} + +func hasAzureParams(installationData *config.InstallationData) bool { + return installationData.AzureBrokerTenantID != "" && installationData.AzureBrokerClientID != "" && installationData.AzureBrokerSubscriptionID != "" && installationData.AzureBrokerClientSecret != "" +} diff --git a/components/installer/pkg/overrides/azure-broker_test.go b/components/installer/pkg/overrides/azure-broker_test.go new file mode 100644 index 000000000000..99bac7a8e081 --- /dev/null +++ b/components/installer/pkg/overrides/azure-broker_test.go @@ -0,0 +1,37 @@ +package overrides + +import ( + "testing" + + . "github.com/kyma-project/kyma/components/installer/pkg/toolkit" + . "github.com/smartystreets/goconvey/convey" +) + +func TestEnableAzureBroker(t *testing.T) { + Convey("EnableAzureBroker", t, func() { + Convey("when InstallationData does not contain azure credentials overrides should be empty", func() { + + installatioData := NewInstallationDataCreator().WithEmptyAzureCredentials().GetData() + overrides, err := EnableAzureBroker(&installatioData) + + So(err, ShouldBeNil) + So(overrides, ShouldBeBlank) + }) + + Convey("when InstallationData contains azure credentials overrides should contain yaml", func() { + dummyOverridesForBroker := ` +azure-broker: + enabled: true + subscription_id: d5423a63-0ab6-4455-9efe-569c6e716625 + tenant_id: 7ffdff3c-daa6-420d-9cff-b04769031acf + client_id: 37bb544f-8935-4a00-a934-3999577fb637 + client_secret: ZGM3ZDlkYTgtZWMxMS00NTg4LTk5OGItOGU5YWJlNWUzYmE4DQo= +` + installatioData := NewInstallationDataCreator().WithDummyAzureCredentials().GetData() + overrides, err := EnableAzureBroker(&installatioData) + + So(err, ShouldBeNil) + So(overrides, ShouldEqual, dummyOverridesForBroker) + }) + }) +} diff --git a/components/installer/pkg/overrides/core.go b/components/installer/pkg/overrides/core.go new file mode 100644 index 000000000000..aa26cfe58974 --- /dev/null +++ b/components/installer/pkg/overrides/core.go @@ -0,0 +1,51 @@ +package overrides + +import ( + "bytes" + "text/template" + + "github.com/kyma-project/kyma/components/installer/pkg/config" +) + +const coreTplStr = ` +nginx-ingress: + controller: + service: + loadBalancerIP: {{.RemoteEnvIP}} +configurations-generator: + kubeConfig: + clusterName: {{.Domain}} + url: {{.K8sApiserverURL}} + ca: {{.K8sApiserverCa}} +cluster-users: + users: + adminGroup: {{.AdminGroup}} +test: + auth: + username: "{{.UITestUser}}" + password: "{{.UITestPassword}}" +` + +// GetCoreOverrides - returns values overrides for core installation basing on domain +func GetCoreOverrides(installationData *config.InstallationData) (string, error) { + if hasValidDomain(installationData) == false { + return "", nil + } + + tmpl, err := template.New("").Parse(coreTplStr) + if err != nil { + return "", err + } + + buf := new(bytes.Buffer) + err = tmpl.Execute(buf, installationData) + if err != nil { + return "", err + } + + return buf.String(), nil +} + +func hasValidDomain(installationData *config.InstallationData) bool { + return installationData.Domain != "" +} diff --git a/components/installer/pkg/overrides/core_test.go b/components/installer/pkg/overrides/core_test.go new file mode 100644 index 000000000000..458d98a0944d --- /dev/null +++ b/components/installer/pkg/overrides/core_test.go @@ -0,0 +1,80 @@ +package overrides + +import ( + "testing" + + . "github.com/kyma-project/kyma/components/installer/pkg/toolkit" + . "github.com/smartystreets/goconvey/convey" +) + +func TestGetCoreOverrides(t *testing.T) { + Convey("GetCoreOverrides", t, func() { + Convey("when InstallationData does not contain domain name overrides should be empty", func() { + + installationData := NewInstallationDataCreator().WithEmptyDomain().GetData() + overrides, err := GetCoreOverrides(&installationData) + + So(err, ShouldBeNil) + So(overrides, ShouldBeBlank) + }) + + Convey("when InstallationData contains domain name overrides should contain yaml", func() { + const dummyOverridesForCore = ` +nginx-ingress: + controller: + service: + loadBalancerIP: 1.1.1.1 +configurations-generator: + kubeConfig: + clusterName: kyma.local + url: + ca: +cluster-users: + users: + adminGroup: testgroup +test: + auth: + username: "" + password: "" +` + installationData := NewInstallationDataCreator().WithDomain("kyma.local").WithRemoteEnvIP("1.1.1.1").WithAdminGroup("testgroup"). + GetData() + + overrides, err := GetCoreOverrides(&installationData) + + So(err, ShouldBeNil) + So(overrides, ShouldEqual, dummyOverridesForCore) + }) + + Convey("when test properties are provided, auth.username and auth.password should exist", func() { + const dummyOverridesForCore = ` +nginx-ingress: + controller: + service: + loadBalancerIP: 1.1.1.1 +configurations-generator: + kubeConfig: + clusterName: kyma.local + url: + ca: +cluster-users: + users: + adminGroup: +test: + auth: + username: "user1" + password: "p@ssw0rd" +` + installationData := NewInstallationDataCreator(). + WithDomain("kyma.local"). + WithRemoteEnvIP("1.1.1.1"). + WithUITestCredentials("user1", "p@ssw0rd"). + GetData() + + overrides, err := GetCoreOverrides(&installationData) + + So(err, ShouldBeNil) + So(overrides, ShouldEqual, dummyOverridesForCore) + }) + }) +} diff --git a/components/installer/pkg/overrides/global.go b/components/installer/pkg/overrides/global.go new file mode 100644 index 000000000000..59927f5137de --- /dev/null +++ b/components/installer/pkg/overrides/global.go @@ -0,0 +1,42 @@ +package overrides + +import ( + "bytes" + "text/template" + + "github.com/kyma-project/kyma/components/installer/pkg/config" +) + +const globalsTplStr = ` +global: + tlsCrt: "{{.ClusterTLSCert}}" + tlsKey: "{{.ClusterTLSKey}}" + isLocalEnv: {{isLocal}} + domainName: "{{.Domain}}" + remoteEnvCa: "{{.RemoteEnvCa}}" + remoteEnvCaKey: "{{.RemoteEnvCaKey}}" + istio: + tls: + secretName: "istio-ingress-certs" +` + +// GetGlobalOverrides . +func GetGlobalOverrides(installationData *config.InstallationData) (string, error) { + + fmap := template.FuncMap{ + "isLocal": installationData.IsLocalInstallation, + } + + tmpl, err := template.New("").Funcs(fmap).Parse(globalsTplStr) + if err != nil { + return "", err + } + + buf := new(bytes.Buffer) + err = tmpl.Execute(buf, installationData) + if err != nil { + return "", err + } + + return buf.String(), nil +} diff --git a/components/installer/pkg/overrides/global_test.go b/components/installer/pkg/overrides/global_test.go new file mode 100644 index 000000000000..e07f6cec4b53 --- /dev/null +++ b/components/installer/pkg/overrides/global_test.go @@ -0,0 +1,97 @@ +package overrides + +import ( + "testing" + + . "github.com/kyma-project/kyma/components/installer/pkg/toolkit" + . "github.com/smartystreets/goconvey/convey" +) + +func TestGetGlobalOverrides(t *testing.T) { + Convey("GetGlobalOverrides", t, func() { + Convey("when IP address is not specified IsLocalEnv should be true", func() { + const dummyOverridesForGlobal = ` +global: + tlsCrt: "" + tlsKey: "" + isLocalEnv: true + domainName: "kyma.local" + remoteEnvCa: "" + remoteEnvCaKey: "" + istio: + tls: + secretName: "istio-ingress-certs" +` + + installData := NewInstallationDataCreator().WithDomain("kyma.local").WithEmptyIP().GetData() + + overrides, err := GetGlobalOverrides(&installData) + + So(err, ShouldBeNil) + So(overrides, ShouldEqual, dummyOverridesForGlobal) + }) + + Convey("when IP address is specified IsLocalEnv should be false", func() { + const dummyOverridesForGlobal = ` +global: + tlsCrt: "" + tlsKey: "" + isLocalEnv: false + domainName: "kyma.local" + remoteEnvCa: "" + remoteEnvCaKey: "" + istio: + tls: + secretName: "istio-ingress-certs" +` + installData := NewInstallationDataCreator().WithDomain("kyma.local").WithIP("100.100.100.100").GetData() + + overrides, err := GetGlobalOverrides(&installData) + + So(err, ShouldBeNil) + So(overrides, ShouldEqual, dummyOverridesForGlobal) + }) + + Convey("when cert properties are provided tlsCrt and tlsKey should exist", func() { + const dummyOverridesForGlobal = ` +global: + tlsCrt: "abc" + tlsKey: "def" + isLocalEnv: false + domainName: "kyma.local" + remoteEnvCa: "" + remoteEnvCaKey: "" + istio: + tls: + secretName: "istio-ingress-certs" +` + installData := NewInstallationDataCreator().WithDomain("kyma.local").WithIP("100.100.100.100").WithCert("abc", "def").GetData() + + overrides, err := GetGlobalOverrides(&installData) + + So(err, ShouldBeNil) + So(overrides, ShouldEqual, dummyOverridesForGlobal) + }) + + Convey("when remote env CA property is provided remoteEnvCa should exist", func() { + const dummyOverridesForGlobal = ` +global: + tlsCrt: "" + tlsKey: "" + isLocalEnv: false + domainName: "kyma.local" + remoteEnvCa: "xyz" + remoteEnvCaKey: "abc" + istio: + tls: + secretName: "istio-ingress-certs" +` + installData := NewInstallationDataCreator().WithDomain("kyma.local").WithIP("100.100.100.100").WithRemoteEnvCa("xyz").WithRemoteEnvCaKey("abc").GetData() + + overrides, err := GetGlobalOverrides(&installData) + + So(err, ShouldBeNil) + So(overrides, ShouldEqual, dummyOverridesForGlobal) + }) + }) +} diff --git a/components/installer/pkg/overrides/istio.go b/components/installer/pkg/overrides/istio.go new file mode 100644 index 000000000000..2b17bbe757c8 --- /dev/null +++ b/components/installer/pkg/overrides/istio.go @@ -0,0 +1,38 @@ +package overrides + +import ( + "bytes" + "text/template" + + "github.com/kyma-project/kyma/components/installer/pkg/config" +) + +const istioTplStr = ` +ingress: + service: + externalPublicIp: {{.ExternalIPAddress}} +` + +// GetIstioOverrides returns values overrides for istio ingress +func GetIstioOverrides(installationData *config.InstallationData) (string, error) { + if hasIPAddress(installationData) == false { + return "", nil + } + + tmpl, err := template.New("").Parse(istioTplStr) + if err != nil { + return "", err + } + + buf := new(bytes.Buffer) + bErr := tmpl.Execute(buf, installationData) + if bErr != nil { + return "", err + } + + return buf.String(), nil +} + +func hasIPAddress(installationData *config.InstallationData) bool { + return installationData.ExternalIPAddress != "" +} diff --git a/components/installer/pkg/overrides/istio_test.go b/components/installer/pkg/overrides/istio_test.go new file mode 100644 index 000000000000..02457e3343fa --- /dev/null +++ b/components/installer/pkg/overrides/istio_test.go @@ -0,0 +1,34 @@ +package overrides + +import ( + "testing" + + . "github.com/kyma-project/kyma/components/installer/pkg/toolkit" + . "github.com/smartystreets/goconvey/convey" +) + +func TestGetIstioOverrides(t *testing.T) { + Convey("GetIstioOverrides", t, func() { + Convey("when IP address is not specified overrides should be empty", func() { + + installationData := NewInstallationDataCreator().WithEmptyIP().GetData() + overrides, err := GetIstioOverrides(&installationData) + + So(err, ShouldBeNil) + So(overrides, ShouldBeEmpty) + }) + + Convey("when IP address is specified should contain yaml", func() { + const dummyOverridesForIstio = ` +ingress: + service: + externalPublicIp: 100.100.100.100 +` + installationData := NewInstallationDataCreator().WithIP("100.100.100.100").GetData() + overrides, err := GetIstioOverrides(&installationData) + + So(err, ShouldBeNil) + So(overrides, ShouldEqual, dummyOverridesForIstio) + }) + }) +} diff --git a/components/installer/pkg/overrides/remote-environment.go b/components/installer/pkg/overrides/remote-environment.go new file mode 100644 index 000000000000..cfc8d87229b5 --- /dev/null +++ b/components/installer/pkg/overrides/remote-environment.go @@ -0,0 +1,29 @@ +package overrides + +const hmcDefault = ` +deployment: + args: + sourceType: marketing +service: + externalapi: + nodePort: 32001 +` + +const ecDefault = ` +deployment: + args: + sourceType: commerce +service: + externalapi: + nodePort: 32000 +` + +// GetHmcDefaultOverrides returns values overrides for hmc default remote environment +func GetHmcDefaultOverrides() string { + return hmcDefault +} + +// GetEcDefaultOverrides returns values overrides for ec default remote environment +func GetEcDefaultOverrides() string { + return ecDefault +} diff --git a/components/installer/pkg/overrides/static-file.go b/components/installer/pkg/overrides/static-file.go new file mode 100644 index 000000000000..b88910d4fe6f --- /dev/null +++ b/components/installer/pkg/overrides/static-file.go @@ -0,0 +1,80 @@ +package overrides + +import ( + "io/ioutil" + "log" + "os" + "path" +) + +// StaticFile interface defines contract for overrides file representation +type StaticFile interface { + HasOverrides() bool + GetOverrides() (*string, error) +} + +// LocalStaticFile struct defines static file overrides for local +type LocalStaticFile struct{} + +// NewLocalStaticFile function creates instance of LocalStaticFile struct for cluster overrides +func NewLocalStaticFile() *LocalStaticFile { + return &LocalStaticFile{} +} + +// HasOverrides . +func (localStaticFile *LocalStaticFile) HasOverrides() bool { + return false +} + +// GetOverrides . +func (localStaticFile *LocalStaticFile) GetOverrides() (*string, error) { + return nil, nil +} + +// ClusterStaticFile struct defines static file overrides for cluster +type ClusterStaticFile struct { + DirectoryPath *string +} + +// NewClusterStaticFile function creates instance of ClusterStaticFile struct for cluster overrides +func NewClusterStaticFile(dirPath string) *ClusterStaticFile { + return &ClusterStaticFile{ + DirectoryPath: &dirPath, + } +} + +// HasOverrides function returns boolean whether additional overrides are defined +func (clusterStaticFile *ClusterStaticFile) HasOverrides() bool { + if clusterStaticFile.DirectoryPath == nil { + return false + } + + if _, err := os.Stat(clusterStaticFile.getFilePath()); os.IsNotExist(err) { + return false + } + + return true +} + +// GetOverrides function reads cluster overrides file and returns its content +func (clusterStaticFile *ClusterStaticFile) GetOverrides() (*string, error) { + fileBytes, err := ioutil.ReadFile(clusterStaticFile.getFilePath()) + + if err != nil { + log.Printf( + "An error occured while reading file with additional overrides from path %s", + clusterStaticFile.getFilePath()) + + return nil, err + } + + overridesStr := string(fileBytes) + + return &overridesStr, nil +} + +func (clusterStaticFile *ClusterStaticFile) getFilePath() string { + const clusterStaticFileName = "cluster.yaml" + + return path.Join(*clusterStaticFile.DirectoryPath, clusterStaticFileName) +} diff --git a/components/installer/pkg/release/controller.go b/components/installer/pkg/release/controller.go new file mode 100644 index 000000000000..67219985fe49 --- /dev/null +++ b/components/installer/pkg/release/controller.go @@ -0,0 +1,153 @@ +package release + +import ( + "log" + "time" + + corev1 "k8s.io/api/core/v1" + typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1" + + kubeerrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/wait" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/tools/record" + "k8s.io/client-go/util/workqueue" + + internalscheme "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned/scheme" + informers "github.com/kyma-project/kyma/components/installer/pkg/client/informers/externalversions" + listers "github.com/kyma-project/kyma/components/installer/pkg/client/listers/release/v1alpha1" + "github.com/kyma-project/kyma/components/installer/pkg/finalizer" + + internalerrors "github.com/kyma-project/kyma/components/installer/pkg/errors" +) + +const ( + // SuccessSynced is used as part of the Event 'reason' when a Release is synced + SuccessSynced = "Synced" + + // MessageResourceSynced is the message used for an Event fired when a Release + // is synced successfully + MessageResourceSynced = "Release synced successfully" +) + +// Controller is the controller implementation for Release resources +type Controller struct { + kubeClientset *kubernetes.Clientset + releaseLister listers.ReleaseLister + queue workqueue.RateLimitingInterface + recorder record.EventRecorder + errorHandlers internalerrors.ErrorHandlersInterface + finalizerManager *finalizer.Manager +} + +// NewController returns a new instance of release Controller +func NewController(kubeClientset *kubernetes.Clientset, internalInformerFactory informers.SharedInformerFactory, finalizerManager *finalizer.Manager) *Controller { + + releaseInformer := internalInformerFactory.Release().V1alpha1().Releases() + + internalscheme.AddToScheme(scheme.Scheme) + + eventBroadcaster := record.NewBroadcaster() + eventBroadcaster.StartRecordingToSink(&typedcorev1.EventSinkImpl{Interface: kubeClientset.CoreV1().Events("")}) + recorder := eventBroadcaster.NewRecorder(scheme.Scheme, corev1.EventSource{Component: "kymaInstaller"}) + + c := &Controller{ + kubeClientset: kubeClientset, + releaseLister: releaseInformer.Lister(), + queue: workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "kymaReleaseQueue"), + recorder: recorder, + errorHandlers: &internalerrors.ErrorHandlers{}, + finalizerManager: finalizerManager, + } + + releaseInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + //TBD + AddFunc: func(obj interface{}) {}, + UpdateFunc: func(old, new interface{}) {}, + DeleteFunc: func(obv interface{}) {}, + }) + + return c +} + +// Run runs the controller +func (c *Controller) Run(workers int, stopCh <-chan struct{}) { + + defer func() { + log.Println("Shutting down Release controller...") + c.queue.ShutDown() + }() + + for i := 0; i < workers; i++ { + //start workers + go wait.Until(c.worker, time.Second, stopCh) + } +} + +func (c *Controller) worker() { + + // process until we're told to stop + for c.processNextWorkItem() { + } +} + +func (c *Controller) processNextWorkItem() bool { + + key, quit := c.queue.Get() + if quit { + return false + } + + defer c.queue.Done(key) + + err := c.syncHandler(key.(string)) + c.handleErr(err, key) + return true +} + +func (c *Controller) syncHandler(key string) error { + + namespace, name, err := cache.SplitMetaNamespaceKey(key) + if err != nil { + return err + } + + release, err := c.releaseLister.Releases(namespace).Get(name) + if err != nil { + if kubeerrors.IsNotFound(err) { + runtime.HandleError(err) + return nil + } + + return err + } + + // Check if deletion has been triggered + // with ReleaseFinalizerHandler + + c.recorder.Event(release, corev1.EventTypeNormal, SuccessSynced, MessageResourceSynced) + return nil +} + +func (c *Controller) handleErr(err error, key interface{}) { + + if err == nil { + c.queue.Forget(key) + return + } + + if c.queue.NumRequeues(key) < 5 { + + // Re-enqueue the key rate limited. Based on the rate limiter on the + // queue and the re-enqueue history, the key will be processed later again. + c.queue.AddRateLimited(key) + return + } + + c.queue.Forget(key) + runtime.HandleError(err) +} diff --git a/components/installer/pkg/servicecatalog/client.go b/components/installer/pkg/servicecatalog/client.go new file mode 100644 index 000000000000..ea091876a4c0 --- /dev/null +++ b/components/installer/pkg/servicecatalog/client.go @@ -0,0 +1,83 @@ +package servicecatalog + +import ( + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" +) + +// ClientInterface exposes functions to interact with ServiceCatalog +type ClientInterface interface { + GetServiceBindings(ns string) (*v1beta1.ServiceBindingList, error) + GetServiceInstances(ns string) (*v1beta1.ServiceInstanceList, error) + DeleteBinding(namespace, name string) error + DeleteInstance(namespace, name string) error +} + +// Client provides functions to interact with Service Catalog. +// It wraps the actual service catalog client which is instantiated lazily (so _this_ Client can be instantiated before Service Catalog is available) +type client struct { + wrapper catalogServiceWrapper +} + +// NewClient returns a pointer to a Client instance configured with provided config. +func NewClient(config *rest.Config) ClientInterface { + res := client{ + wrapper: newDefaultCatalogServiceWrapper(config), + } + return &res +} + +// GetServiceBindings returns all ServiceBinding objects from provided namespace. +// Use empty string to return objects from all namespaces. +func (c *client) GetServiceBindings(ns string) (*v1beta1.ServiceBindingList, error) { + api, err := c.wrapper.getCatalogAPI() + if err != nil { + return nil, err + } + + return api.ServiceBindings(ns).List(v1.ListOptions{}) +} + +// GetServiceInstances returns all ServiceInstance objects from provided namespace. +// Use empty string to return objects from all namespaces. +func (c *client) GetServiceInstances(ns string) (*v1beta1.ServiceInstanceList, error) { + api, err := c.wrapper.getCatalogAPI() + if err != nil { + return nil, err + } + + return api.ServiceInstances(ns).List(v1.ListOptions{}) +} + +// DeleteBinding deletes ServiceBinding with given name from given namespace +func (c *client) DeleteBinding(namespace, name string) error { + api, err := c.wrapper.getCatalogAPI() + if err != nil { + return err + } + + err = api.ServiceBindings(namespace).Delete(name, &v1.DeleteOptions{}) + + if err != nil { + return err + } + + return nil +} + +// DeleteBinding deletes ServiceBinding with given name from given namespace +func (c *client) DeleteInstance(namespace, name string) error { + api, err := c.wrapper.getCatalogAPI() + if err != nil { + return err + } + + err = api.ServiceInstances(namespace).Delete(name, &v1.DeleteOptions{}) + + if err != nil { + return err + } + + return nil +} diff --git a/components/installer/pkg/servicecatalog/wrapper.go b/components/installer/pkg/servicecatalog/wrapper.go new file mode 100644 index 000000000000..51f5675d10b7 --- /dev/null +++ b/components/installer/pkg/servicecatalog/wrapper.go @@ -0,0 +1,48 @@ +package servicecatalog + +import ( + serviceCatalog "github.com/kubernetes-incubator/service-catalog/pkg/client/clientset_generated/clientset" + servicecatalogv1beta1 "github.com/kubernetes-incubator/service-catalog/pkg/client/clientset_generated/clientset/typed/servicecatalog/v1beta1" + "k8s.io/client-go/rest" +) + +// Wraps Service Catalog API so we can test the code without the actual API being invoked +type catalogServiceWrapper interface { + getCatalogAPI() (servicecatalogv1beta1.ServicecatalogV1beta1Interface, error) +} + +// Implements catalogServiceWrapper +type defaultCatalogServiceWrapper struct { + config *rest.Config + catalogClient *serviceCatalog.Clientset +} + +func (w *defaultCatalogServiceWrapper) getCatalogAPI() (servicecatalogv1beta1.ServicecatalogV1beta1Interface, error) { + if err := w.lazyInit(); err != nil { + return nil, err + } + return w.catalogClient.ServicecatalogV1beta1(), nil +} + +func newDefaultCatalogServiceWrapper(config *rest.Config) catalogServiceWrapper { + res := defaultCatalogServiceWrapper{ + config: config, + } + return &res +} + +// Lazy initialization ! +func (w *defaultCatalogServiceWrapper) lazyInit() error { + + if w.catalogClient == nil { + //boostrap new service catalog client + catalogClient, err := serviceCatalog.NewForConfig(w.config) + if err != nil { + return err + } + + w.catalogClient = catalogClient + } + + return nil +} diff --git a/components/installer/pkg/statusmanager/status-manager.go b/components/installer/pkg/statusmanager/status-manager.go new file mode 100644 index 000000000000..0823bce95bf5 --- /dev/null +++ b/components/installer/pkg/statusmanager/status-manager.go @@ -0,0 +1,102 @@ +package statusmanager + +import ( + installationv1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/apis/installer/v1alpha1" + installationClientset "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned" + listers "github.com/kyma-project/kyma/components/installer/pkg/client/listers/installer/v1alpha1" + "github.com/kyma-project/kyma/components/installer/pkg/consts" + "k8s.io/client-go/util/retry" +) + +// StatusManager . +type StatusManager interface { + InProgress(description string) error + InstallDone(url, kymaVersion string) error + UpdateDone(url, kymaVersion string) error + UninstallDone() error + Error(description string) error +} + +type statusManager struct { + lister listers.InstallationLister + client installationClientset.Interface +} + +// NewKymaStatusManager . +func NewKymaStatusManager(installationClient installationClientset.Interface, installationLister listers.InstallationLister) StatusManager { + sm := &statusManager{ + lister: installationLister, + client: installationClient, + } + + return sm +} + +//InProgress . +func (sm *statusManager) InProgress(description string) error { + instObj, getErr := sm.lister.Installations(consts.INST_NAMESPACE).Get(consts.INST_RESOURCE) + if getErr != nil { + return getErr + } + + installationCopy := instObj.DeepCopy() + + instStatus := getStatus(installationv1alpha1.StateInProgress, description, installationCopy.Status.URL, installationCopy.Status.KymaVersion) + return sm.update(instStatus) +} + +//InstallDone . +func (sm *statusManager) InstallDone(url, kymaVersion string) error { + instStatus := getStatus(installationv1alpha1.StateInstalled, "Kyma installed", url, kymaVersion) + return sm.update(instStatus) +} + +//UpdateDone . +func (sm *statusManager) UpdateDone(url, kymaVersion string) error { + instStatus := getStatus(installationv1alpha1.StateUpdated, "Kyma updated", url, kymaVersion) + return sm.update(instStatus) +} + +//UninstallDone . +func (sm *statusManager) UninstallDone() error { + instStatus := getStatus(installationv1alpha1.StateUninstalled, "Kyma uninstalled", "", "") + return sm.update(instStatus) +} + +//Error . +func (sm *statusManager) Error(description string) error { + instStatus := getStatus(installationv1alpha1.StateError, description, "", "") + return sm.update(instStatus) +} + +func (sm *statusManager) update(installationStatus *installationv1alpha1.InstallationStatus) error { + retryErr := retry.RetryOnConflict(retry.DefaultRetry, func() error { + instObj, getErr := sm.lister.Installations(consts.INST_NAMESPACE).Get(consts.INST_RESOURCE) + if getErr != nil { + return getErr + } + + installationCopy := instObj.DeepCopy() + installationCopy.Status = *installationStatus + + installationCopy.Status.Conditions = instObj.Status.Conditions + + _, updateErr := sm.client.InstallerV1alpha1().Installations(consts.INST_NAMESPACE).Update(installationCopy) + return updateErr + }) + + if retryErr != nil { + return retryErr + } + + return nil +} + +func getStatus(state installationv1alpha1.StateEnum, description, url, version string) *installationv1alpha1.InstallationStatus { + return &installationv1alpha1.InstallationStatus{ + State: state, + Description: description, + URL: url, + KymaVersion: version, + } +} diff --git a/components/installer/pkg/statusmanager/status-manager_test.go b/components/installer/pkg/statusmanager/status-manager_test.go new file mode 100644 index 000000000000..aa4a380e8a86 --- /dev/null +++ b/components/installer/pkg/statusmanager/status-manager_test.go @@ -0,0 +1,272 @@ +package statusmanager + +import ( + "testing" + "time" + + "github.com/kyma-project/kyma/components/installer/pkg/consts" + + installationv1alpha1 "github.com/kyma-project/kyma/components/installer/pkg/apis/installer/v1alpha1" + fake "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned/fake" + installationInformers "github.com/kyma-project/kyma/components/installer/pkg/client/informers/externalversions" + . "github.com/smartystreets/goconvey/convey" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestStatusManager(t *testing.T) { + Convey("Status Manager InProgress", t, func() { + Convey("should return error if kyma installation is not found", func() { + testStatusManager := getTestSetup() + + err := testStatusManager.InProgress("installing kyma") + + So(err, ShouldNotBeNil) + }) + + Convey("should update state and description only", func() { + expectedStatus := installationv1alpha1.StateInProgress + expectedDescription := "installing kyma" + + givenURL := "fakeURL" + givenVersion := "0.0.1" + + testInst := &installationv1alpha1.Installation{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.INST_RESOURCE, + Namespace: consts.INST_NAMESPACE, + }, + Spec: installationv1alpha1.InstallationSpec{ + URL: givenURL, + KymaVersion: givenVersion, + }, + } + testStatusManager := getTestSetup(testInst) + + err := testStatusManager.InProgress(expectedDescription) + + kymaInst, _ := testStatusManager.client.InstallerV1alpha1().Installations(consts.INST_NAMESPACE).Get(consts.INST_RESOURCE, metav1.GetOptions{}) + + So(err, ShouldBeNil) + So(kymaInst.Status.State, ShouldEqual, expectedStatus) + So(kymaInst.Status.Description, ShouldEqual, expectedDescription) + So(kymaInst.Status.URL, ShouldEqual, "") + So(kymaInst.Status.KymaVersion, ShouldEqual, "") + }) + + Convey("should update state and description only leaving old url and version", func() { + oldState := installationv1alpha1.StateInstalled + oldDescription := "Kyma installed" + oldURL := "installedURL" + oldVersion := "0.0.1" + + testState := installationv1alpha1.StateInProgress + testDescription := "installing kyma" + testURL := "fakeURL" + testVersion := "0.0.2" + + testInst := &installationv1alpha1.Installation{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.INST_RESOURCE, + Namespace: consts.INST_NAMESPACE, + }, + Spec: installationv1alpha1.InstallationSpec{ + URL: testURL, + KymaVersion: testVersion, + }, + Status: installationv1alpha1.InstallationStatus{ + State: oldState, + Description: oldDescription, + URL: oldURL, + KymaVersion: oldVersion, + }, + } + testStatusManager := getTestSetup(testInst) + + err := testStatusManager.InProgress(testDescription) + + kymaInst, _ := testStatusManager.client.InstallerV1alpha1().Installations(consts.INST_NAMESPACE).Get(consts.INST_RESOURCE, metav1.GetOptions{}) + + So(err, ShouldBeNil) + So(kymaInst.Status.State, ShouldEqual, testState) + So(kymaInst.Status.Description, ShouldEqual, testDescription) + So(kymaInst.Status.URL, ShouldEqual, oldURL) + So(kymaInst.Status.KymaVersion, ShouldEqual, oldVersion) + }) + }) + + Convey("Status Manager Error", t, func() { + + Convey("should update state and description only", func() { + expectedState := installationv1alpha1.StateError + expectedDescription := "installing kyma" + + givenURL := "fakeURL" + givenVersion := "0.0.1" + + testInst := &installationv1alpha1.Installation{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.INST_RESOURCE, + Namespace: consts.INST_NAMESPACE, + }, + Spec: installationv1alpha1.InstallationSpec{ + URL: givenURL, + KymaVersion: givenVersion, + }, + } + testStatusManager := getTestSetup(testInst) + + err := testStatusManager.Error(expectedDescription) + + kymaInst, _ := testStatusManager.client.InstallerV1alpha1().Installations(consts.INST_NAMESPACE).Get(consts.INST_RESOURCE, metav1.GetOptions{}) + + So(err, ShouldBeNil) + So(kymaInst.Status.State, ShouldEqual, expectedState) + So(kymaInst.Status.Description, ShouldEqual, expectedDescription) + So(kymaInst.Status.URL, ShouldEqual, "") + So(kymaInst.Status.KymaVersion, ShouldEqual, "") + }) + + Convey("should update state, description only and clear URL and KymaVersion", func() { + oldState := installationv1alpha1.StateInstalled + oldDescription := "kyma installed" + oldURL := "installedURL" + oldVersion := "0.0.1" + + testDescription := "updating kyma" + testState := installationv1alpha1.StateError + testURL := "fakeURL" + testVersion := "0.0.2" + + testInst := &installationv1alpha1.Installation{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.INST_RESOURCE, + Namespace: consts.INST_NAMESPACE, + }, + Spec: installationv1alpha1.InstallationSpec{ + URL: testURL, + KymaVersion: testVersion, + }, + Status: installationv1alpha1.InstallationStatus{ + State: oldState, + Description: oldDescription, + URL: oldURL, + KymaVersion: oldVersion, + }, + } + testStatusManager := getTestSetup(testInst) + + err := testStatusManager.Error(testDescription) + + kymaInst, _ := testStatusManager.client.InstallerV1alpha1().Installations(consts.INST_NAMESPACE).Get(consts.INST_RESOURCE, metav1.GetOptions{}) + + So(err, ShouldBeNil) + So(kymaInst.Status.State, ShouldEqual, testState) + So(kymaInst.Status.Description, ShouldEqual, testDescription) + So(kymaInst.Status.URL, ShouldEqual, "") + So(kymaInst.Status.KymaVersion, ShouldEqual, "") + }) + }) + + Convey("Status Manager Done", t, func() { + + Convey("should update state, description, url and kyma version after installation", func() { + oldState := installationv1alpha1.StateInProgress + oldDescription := "installing kyma" + oldURL := "installedURL" + oldVersion := "0.0.1" + + testState := installationv1alpha1.StateInstalled + testDescription := "Kyma installed" + testURL := "fakeURL" + testVersion := "0.0.2" + + testInst := &installationv1alpha1.Installation{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.INST_RESOURCE, + Namespace: consts.INST_NAMESPACE, + }, + Spec: installationv1alpha1.InstallationSpec{ + URL: testURL, + KymaVersion: testVersion, + }, + Status: installationv1alpha1.InstallationStatus{ + State: oldState, + Description: oldDescription, + URL: oldURL, + KymaVersion: oldVersion, + }, + } + testStatusManager := getTestSetup(testInst) + + err := testStatusManager.InstallDone(testURL, testVersion) + + kymaInst, _ := testStatusManager.client.InstallerV1alpha1().Installations(consts.INST_NAMESPACE).Get(consts.INST_RESOURCE, metav1.GetOptions{}) + + So(err, ShouldBeNil) + So(kymaInst.Status.State, ShouldEqual, testState) + So(kymaInst.Status.Description, ShouldEqual, testDescription) + So(kymaInst.Status.URL, ShouldEqual, testURL) + So(kymaInst.Status.KymaVersion, ShouldEqual, testVersion) + }) + + Convey("should update state, description, url and kyma version after update", func() { + oldState := installationv1alpha1.StateInstalled + oldDescription := "installing kyma" + oldURL := "installedURL" + oldVersion := "0.0.1" + + testState := installationv1alpha1.StateUpdated + testDescription := "Kyma updated" + testURL := "fakeURL" + testVersion := "0.0.2" + + testInst := &installationv1alpha1.Installation{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.INST_RESOURCE, + Namespace: consts.INST_NAMESPACE, + }, + Spec: installationv1alpha1.InstallationSpec{ + URL: testURL, + KymaVersion: testVersion, + }, + Status: installationv1alpha1.InstallationStatus{ + State: oldState, + Description: oldDescription, + URL: oldURL, + KymaVersion: oldVersion, + }, + } + testStatusManager := getTestSetup(testInst) + + err := testStatusManager.UpdateDone(testURL, testVersion) + + kymaInst, _ := testStatusManager.client.InstallerV1alpha1().Installations(consts.INST_NAMESPACE).Get(consts.INST_RESOURCE, metav1.GetOptions{}) + + So(err, ShouldBeNil) + So(kymaInst.Status.State, ShouldEqual, testState) + So(kymaInst.Status.Description, ShouldEqual, testDescription) + So(kymaInst.Status.URL, ShouldEqual, testURL) + So(kymaInst.Status.KymaVersion, ShouldEqual, testVersion) + }) + }) +} + +func getTestSetup(installations ...runtime.Object) *statusManager { + fakeClient := fake.NewSimpleClientset(installations...) + informers := installationInformers.NewSharedInformerFactory(fakeClient, time.Second*0) + installationLister := informers.Installer().V1alpha1().Installations().Lister() + + if len(installations) > 0 { + for ind := range installations { + informers.Installer().V1alpha1().Installations().Informer().GetIndexer().Add(installations[ind]) + } + } + + testStatusManager := &statusManager{ + client: fakeClient, + lister: installationLister, + } + + return testStatusManager +} diff --git a/components/installer/pkg/steps/azure-deprovisioner.go b/components/installer/pkg/steps/azure-deprovisioner.go new file mode 100644 index 000000000000..1be635a0226f --- /dev/null +++ b/components/installer/pkg/steps/azure-deprovisioner.go @@ -0,0 +1,297 @@ +package steps + +import ( + "log" + "time" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + installationConfig "github.com/kyma-project/kyma/components/installer/pkg/config" + internalerrors "github.com/kyma-project/kyma/components/installer/pkg/errors" + serviceCatalog "github.com/kyma-project/kyma/components/installer/pkg/servicecatalog" +) + +// For now we're only interested in these +var azureBrokerServiceClasses = map[string]string{ + "fb9bc99e-0aa9-11e6-8a8a-000d3a002ed5": "azure-sqldb", + "997b8372-8dac-40ac-ae65-758b4a5075a5": "azure-mysqldb", + "0346088a-d4b2-4478-aa32-f18e295ec1d9": "azure-rediscache", +} + +// DeprovisionConfig is used to parametrize deprovisioning of Azure Resources +type DeprovisionConfig struct { + BindingDeleteMaxReps int + BindingDeleteSleepTime int + InstanceDeleteMaxReps int + InstanceDeleteSleepTime int +} + +// DefaultDeprovisionConfig returns default config for deprovisioning Azure Resources +func DefaultDeprovisionConfig() *DeprovisionConfig { + res := DeprovisionConfig{ + BindingDeleteMaxReps: 3, //times + BindingDeleteSleepTime: 3, //seconds + InstanceDeleteMaxReps: 20, //times + InstanceDeleteSleepTime: 15, //seconds + } + + return &res +} + +// DeprovisionAzureResources performs automatic removal of all resources created with Azure Broker. +func (steps *InstallationSteps) DeprovisionAzureResources(config *DeprovisionConfig, installation installationConfig.InstallationContext) error { + + const stepName string = "Deprovisioning Azure Broker resources" + steps.PrintInstallationStep(stepName) + + steps.statusManager.InProgress(stepName) + + if config == nil { + config = DefaultDeprovisionConfig() + } + + d := deprovisioner{ + config: config, + serviceCatalog: steps.serviceCatalog, + errorHandlers: steps.errorHandlers, + } + + if err := d.deprovision(); err != nil { + return err + } + + log.Println(stepName + "...DONE") + return nil +} + +type deprovisioner struct { + config *DeprovisionConfig + serviceCatalog serviceCatalog.ClientInterface + errorHandlers internalerrors.ErrorHandlersInterface +} + +func (d deprovisioner) deprovision() error { + + const allNamespaces = "" //empty string == all namespaces + instancesList, err := d.serviceCatalog.GetServiceInstances(allNamespaces) + if d.errorHandlers.CheckError("Error while getting Service Instances: ", err) { + return err + } + azureInstances := filterAzureBrokerInstances(instancesList.Items) + + bindingsList, err := d.serviceCatalog.GetServiceBindings(allNamespaces) + + if d.errorHandlers.CheckError("Error while getting Service Bindings: ", err) { + return err + } + + azureBindings := filterAzureBrokerBindings(azureInstances, bindingsList.Items) + + //First, delete all bindings + if len(azureBindings) > 0 { + err = d.deleteBindings(azureBindings) + d.errorHandlers.LogError("An error occurred during deleting Service Bindings: ", err) + } else { + log.Println("--> No Service Bindings found!") + } + + //Then delete all instances + if len(azureInstances) > 0 { + err = d.deleteInstances(azureInstances) + if d.errorHandlers.CheckError("An error occurred during deleting Service Instances: ", err) { + return err + } + } else { + log.Println("--> No Service Instances found!") + } + + return err +} + +//deleteBindings tries to delete provided objects and waits for completion +func (d deprovisioner) deleteBindings(bindings []v1beta1.ServiceBinding) error { + log.Println("--> Deleting Service Bindings...") + deletedBindings := []v1beta1.ServiceBinding{} + + for _, binding := range bindings { + log.Printf("----> Deleting Service Binding: [%s/%s], Service Instance: %s\n", binding.Namespace, binding.Name, binding.Spec.ServiceInstanceRef.Name) + err := d.serviceCatalog.DeleteBinding(binding.Namespace, binding.Name) + if !d.errorHandlers.CheckError("----> An error occurred during deleting Service Binding: ", err) { + deletedBindings = append(deletedBindings, binding) + } + } + + if len(deletedBindings) > 0 { + log.Println("----> Waiting until all Service Bindings are deleted...") + + //Bindings are quite fast to delete + time.Sleep(time.Second * time.Duration(d.config.BindingDeleteSleepTime)) + + //Wait for bindings to disappear + bindingsTest := func() (bool, error) { + return d.bindingsExist(deletedBindings) + } + + waitUntilExists(d.config.BindingDeleteMaxReps, d.config.BindingDeleteSleepTime, bindingsTest, "Service Binding") + } else { + log.Println("--> Warning! No Service Bindings were deleted...") + } + + return nil +} + +//deleteInstances tries to delete provided objects and waits for completion +func (d deprovisioner) deleteInstances(instances []v1beta1.ServiceInstance) error { + log.Println("--> Deleting Service Instances...") + deletedInstances := []v1beta1.ServiceInstance{} + + for _, instance := range instances { + log.Printf("----> Deleting Service Instance: [%s/%s]\n", instance.Namespace, instance.Name) + err := d.serviceCatalog.DeleteInstance(instance.Namespace, instance.Name) + if !d.errorHandlers.CheckError("----> An error occurred during deleting Service Instance: ", err) { + deletedInstances = append(deletedInstances, instance) + } + } + + if len(deletedInstances) > 0 { + log.Println("----> Waiting until all Service Instances are deleted...") + + //Instances take long time to delete + time.Sleep(time.Second * time.Duration(d.config.InstanceDeleteSleepTime)) + + //Wait for instances to disappear + instancesTest := func() (bool, error) { + return d.instancesExist(deletedInstances) + } + + waitUntilExists(d.config.InstanceDeleteMaxReps, d.config.InstanceDeleteSleepTime, instancesTest, "Service Instance") + } else { + log.Println("----> Warning! No Service Instances were deleted...") + } + + return nil +} + +// maxReps: maximum number of loop repetitions +// waitTime: time to wait between repetitions (seconds) +// existsFunc: function that returns true if the resource still exists +func waitUntilExists(maxReps, waitTime int, existsFunc func() (bool, error), typeName string) { + for i := 0; i < maxReps; i++ { + exists, err := existsFunc() + + if err != nil { + log.Println("--> An error occured while checking if "+typeName+" exists: ", err) + } else { + if !exists { + log.Printf("----> No more %s(s) exist!\n", typeName) + return + } + } + + if i < maxReps { + log.Printf("----> Some %s(s) still exist, keep waiting... (done: %v/%v)\n", typeName, i+1, maxReps) + time.Sleep(time.Second * time.Duration(waitTime)) + } + } + + log.Printf("----> Warning! Some %s(s) still exist, manual cleanup may be required!\n", typeName) +} + +func (d deprovisioner) instancesExist(deletedInstances []v1beta1.ServiceInstance) (bool, error) { + stillExists := func(existing *v1beta1.ServiceInstance) bool { + for _, deletedInstance := range deletedInstances { + if deletedInstance.UID == existing.UID { + return true + } + } + + return false + } + + findRes, err := d.serviceCatalog.GetServiceInstances("") + + if err != nil { + return false, err + } + + result := false + + if len(findRes.Items) > 0 { + for _, existing := range findRes.Items { + if stillExists(&existing) { + log.Printf("------> Service Instance: [%s/%s] still exists!\n", existing.Namespace, existing.Name) + result = true + } + } + } + + return result, nil +} + +func (d deprovisioner) bindingsExist(deletedBindings []v1beta1.ServiceBinding) (bool, error) { + + stillExists := func(existing *v1beta1.ServiceBinding) bool { + for _, deletedBinding := range deletedBindings { + if deletedBinding.UID == existing.UID { + return true + } + } + + return false + } + + findRes, err := d.serviceCatalog.GetServiceBindings("") + + if err != nil { + return false, err + } + + result := false + + if len(findRes.Items) > 0 { + for _, existing := range findRes.Items { + if stillExists(&existing) { + log.Printf("------> Service Binding: [%s/%s] still exists!\n", existing.Namespace, existing.Name) + result = true + } + } + } + + return result, nil +} + +//Filters given ServiceInstance slice, returning only those managed by Azure Broker +func filterAzureBrokerInstances(items []v1beta1.ServiceInstance) []v1beta1.ServiceInstance { + res := []v1beta1.ServiceInstance{} + + for _, item := range items { + if azureBrokerServiceClasses[item.Spec.ClusterServiceClassRef.Name] != "" { + res = append(res, item) + } + } + + return res +} + +//Filters given ServiceBinding slice, returning only those managed by Azure Broker +//Since ServiceBinding objects don't have any metadata related to the Broker, we have to find corresponding ServiceInstance object. +func filterAzureBrokerBindings(azureBrokerInstances []v1beta1.ServiceInstance, bindings []v1beta1.ServiceBinding) []v1beta1.ServiceBinding { + res := []v1beta1.ServiceBinding{} + + isAzureBrokerBinding := func(binding v1beta1.ServiceBinding) bool { + for _, instance := range azureBrokerInstances { + //ServiceInstanceRef is LocalObjectReference (same namespace) + if instance.Name == binding.Spec.ServiceInstanceRef.Name { + return true + } + } + return false + } + + for _, binding := range bindings { + if isAzureBrokerBinding(binding) { + res = append(res, binding) + } + } + + return res +} diff --git a/components/installer/pkg/steps/azure-deprovisioner_test.go b/components/installer/pkg/steps/azure-deprovisioner_test.go new file mode 100644 index 000000000000..8f9802001d15 --- /dev/null +++ b/components/installer/pkg/steps/azure-deprovisioner_test.go @@ -0,0 +1,152 @@ +package steps + +import ( + "testing" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + internalerrors "github.com/kyma-project/kyma/components/installer/pkg/errors" + . "github.com/smartystreets/goconvey/convey" +) + +func TestAzureDeprovisioner(t *testing.T) { + Convey("filterAzureBrokerInstances function should", t, func() { + + Convey("only return instances corresponding to Azure Classes", func() { + + const azureClassID = "fb9bc99e-0aa9-11e6-8a8a-000d3a002ed5" + //given + i1 := stubServiceInstance("i1", azureClassID) + i2 := stubServiceInstance("i2", "shouldBeFilteredOut1") + i3 := stubServiceInstance("i3", "shouldBeFilteredOut2") + + //when + filtered := filterAzureBrokerInstances([]v1beta1.ServiceInstance{*i1, *i2, *i3}) + + So(len(filtered), ShouldEqual, 1) + So(filtered[0].Name, ShouldEqual, "i1") + }) + }) + + Convey("filterAzureBrokerBindings function should", t, func() { + + Convey("only return bindings corresponding to Azure Classes", func() { + + const azureClassID = "fb9bc99e-0aa9-11e6-8a8a-000d3a002ed5" + //given + i1 := stubServiceInstance("i1", azureClassID) + b1 := stubServiceBinding("b1", "i1", "") + i2 := stubServiceInstance("i2", azureClassID) + b2 := stubServiceBinding("b2", "i2", "") + b3 := stubServiceBinding("b3", "shouldBeFiltered", "") + //when + filtered := filterAzureBrokerBindings([]v1beta1.ServiceInstance{*i2, *i1}, []v1beta1.ServiceBinding{*b1, *b2, *b3}) + + So(len(filtered), ShouldEqual, 2) + So(filtered[0].Name, ShouldEqual, "b1") + So(filtered[1].Name, ShouldEqual, "b2") + }) + }) + + Convey("deleteBindings function should", t, func() { + + Convey("retry for configured number of times", func() { + + const azureClassID = "fb9bc99e-0aa9-11e6-8a8a-000d3a002ed5" + //given + b1 := stubServiceBinding("b1", "i1", "11") + b1.Namespace = "abc" + b2 := stubServiceBinding("b2", "i2", "22") + b2.Namespace = "abc" + b3 := stubServiceBinding("b3", "i3", "33") + b3.Namespace = "abc" + bindings := []v1beta1.ServiceBinding{*b1, *b2, *b3} + + stubClient := deleteBindingsMockClient{ + maxGetBindingsCount: 2, + items: bindings, + } + + config := DeprovisionConfig{ + BindingDeleteMaxReps: 5, + BindingDeleteSleepTime: 0, + } + + d := deprovisioner{ + config: &config, + serviceCatalog: &stubClient, + errorHandlers: &internalerrors.ErrorHandlers{}, + } + + //when + err := d.deleteBindings(bindings) + + //then + So(err, ShouldBeNil) + //Ensure deleteBinding was called properly + So(len(stubClient.deletedBindings), ShouldEqual, 3) + So(stubClient.deletedBindings[0], ShouldEqual, "abc/b1") + So(stubClient.deletedBindings[1], ShouldEqual, "abc/b2") + So(stubClient.deletedBindings[2], ShouldEqual, "abc/b3") + //Ensure getBindings was only called twice + So(stubClient.actualGetBindingsCount, ShouldEqual, 2) + }) + }) + +} + +func stubServiceInstance(name, clusterServiceClassName string) *v1beta1.ServiceInstance { + ref := v1beta1.ClusterObjectReference{} + ref.Name = clusterServiceClassName + + res := v1beta1.ServiceInstance{} + res.Name = name + res.Spec.ClusterServiceClassRef = &ref + + return &res +} + +func stubServiceBinding(name, serviceInstanceName, UID string) *v1beta1.ServiceBinding { + instanceRef := v1beta1.LocalObjectReference{ + Name: serviceInstanceName, + } + + res := v1beta1.ServiceBinding{} + res.Name = name + res.Spec.ServiceInstanceRef = instanceRef + + return &res +} + +func mockCatalogInterfaceDeleteBindings() { + return +} + +//ServiceCatalog interface mock for deleteBindings function +type deleteBindingsMockClient struct { + maxGetBindingsCount int + actualGetBindingsCount int + items []v1beta1.ServiceBinding + deletedBindings []string //registers calls to "DeleteBinding" +} + +func (c *deleteBindingsMockClient) GetServiceBindings(ns string) (*v1beta1.ServiceBindingList, error) { + res := v1beta1.ServiceBindingList{} + c.maxGetBindingsCount-- + c.actualGetBindingsCount++ + if c.maxGetBindingsCount > 0 { + res := v1beta1.ServiceBindingList{} + res.Items = c.items + return &res, nil + } + return &res, nil +} + +func (c *deleteBindingsMockClient) GetServiceInstances(ns string) (*v1beta1.ServiceInstanceList, error) { + return nil, nil +} + +func (c *deleteBindingsMockClient) DeleteBinding(namespace, name string) error { + c.deletedBindings = append(c.deletedBindings, namespace+"/"+name) + return nil +} +func (c *deleteBindingsMockClient) DeleteInstance(namespace, name string) error { return nil } diff --git a/components/installer/pkg/steps/cluster-essentials.go b/components/installer/pkg/steps/cluster-essentials.go new file mode 100644 index 000000000000..59cf5aee314c --- /dev/null +++ b/components/installer/pkg/steps/cluster-essentials.go @@ -0,0 +1,80 @@ +package steps + +import ( + "log" + "path" + "strings" + + "github.com/kyma-project/kyma/components/installer/pkg/config" + "github.com/kyma-project/kyma/components/installer/pkg/overrides" +) + +// InstallClusterEssentials . +func (steps *InstallationSteps) InstallClusterEssentials(installationData *config.InstallationData) error { + const stepName string = "Installing cluster-essentials" + steps.PrintInstallationStep(stepName) + steps.statusManager.InProgress(stepName) + + releaseName := "cluster-essentials" + chartDir := path.Join(steps.chartDir, releaseName) + clusterEssentialsOverrides := steps.getClusterEssentialsOverrides(installationData, chartDir) + + installResp, installErr := steps.helmClient.InstallRelease( + chartDir, + "kyma-system", + releaseName, + clusterEssentialsOverrides) + + if steps.errorHandlers.CheckError("Install Error: ", installErr) { + steps.statusManager.Error(stepName) + return installErr + } + + steps.helmClient.PrintRelease(installResp.Release) + log.Println(stepName + "...DONE") + + return nil +} + +// UpdateClusterEssentials . +func (steps *InstallationSteps) UpdateClusterEssentials(installationData *config.InstallationData) error { + const stepName string = "Updating cluster-essentials" + steps.PrintInstallationStep(stepName) + steps.statusManager.InProgress(stepName) + + releaseName := "cluster-essentials" + chartDir := path.Join(steps.chartDir, releaseName) + clusterEssentailsOverrides := steps.getClusterEssentialsOverrides(installationData, chartDir) + + upgradeResp, upgradeErr := steps.helmClient.UpgradeRelease( + chartDir, + releaseName, + clusterEssentailsOverrides) + + if steps.errorHandlers.CheckError("Upgrade Error: ", upgradeErr) { + steps.statusManager.Error(stepName) + return upgradeErr + } + + steps.helmClient.PrintRelease(upgradeResp.Release) + log.Println(stepName + "...DONE") + + return nil +} + +func (steps *InstallationSteps) getClusterEssentialsOverrides(installationData *config.InstallationData, chartDir string) string { + var allOverrides []string + + globalOverrides, err := overrides.GetGlobalOverrides(installationData) + steps.errorHandlers.LogError("Couldn't get global overrides: ", err) + allOverrides = append(allOverrides, globalOverrides) + + fileOverrides := steps.getStaticFileOverrides(installationData, chartDir) + if fileOverrides.HasOverrides() == true { + fileOverridesStr, err := fileOverrides.GetOverrides() + steps.errorHandlers.LogError("Couldn't get additional overrides: ", err) + allOverrides = append(allOverrides, *fileOverridesStr) + } + + return strings.Join(allOverrides, "\n") +} diff --git a/components/installer/pkg/steps/cluster-essentials_test.go b/components/installer/pkg/steps/cluster-essentials_test.go new file mode 100644 index 000000000000..aa540f8306b3 --- /dev/null +++ b/components/installer/pkg/steps/cluster-essentials_test.go @@ -0,0 +1,39 @@ +package steps + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestInstallClusterEssentials(t *testing.T) { + + Convey("InstallClusterEssentials function", t, func() { + + Convey("In case no error occurs, should", func() { + + kymaTestSteps, testInst, mockHelmClient, _ := getTestSetup() + + Convey("call UpdateInstallationStatus once and call InstallRelease returning no error", func() { + + err := kymaTestSteps.InstallClusterEssentials(testInst) + + So(mockHelmClient.InstallReleaseCalled, ShouldBeTrue) + So(err, ShouldBeNil) + }) + }) + + Convey("In case an error occurs, should", func() { + + kymaTestSteps, testInst, mockErrorHelmClient, _ := getFailingTestSetup() + + Convey("call UpdateInstallationStatus once, call InstallRelease, call UpdateInstallationStatus again and return the error", func() { + + err := kymaTestSteps.InstallClusterEssentials(testInst) + + So(mockErrorHelmClient.InstallReleaseCalled, ShouldBeTrue) + So(err, ShouldNotBeNil) + }) + }) + }) +} diff --git a/components/installer/pkg/steps/cluster-prerequisites.go b/components/installer/pkg/steps/cluster-prerequisites.go new file mode 100644 index 000000000000..64330d6dd299 --- /dev/null +++ b/components/installer/pkg/steps/cluster-prerequisites.go @@ -0,0 +1,47 @@ +package steps + +import ( + "log" + "path" + + "github.com/kyma-project/kyma/components/installer/pkg/config" + "github.com/kyma-project/kyma/components/installer/pkg/toolkit" +) + +//InstallClusterPrerequisites will install all needed before Kyma installation resources +func (steps *InstallationSteps) InstallClusterPrerequisites(installationData *config.InstallationData) error { + const stepName string = "Installing cluster prerequisites" + steps.PrintInstallationStep(stepName) + + steps.statusManager.InProgress(stepName) + prerequisitesScriptPath := path.Join(steps.chartDir, "cluster-prerequisites/install.sh") + err := steps.kymaCommandExecutor.RunCommand("/bin/bash", prerequisitesScriptPath) + + if steps.errorHandlers.CheckError("Script Error: ", err) { + steps.statusManager.Error(stepName) + return err + } + + log.Println(stepName + "...DONE") + + return nil +} + +//UpdateClusterPrerequisites will update all needed before Kyma installation resources +func (steps *InstallationSteps) UpdateClusterPrerequisites(installationData *config.InstallationData) error { + const stepName string = "Updating cluster prerequisites" + steps.PrintInstallationStep(stepName) + + steps.statusManager.InProgress(stepName) + prerequisitesScriptPath := path.Join(steps.chartDir, "cluster-prerequisites/update.sh") + err := steps.kymaCommandExecutor.RunBashCommand(prerequisitesScriptPath, toolkit.EmptyArgs) + + if steps.errorHandlers.CheckError("Script Error: ", err) { + steps.statusManager.Error(stepName) + return err + } + + log.Println(stepName + "...DONE") + + return nil +} diff --git a/components/installer/pkg/steps/cluster-prerequisites_test.go b/components/installer/pkg/steps/cluster-prerequisites_test.go new file mode 100644 index 000000000000..b4c1ba808348 --- /dev/null +++ b/components/installer/pkg/steps/cluster-prerequisites_test.go @@ -0,0 +1,39 @@ +package steps + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestInstallClusterPrerequisites(t *testing.T) { + + Convey("InstallClusterPrerequisites function", t, func() { + + Convey("In case no error occurs, should", func() { + + kymaTestSteps, testInst, _, mockCommandExecutor := getTestSetup() + + Convey("call UpdateInstallationStatus once and call RunCommand returning no error", func() { + + err := kymaTestSteps.InstallClusterPrerequisites(testInst) + + So(mockCommandExecutor.TimesMockCommandExecutorCalled, ShouldEqual, 1) + So(err, ShouldBeNil) + }) + }) + + Convey("In case an error occurs, should", func() { + + kymaTestSteps, testInst, _, mockFailingCommandExecutor := getFailingTestSetup() + + Convey("call UpdateInstallationStatus once, call RunCommand, call UpdateInstallationStatus again and return the error", func() { + + err := kymaTestSteps.InstallClusterPrerequisites(testInst) + + So(mockFailingCommandExecutor.MockFailingCommandExecutorCalled, ShouldBeTrue) + So(err, ShouldNotBeNil) + }) + }) + }) +} diff --git a/components/installer/pkg/steps/core.go b/components/installer/pkg/steps/core.go new file mode 100644 index 000000000000..d95425c90835 --- /dev/null +++ b/components/installer/pkg/steps/core.go @@ -0,0 +1,120 @@ +package steps + +import ( + "log" + "os/exec" + "path" + "strings" + + "github.com/kyma-project/kyma/components/installer/pkg/config" + "github.com/kyma-project/kyma/components/installer/pkg/overrides" +) + +const kymaPath = "/kyma" + +//InstallCore . +func (steps InstallationSteps) InstallCore(installationData *config.InstallationData) error { + const stepName string = "Installing core" + steps.PrintInstallationStep(stepName) + steps.statusManager.InProgress(stepName) + + releaseName := "core" + chartDir := path.Join(steps.chartDir, releaseName) + coreOverrides := steps.getCoreOverrides(installationData, chartDir) + + installResp, installErr := steps.helmClient.InstallRelease( + chartDir, + "kyma-system", + releaseName, + coreOverrides) + + if steps.errorHandlers.CheckError("Install Error: ", installErr) { + steps.statusManager.Error(stepName) + logCore(steps) + return installErr + } + + steps.helmClient.PrintRelease(installResp.Release) + log.Println(stepName + "...DONE") + + return nil +} + +//UpgradeCore . +func (steps InstallationSteps) UpgradeCore(installationData *config.InstallationData) error { + const stepName string = "Upgrading core" + steps.PrintInstallationStep(stepName) + steps.statusManager.InProgress(stepName) + + releaseName := "core" + chartDir := path.Join(steps.chartDir, releaseName) + coreOverrides := steps.getCoreOverrides(installationData, chartDir) + + upgradeResp, upgradeErr := steps.helmClient.UpgradeRelease( + chartDir, + releaseName, + coreOverrides) + + if steps.errorHandlers.CheckError("Upgrade Error: ", upgradeErr) { + steps.statusManager.Error(stepName) + logCore(steps) + return upgradeErr + } + + steps.helmClient.PrintRelease(upgradeResp.Release) + log.Println(stepName + "...DONE") + + return nil +} + +//Legacy stuff from old build scripts. +//TODO: Provide such logs for every step +func logCore(steps InstallationSteps) { + //Try to display debug data in case of failure + status, statusErr := steps.helmClient.ReleaseStatus("core") + if statusErr != nil { + log.Println("Cannot get release status: ", statusErr) + } else { + log.Println("core status: \n" + status) + } + + logFailedResources("kyma-system") +} + +func logFailedResources(ns string) { + path := path.Join(kymaPath, "installation/scripts/utils.sh") + log.Println("\nLooking for failed resources in namespace: " + ns) + cmd := exec.Command("/bin/bash", "-c", "source "+path+"; showFailedResources "+ns) + msg, scriptErr := cmd.Output() + if scriptErr != nil { + log.Printf("An error occurred while running script: %s (%s)\n", string(msg[:]), scriptErr) + return + } + log.Println(string(msg[:])) +} + +func (steps *InstallationSteps) getCoreOverrides(installationData *config.InstallationData, chartDir string) string { + var allOverrides []string + + globalOverrides, err := overrides.GetGlobalOverrides(installationData) + steps.errorHandlers.LogError("Couldn't get global overrides: ", err) + allOverrides = append(allOverrides, globalOverrides) + + azureBrokerOverrides, err := overrides.EnableAzureBroker(installationData) + steps.errorHandlers.LogError("Enable azure-broker Error: ", err) + allOverrides = append(allOverrides, azureBrokerOverrides) + + coreOverrides, err := overrides.GetCoreOverrides(installationData) + steps.errorHandlers.LogError("Couldn't get Kyma core overrides: ", err) + + allOverrides = append(allOverrides, coreOverrides) + + fileOverrides := steps.getStaticFileOverrides(installationData, chartDir) + if fileOverrides.HasOverrides() == true { + fileOverridesStr, err := fileOverrides.GetOverrides() + steps.errorHandlers.LogError("Couldn't get additional overrides: ", err) + allOverrides = append(allOverrides, *fileOverridesStr) + } + + return strings.Join(allOverrides, "\n") +} diff --git a/components/installer/pkg/steps/core_test.go b/components/installer/pkg/steps/core_test.go new file mode 100644 index 000000000000..5a4575ddd738 --- /dev/null +++ b/components/installer/pkg/steps/core_test.go @@ -0,0 +1,73 @@ +package steps + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestInstallCore(t *testing.T) { + + Convey("InstallCore function", t, func() { + + Convey("In case no error occurs, should", func() { + + kymaTestSteps, installationData, mockHelmClient, _ := getTestSetup() + + Convey("call UpdateInstallationStatus once and call InstalRelease, returning no error", func() { + + err := kymaTestSteps.InstallCore(installationData) + + So(mockHelmClient.InstallReleaseCalled, ShouldBeTrue) + So(err, ShouldBeNil) + }) + }) + + Convey("In case en error occurs, should", func() { + + kymaTestSteps, installationData, mockErrorHelmClient, _ := getFailingTestSetup() + + Convey("call UpdateInstallationStatus once, call InstallRelease, call UpdateInstallationStatus, call ReleaseStatus again and return the error", func() { + + err := kymaTestSteps.InstallCore(installationData) + + So(mockErrorHelmClient.InstallReleaseCalled, ShouldBeTrue) + So(mockErrorHelmClient.ReleaseStatusCalled, ShouldBeTrue) + So(err, ShouldNotBeNil) + }) + }) + }) +} + +func TestUpgradeCore(t *testing.T) { + + Convey("UpgradeCore function", t, func() { + + Convey("In case no error occurs, should", func() { + + kymaTestSteps, installationData, mockHelmClient, _ := getTestSetup() + + Convey("call UpdateInstallationStatus once and call UpdateRelease, returning no error", func() { + + err := kymaTestSteps.UpgradeCore(installationData) + + So(mockHelmClient.UpgradeReleaseCalled, ShouldBeTrue) + So(err, ShouldBeNil) + }) + }) + + Convey("In case en error occurs, should", func() { + + kymaTestSteps, installationData, mockErrorHelmClient, _ := getFailingTestSetup() + + Convey("call UpdateInstallationStatus once, call UpdateRelease, call UpdateInstallationStatus, call ReleaseStatus again and return the error", func() { + + err := kymaTestSteps.UpgradeCore(installationData) + + So(mockErrorHelmClient.UpgradeReleaseCalled, ShouldBeTrue) + So(mockErrorHelmClient.ReleaseStatusCalled, ShouldBeTrue) + So(err, ShouldNotBeNil) + }) + }) + }) +} diff --git a/components/installer/pkg/steps/dex.go b/components/installer/pkg/steps/dex.go new file mode 100644 index 000000000000..9d803704e6d6 --- /dev/null +++ b/components/installer/pkg/steps/dex.go @@ -0,0 +1,87 @@ +package steps + +import ( + "log" + "path" + "strings" + + "github.com/kyma-project/kyma/components/installer/pkg/config" + "github.com/kyma-project/kyma/components/installer/pkg/overrides" +) + +//InstallDex installs Dex component +func (steps *InstallationSteps) InstallDex(installationData *config.InstallationData) error { + + const stepName string = "Installing Dex" + const namespace string = "kyma-system" + + releaseName := "dex" + chartDir := path.Join(steps.chartDir, releaseName) + overrides := steps.getDexOverrides(installationData, chartDir) + + steps.PrintInstallationStep(stepName) + steps.statusManager.InProgress(stepName) + + installResp, installErr := steps.helmClient.InstallRelease( + chartDir, + namespace, + releaseName, + overrides) + + if steps.errorHandlers.CheckError("Install Error: ", installErr) { + steps.statusManager.Error(stepName) + return installErr + } + + steps.helmClient.PrintRelease(installResp.Release) + log.Println(stepName + "...Done") + + return nil +} + +// UpdateDex updates Dex component +func (steps *InstallationSteps) UpdateDex(installationData *config.InstallationData) error { + + const stepName string = "Updating Dex" + const namespace string = "kyma-system" + + releaseName := "dex" + chartDir := path.Join(steps.chartDir, releaseName) + overrides := steps.getDexOverrides(installationData, chartDir) + + steps.PrintInstallationStep(stepName) + steps.statusManager.InProgress(stepName) + + upgradeResp, upgradeErr := steps.helmClient.UpgradeRelease( + chartDir, + releaseName, + overrides) + + if steps.errorHandlers.CheckError("Install Error: ", upgradeErr) { + steps.statusManager.Error(stepName) + return upgradeErr + } + + steps.helmClient.PrintRelease(upgradeResp.Release) + log.Println(stepName + "...Done") + + return nil +} + +func (steps *InstallationSteps) getDexOverrides(installationData *config.InstallationData, chartDir string) string { + + var allOverrides []string + + globalOverrides, err := overrides.GetGlobalOverrides(installationData) + steps.errorHandlers.LogError("Couldn't get global overrides: ", err) + allOverrides = append(allOverrides, globalOverrides) + + fileOverrides := steps.getStaticFileOverrides(installationData, chartDir) + if fileOverrides.HasOverrides() == true { + fileOverridesStr, err := fileOverrides.GetOverrides() + steps.errorHandlers.LogError("Couldn't get additional overrides: ", err) + allOverrides = append(allOverrides, *fileOverridesStr) + } + + return strings.Join(allOverrides, "\n") +} diff --git a/components/installer/pkg/steps/dex_test.go b/components/installer/pkg/steps/dex_test.go new file mode 100644 index 000000000000..97280cd9717a --- /dev/null +++ b/components/installer/pkg/steps/dex_test.go @@ -0,0 +1,71 @@ +package steps + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestInstallDex(t *testing.T) { + + Convey("InstallDex function", t, func() { + + Convey("In case no error occurs, should", func() { + + kymaTestSteps, installationData, mockHelmClient, _ := getTestSetup() + + Convey("call UpdateInstallationStatus once and call InstallRelease, returning no error", func() { + + err := kymaTestSteps.InstallDex(installationData) + + So(mockHelmClient.InstallReleaseCalled, ShouldBeTrue) + So(err, ShouldBeNil) + }) + }) + + Convey("In case an error occurs, should", func() { + + kymaTestSteps, installationData, mockErrorHelmClient, _ := getFailingTestSetup() + + Convey("call UpdateInstallationStatus once, call InstallRelease, call UpdateInstallationStatus again and return the error", func() { + + err := kymaTestSteps.InstallDex(installationData) + + So(mockErrorHelmClient.InstallReleaseCalled, ShouldBeTrue) + So(err, ShouldNotBeNil) + }) + }) + }) +} + +func TestUpdateDex(t *testing.T) { + + Convey("UpdateDex function", t, func() { + + Convey("In case no error occurs, should", func() { + + kymaTestSteps, installationData, mockHelmClient, _ := getTestSetup() + + Convey("call UpdateInstallationStatus once and call UpdateRelease, returning no error", func() { + + err := kymaTestSteps.UpdateDex(installationData) + + So(mockHelmClient.UpgradeReleaseCalled, ShouldBeTrue) + So(err, ShouldBeNil) + }) + }) + + Convey("In case an error occurs, should", func() { + + kymaTestSteps, installationData, mockErrorHelmClient, _ := getFailingTestSetup() + + Convey("call UpdateInstallationStatus once, call UpdateRelease, call UpdateInstallationStatus again and return the error", func() { + + err := kymaTestSteps.UpdateDex(installationData) + + So(mockErrorHelmClient.UpgradeReleaseCalled, ShouldBeTrue) + So(err, ShouldNotBeNil) + }) + }) + }) +} diff --git a/components/installer/pkg/steps/doc.go b/components/installer/pkg/steps/doc.go new file mode 100644 index 000000000000..45098f2672da --- /dev/null +++ b/components/installer/pkg/steps/doc.go @@ -0,0 +1,2 @@ +//Package steps contains implementation for all installation steps. Not all are moved yet, work in progress +package steps // import "github.com/kyma-project/kyma/components/installer/pkg/steps" diff --git a/components/installer/pkg/steps/download-kyma.go b/components/installer/pkg/steps/download-kyma.go new file mode 100644 index 000000000000..547dbe326977 --- /dev/null +++ b/components/installer/pkg/steps/download-kyma.go @@ -0,0 +1,59 @@ +package steps + +import ( + "errors" + "log" + + "github.com/kyma-project/kyma/components/installer/pkg/config" +) + +//DownloadKyma . +func (steps InstallationSteps) DownloadKyma(installationData *config.InstallationData) error { + const stepName string = "Downloading Kyma" + steps.PrintInstallationStep(stepName) + steps.statusManager.InProgress(stepName) + + if steps.kymaPackageClient.NeedDownload(steps.kymaPath) { + + if installationData.KymaVersion == "" { + validationErr := errors.New("Set version for Kyma package") + steps.errorHandlers.LogError("Validation error: ", validationErr) + steps.statusManager.Error(stepName) + return validationErr + } + + if installationData.URL == "" { + validationErr := errors.New("Set url to Kyma package") + steps.errorHandlers.LogError("Validation error: ", validationErr) + steps.statusManager.Error(stepName) + return validationErr + } + + log.Println("Downloading Kyma... Version: " + installationData.KymaVersion + " url: " + installationData.URL) + + err := steps.kymaPackageClient.CreateDir(steps.kymaPath) + + if steps.errorHandlers.CheckError("Mkdir error: ", err) { + steps.statusManager.Error(stepName) + return err + } + + err = steps.kymaCommandExecutor.RunCommand("curl", "-Lks", installationData.URL, "-o", installationData.KymaVersion+".tar.gz") + + if steps.errorHandlers.CheckError("Download Kyma error: ", err) { + steps.statusManager.Error(stepName) + return err + } + + err = steps.kymaCommandExecutor.RunCommand("tar", "xz", "-C", steps.kymaPath, "--strip-components=1", "-f", installationData.KymaVersion+".tar.gz") + + if steps.errorHandlers.CheckError("Unpack Kyma error: ", err) { + steps.statusManager.Error(stepName) + return err + } + } else { + log.Println("Local Kyma sources provided. Download of sources not required.") + } + log.Println(stepName + "...DONE") + return nil +} diff --git a/components/installer/pkg/steps/download-kyma_test.go b/components/installer/pkg/steps/download-kyma_test.go new file mode 100644 index 000000000000..ab1b672cb233 --- /dev/null +++ b/components/installer/pkg/steps/download-kyma_test.go @@ -0,0 +1,186 @@ +package steps + +import ( + "strings" + "testing" + "time" + + fake "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned/fake" + installationInformers "github.com/kyma-project/kyma/components/installer/pkg/client/informers/externalversions" + "github.com/kyma-project/kyma/components/installer/pkg/config" + statusmanager "github.com/kyma-project/kyma/components/installer/pkg/statusmanager" + . "github.com/smartystreets/goconvey/convey" +) + +func TestDownloadKyma(t *testing.T) { + + Convey("DownloadKyma function", t, func() { + + Convey("should download kyma package in case of remote installation", func() { + testInst := &config.InstallationData{ + URL: "doesnotexists", + KymaVersion: "test", + } + + fakeClient := fake.NewSimpleClientset() + informers := installationInformers.NewSharedInformerFactory(fakeClient, time.Second*0) + mockStatusManager := statusmanager.NewKymaStatusManager(fakeClient, informers.Installer().V1alpha1().Installations().Lister()) + + mockCommandExecutor := newKymaCommandExecutor(). + pushCommand("curl", "-Lks", testInst.URL, "-o", testInst.KymaVersion+".tar.gz"). + pushCommand("tar", "xz", "-C", "./test-kyma", "--strip-components=1", "-f", testInst.KymaVersion+".tar.gz") + + mockKymaPackage := &mockKymaPackageClientForDownload{} + kymaTestSteps := New(nil, nil, nil, *TestChartDir, mockStatusManager, nil, mockCommandExecutor, mockKymaPackage) + + err := kymaTestSteps.DownloadKyma(testInst) + + So(mockCommandExecutor.TimesMockCommandExecutorCalled, ShouldEqual, 2) + So(err, ShouldBeNil) + }) + + Convey("should not create directory nor download kyma package in case of local installation", func() { + + testInst := &config.InstallationData{} + + fakeClient := fake.NewSimpleClientset() + informers := installationInformers.NewSharedInformerFactory(fakeClient, time.Second*0) + mockStatusManager := statusmanager.NewKymaStatusManager(fakeClient, informers.Installer().V1alpha1().Installations().Lister()) + + mockCommandExecutor := newKymaCommandExecutor(). + expectNoCalls() + + mockKymaPackage := &mockKymaPackageClientForLocal{} + + kymaTestSteps := New(nil, nil, nil, *TestChartDir, mockStatusManager, nil, mockCommandExecutor, mockKymaPackage) + + err := kymaTestSteps.DownloadKyma(testInst) + + So(mockCommandExecutor.TimesMockCommandExecutorCalled, ShouldEqual, 0) + So(err, ShouldBeNil) + }) + + Convey("should return error if url is not set", func() { + testInst := &config.InstallationData{} + + fakeClient := fake.NewSimpleClientset() + informers := installationInformers.NewSharedInformerFactory(fakeClient, time.Second*0) + mockStatusManager := statusmanager.NewKymaStatusManager(fakeClient, informers.Installer().V1alpha1().Installations().Lister()) + + mockCommandExecutor := newKymaCommandExecutor(). + expectNoCalls() + + mockKymaPackage := &mockKymaPackageClientForDownload{} + kymaTestSteps := New(nil, nil, nil, *TestChartDir, mockStatusManager, nil, mockCommandExecutor, mockKymaPackage) + + err := kymaTestSteps.DownloadKyma(testInst) + + So(mockCommandExecutor.TimesMockCommandExecutorCalled, ShouldEqual, 0) + So(err, ShouldNotBeNil) + }) + + }) +} + +type mockKymaPackageClientForLocal struct { +} + +// NeedDownload . +func (kymaPackageClient *mockKymaPackageClientForLocal) NeedDownload(kymaPath string) bool { + So(kymaPath, ShouldEqual, "./test-kyma") + + return false +} + +// CreateDir . +func (kymaPackageClient *mockKymaPackageClientForLocal) CreateDir(kymaPath string) error { + panic("CreateDir shouldn't be called!") +} + +// RemoveDir . +func (kymaPackageClient *mockKymaPackageClientForLocal) RemoveDir(kymaPath string) error { + So(kymaPath, ShouldEqual, "./test-kyma") + + return nil +} + +type mockKymaPackageClientForDownload struct { +} + +// NeedDownload . +func (kymaPackageClient *mockKymaPackageClientForDownload) NeedDownload(kymaPath string) bool { + So(kymaPath, ShouldEqual, "./test-kyma") + + return true +} + +// CreateDir . +func (kymaPackageClient *mockKymaPackageClientForDownload) CreateDir(kymaPath string) error { + So(kymaPath, ShouldEqual, "./test-kyma") + + return nil +} + +// RemoveDir . +func (kymaPackageClient *mockKymaPackageClientForDownload) RemoveDir(kymaPath string) error { + So(kymaPath, ShouldEqual, "./test-kyma") + + return nil +} + +type mockCommandExecutor struct { + TimesMockCommandExecutorCalled int + commands []command + noCallsExpected bool +} + +func newKymaCommandExecutor() *mockCommandExecutor { + return &mockCommandExecutor{} +} + +func (kymaCommandExecutor *mockCommandExecutor) pushCommand(execPath string, execArgs ...string) *mockCommandExecutor { + command := &command{ + execPath: execPath, + execArgs: execArgs, + } + kymaCommandExecutor.commands = append(kymaCommandExecutor.commands, *command) + return kymaCommandExecutor +} + +func (kymaCommandExecutor *mockCommandExecutor) popCommand() command { + var cmd command + cmd, kymaCommandExecutor.commands = kymaCommandExecutor.commands[0], kymaCommandExecutor.commands[1:] + + return cmd +} + +//RunCommand . +func (kymaCommandExecutor *mockCommandExecutor) RunCommand(execPath string, execArgs ...string) error { + if kymaCommandExecutor.noCallsExpected { + panic("Shouldn't be called!") + } + + cmd := kymaCommandExecutor.popCommand() + + So(execPath, ShouldEqual, cmd.execPath) + So(strings.Join(execArgs, ""), ShouldEqual, strings.Join(cmd.execArgs, "")) + + kymaCommandExecutor.TimesMockCommandExecutorCalled++ + + return nil +} + +//RunBashCommand . +func (kymaCommandExecutor *mockCommandExecutor) RunBashCommand(execPath string, execArgs ...string) error { + panic("RunBashCommand shouldn't be called!") +} + +func (kymaCommandExecutor *mockCommandExecutor) expectNoCalls() *mockCommandExecutor { + kymaCommandExecutor.noCallsExpected = true + return kymaCommandExecutor +} + +type command struct { + execPath string + execArgs []string +} diff --git a/components/installer/pkg/steps/installation.go b/components/installer/pkg/steps/installation.go new file mode 100644 index 000000000000..d0df5bd44de6 --- /dev/null +++ b/components/installer/pkg/steps/installation.go @@ -0,0 +1,237 @@ +package steps + +import ( + "log" + "path" + + actionmanager "github.com/kyma-project/kyma/components/installer/pkg/actionmanager" + "github.com/kyma-project/kyma/components/installer/pkg/config" + internalerrors "github.com/kyma-project/kyma/components/installer/pkg/errors" + "github.com/kyma-project/kyma/components/installer/pkg/kymahelm" + "github.com/kyma-project/kyma/components/installer/pkg/overrides" + serviceCatalog "github.com/kyma-project/kyma/components/installer/pkg/servicecatalog" + statusmanager "github.com/kyma-project/kyma/components/installer/pkg/statusmanager" + "github.com/kyma-project/kyma/components/installer/pkg/toolkit" + "k8s.io/client-go/kubernetes" +) + +//InstallationSteps . +type InstallationSteps struct { + helmClient kymahelm.ClientInterface + kubeClientset *kubernetes.Clientset + serviceCatalog serviceCatalog.ClientInterface + errorHandlers internalerrors.ErrorHandlersInterface + chartDir string + statusManager statusmanager.StatusManager + actionManager actionmanager.ActionManager + kymaCommandExecutor toolkit.CommandExecutor + kymaPath string + kymaPackageClient KymaPackageInterface +} + +// New . +func New(helmClient kymahelm.ClientInterface, kubeClientset *kubernetes.Clientset, serviceCatalog serviceCatalog.ClientInterface, kymaDir string, statusManager statusmanager.StatusManager, actionManager actionmanager.ActionManager, kymaCommandExecutor toolkit.CommandExecutor, kymaPackageClient KymaPackageInterface) *InstallationSteps { + steps := &InstallationSteps{ + helmClient: helmClient, + kubeClientset: kubeClientset, + serviceCatalog: serviceCatalog, + errorHandlers: &internalerrors.ErrorHandlers{}, + chartDir: path.Join(kymaDir, "resources"), + statusManager: statusManager, + actionManager: actionManager, + kymaCommandExecutor: kymaCommandExecutor, + kymaPath: kymaDir, + kymaPackageClient: kymaPackageClient, + } + + return steps +} + +//InstallKyma . +func (steps *InstallationSteps) InstallKyma(installationData *config.InstallationData) error { + downloadKymaErr := steps.DownloadKyma(installationData) + if downloadKymaErr != nil { + return downloadKymaErr + } + + instErr := steps.InstallClusterPrerequisites(installationData) + if instErr != nil { + return instErr + } + + instErr = steps.InstallTiller(installationData) + if instErr != nil { + return instErr + } + + instErr = steps.InstallClusterEssentials(installationData) + if instErr != nil { + return instErr + } + + instErr = steps.InstallIstio(installationData) + if instErr != nil { + return instErr + } + + instErr = steps.InstallPrometheus(installationData) + if instErr != nil { + return instErr + } + + bundlesErr := steps.ProvisionBundles(installationData) + if bundlesErr != nil { + return bundlesErr + } + + dexErr := steps.InstallDex(installationData) + if dexErr != nil { + return dexErr + } + + instErr = steps.InstallCore(installationData) + if instErr != nil { + return instErr + } + + upgradeErr := steps.UpgradeCore(installationData) + if upgradeErr != nil { + return upgradeErr + } + + instErr = steps.InstallHmcDefaultRemoteEnvironments(installationData) + if instErr != nil { + return instErr + } + + instErr = steps.InstallEcDefaultRemoteEnvironments(installationData) + if instErr != nil { + return instErr + } + + instErr = steps.RemoveKymaSources(installationData) + if instErr != nil { + return instErr + } + + err := steps.actionManager.RemoveActionLabel(installationData.Context.Name, installationData.Context.Namespace, "action") + if steps.errorHandlers.CheckError("Error on removing label: ", err) { + return err + } + + err = steps.statusManager.InstallDone(installationData.URL, installationData.KymaVersion) + if err != nil { + return err + } + + return nil +} + +//UpdateKyma . +func (steps *InstallationSteps) UpdateKyma(installationData *config.InstallationData) error { + downloadKymaErr := steps.DownloadKyma(installationData) + if downloadKymaErr != nil { + return downloadKymaErr + } + + upgradeErr := steps.UpdateClusterPrerequisites(installationData) + if upgradeErr != nil { + return upgradeErr + } + + upgradeErr = steps.UpdateTiller(installationData) + if upgradeErr != nil { + return upgradeErr + } + + upgradeErr = steps.UpdateClusterEssentials(installationData) + if upgradeErr != nil { + return upgradeErr + } + + upgradeErr = steps.UpdateIstio(installationData) + if upgradeErr != nil { + return upgradeErr + } + + upgradeErr = steps.UpdatePrometheus(installationData) + if upgradeErr != nil { + return upgradeErr + } + + bundlesErr := steps.UpdateBundles(installationData) + if bundlesErr != nil { + return bundlesErr + } + + dexErr := steps.UpdateDex(installationData) + if dexErr != nil { + return dexErr + } + + upgradeErr = steps.UpgradeCore(installationData) + if upgradeErr != nil { + return upgradeErr + } + + upgradeErr = steps.UpdateHmcDefaultRemoteEnvironments(installationData) + if upgradeErr != nil { + return upgradeErr + } + + upgradeErr = steps.UpdateEcDefaultRemoteEnvironments(installationData) + if upgradeErr != nil { + return upgradeErr + } + + upgradeErr = steps.RemoveKymaSources(installationData) + if upgradeErr != nil { + return upgradeErr + } + + err := steps.actionManager.RemoveActionLabel(installationData.Context.Name, installationData.Context.Namespace, "action") + if steps.errorHandlers.CheckError("Error on removing label: ", err) { + return err + } + + err = steps.statusManager.UpdateDone(installationData.URL, installationData.KymaVersion) + if err != nil { + return err + } + + return nil +} + +//UninstallKyma . +func (steps *InstallationSteps) UninstallKyma(installationData *config.InstallationData) error { + err := steps.DeprovisionAzureResources(DefaultDeprovisionConfig(), installationData.Context) + steps.errorHandlers.LogError("An error during deprovisioning: ", err) + steps.RemoveKymaComponents() + + err = steps.actionManager.RemoveActionLabel(installationData.Context.Name, installationData.Context.Namespace, "action") + if steps.errorHandlers.CheckError("Error on removing label: ", err) { + return err + } + + err = steps.statusManager.UninstallDone() + if err != nil { + return err + } + + return nil +} + +//PrintInstallationStep . +func (steps *InstallationSteps) PrintInstallationStep(stepName string) { + log.Println("---------------------------") + log.Println(stepName) + log.Println("---------------------------") +} + +func (steps *InstallationSteps) getStaticFileOverrides(installationData *config.InstallationData, chartDir string) overrides.StaticFile { + if installationData.IsLocalInstallation() == true { + return overrides.NewLocalStaticFile() + } + + return overrides.NewClusterStaticFile(chartDir) +} diff --git a/components/installer/pkg/steps/istio.go b/components/installer/pkg/steps/istio.go new file mode 100644 index 000000000000..e0501d235406 --- /dev/null +++ b/components/installer/pkg/steps/istio.go @@ -0,0 +1,85 @@ +package steps + +import ( + "log" + "path" + "strings" + + "github.com/kyma-project/kyma/components/installer/pkg/config" + "github.com/kyma-project/kyma/components/installer/pkg/overrides" +) + +// InstallIstio . +func (steps *InstallationSteps) InstallIstio(installationData *config.InstallationData) error { + const stepName string = "Installing istio" + steps.PrintInstallationStep(stepName) + steps.statusManager.InProgress(stepName) + + releaseName := "istio" + chartDir := path.Join(steps.chartDir, releaseName, "istio") + overrides := steps.getIstioOverrides(installationData, chartDir) + + //helm install + installResp, installErr := steps.helmClient.InstallReleaseWithoutWait( + chartDir, + "istio-system", + releaseName, + overrides) + + if steps.errorHandlers.CheckError("Install Error: ", installErr) { + steps.statusManager.Error(stepName) + return installErr + } + + steps.helmClient.PrintRelease(installResp.Release) + log.Println(stepName + "...DONE") + + return nil +} + +// UpdateIstio . +func (steps *InstallationSteps) UpdateIstio(installationData *config.InstallationData) error { + const stepName string = "Updating istio" + steps.PrintInstallationStep(stepName) + steps.statusManager.InProgress(stepName) + + releaseName := "istio" + chartDir := path.Join(steps.chartDir, releaseName, "istio") + overrides := steps.getIstioOverrides(installationData, chartDir) + + upgradeResp, upgradeErr := steps.helmClient.UpgradeRelease( + chartDir, + releaseName, + overrides) + + if steps.errorHandlers.CheckError("Upgrade Error: ", upgradeErr) { + steps.statusManager.Error("Updating istio") + return upgradeErr + } + + steps.helmClient.PrintRelease(upgradeResp.Release) + log.Println(stepName + "...DONE") + + return nil +} + +func (steps *InstallationSteps) getIstioOverrides(installationData *config.InstallationData, chartDir string) string { + var allOverrides []string + + globalOverrides, err := overrides.GetGlobalOverrides(installationData) + steps.errorHandlers.LogError("Couldn't get global overrides: ", err) + allOverrides = append(allOverrides, globalOverrides) + + istioOverrides, err := overrides.GetIstioOverrides(installationData) + steps.errorHandlers.LogError("Couldn't get Istio overrides: ", err) + allOverrides = append(allOverrides, istioOverrides) + + fileOverrides := steps.getStaticFileOverrides(installationData, chartDir) + if fileOverrides.HasOverrides() == true { + fileOverridesStr, err := fileOverrides.GetOverrides() + steps.errorHandlers.LogError("Couldn't get additional overrides: ", err) + allOverrides = append(allOverrides, *fileOverridesStr) + } + + return strings.Join(allOverrides, "\n") +} diff --git a/components/installer/pkg/steps/istio_test.go b/components/installer/pkg/steps/istio_test.go new file mode 100644 index 000000000000..2115734ef932 --- /dev/null +++ b/components/installer/pkg/steps/istio_test.go @@ -0,0 +1,45 @@ +package steps + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestInstallIstio(t *testing.T) { + + Convey("InstallIstio", t, func() { + + Convey("In case no error occurs, should", func() { + + kymaTestSteps, installationData, mockHelmClient, mockCommandExecutor := getTestSetup() + + Convey("call UpdateInstallationStatus once, call RunCommand once, call InstallReleaseWithoutWait, call RunCommand again and return no error", func() { + + err := kymaTestSteps.InstallIstio(installationData) + + So(mockHelmClient.InstallReleaseWithoutWaitCalled, ShouldBeTrue) + // We no longer execute shell commands during istio installation + So(mockCommandExecutor.TimesMockCommandExecutorCalled, ShouldEqual, 0) + So(err, ShouldBeNil) + }) + + }) + + Convey("in case InstallRelease error occurs", func() { + + kymaTestSteps, installationData, mockErrorHelmClient, _ := getFailingTestSetup() + mockCommandExecutor := &MockCommandExecutor{} + + Convey("call UpdateInstallationStatus once, call RunCommand once, call InstallReleaseWithoutWait, call UpdateInstallationStatus again and return the error", func() { + + err := kymaTestSteps.InstallIstio(installationData) + + // We no longer execute shell commands during istio installation + So(mockCommandExecutor.TimesMockCommandExecutorCalled, ShouldEqual, 0) + So(mockErrorHelmClient.InstallReleaseWithoutWaitCalled, ShouldBeTrue) + So(err, ShouldNotBeNil) + }) + }) + }) +} diff --git a/components/installer/pkg/steps/kyma-package.go b/components/installer/pkg/steps/kyma-package.go new file mode 100644 index 000000000000..c0238e99540f --- /dev/null +++ b/components/installer/pkg/steps/kyma-package.go @@ -0,0 +1,30 @@ +package steps + +import "os" + +// KymaPackageInterface . +type KymaPackageInterface interface { + CreateDir(kymaPath string) error + NeedDownload(kymaPath string) bool + RemoveDir(kymaPath string) error +} + +// KymaPackageClient . +type KymaPackageClient struct { +} + +// NeedDownload . +func (kymaPackageClient *KymaPackageClient) NeedDownload(kymaPath string) bool { + _, err := os.Stat(kymaPath) + return os.IsNotExist(err) +} + +// CreateDir . +func (kymaPackageClient *KymaPackageClient) CreateDir(kymaPath string) error { + return os.MkdirAll(kymaPath, os.ModePerm|os.ModeDir) +} + +// RemoveDir . +func (kymaPackageClient *KymaPackageClient) RemoveDir(kymaPath string) error { + return os.RemoveAll(kymaPath) +} diff --git a/components/installer/pkg/steps/mock.go b/components/installer/pkg/steps/mock.go new file mode 100644 index 000000000000..e039268da597 --- /dev/null +++ b/components/installer/pkg/steps/mock.go @@ -0,0 +1,188 @@ +package steps + +import ( + "errors" + "flag" + "time" + + "github.com/kyma-project/kyma/components/installer/pkg/config" + + "github.com/kyma-project/kyma/components/installer/pkg/client/clientset/versioned/fake" + "github.com/kyma-project/kyma/components/installer/pkg/statusmanager" + "k8s.io/helm/pkg/proto/hapi/release" + + installationInformers "github.com/kyma-project/kyma/components/installer/pkg/client/informers/externalversions" + "github.com/kyma-project/kyma/components/installer/pkg/kymahelm" + "github.com/kyma-project/kyma/components/installer/pkg/toolkit" + + rls "k8s.io/helm/pkg/proto/hapi/services" +) + +//TestChartDir is a mock directory for tests +var TestChartDir = flag.String("testchartdir", "./test-kyma", "Test chart directory") + +//MockHelmClient is a fake helm client that returns no errors +type MockHelmClient struct { + InstallReleaseCalled bool + InstallReleaseWithoutWaitCalled bool + UpgradeReleaseCalled bool +} + +//InstallRelease mocks a call to helm client's InstallRelease function +func (mhc *MockHelmClient) InstallRelease(chartdir, ns, releasename, overrides string) (*rls.InstallReleaseResponse, error) { + mhc.InstallReleaseCalled = true + mockResponse := &rls.InstallReleaseResponse{} + return mockResponse, nil +} + +//InstallReleaseWithoutWait mocks a call to helm client's InstallReleaseWithoutWait function +func (mhc *MockHelmClient) InstallReleaseWithoutWait(chartdir, ns, releasename, overrides string) (*rls.InstallReleaseResponse, error) { + mhc.InstallReleaseWithoutWaitCalled = true + mockResponse := &rls.InstallReleaseResponse{} + return mockResponse, nil +} + +//UpgradeRelease mocks a call to helm client's UpgradeRelease function +func (mhc *MockHelmClient) UpgradeRelease(chartDir, releaseName, overrides string) (*rls.UpdateReleaseResponse, error) { + mhc.UpgradeReleaseCalled = true + mockResponse := &rls.UpdateReleaseResponse{} + return mockResponse, nil +} + +//ListReleases mocks a call to helm client's ListRelease function +func (mhc *MockHelmClient) ListReleases() (*rls.ListReleasesResponse, error) { return nil, nil } + +//ReleaseStatus mocks a call to helm client's ReleaseStatus function +func (mhc *MockHelmClient) ReleaseStatus(rname string) (string, error) { return "", nil } + +//InstallReleaseFromChart mocks a call to helm client's InstallReleaseFromChart function +func (mhc *MockHelmClient) InstallReleaseFromChart(chartdir, ns, releaseName, overrides string) (*rls.InstallReleaseResponse, error) { + return nil, nil +} + +//DeleteRelease mocks a call to helm client's DeleteRelease function +func (mhc *MockHelmClient) DeleteRelease(releaseName string) (*rls.UninstallReleaseResponse, error) { + return nil, nil +} + +// PrintRelease mocks a call to helm client's PrintRelease function +func (mhc *MockHelmClient) PrintRelease(release *release.Release) {} + +//MockErrorHelmClient is a fake helm client that always returns an error +type MockErrorHelmClient struct { + InstallReleaseCalled bool + InstallReleaseWithoutWaitCalled bool + UpgradeReleaseCalled bool + ReleaseStatusCalled bool +} + +//InstallRelease mocks a call to helm client's InstallRelease function +func (mehc *MockErrorHelmClient) InstallRelease(chartdir, ns, releasename, overrides string) (*rls.InstallReleaseResponse, error) { + mehc.InstallReleaseCalled = true + err := errors.New("InstallRelease test error") + return nil, err +} + +//InstallReleaseWithoutWait mocks a call to helm client's InstallReleaseWithoutWait function +func (mehc *MockErrorHelmClient) InstallReleaseWithoutWait(chartdir, ns, releasename, overrides string) (*rls.InstallReleaseResponse, error) { + mehc.InstallReleaseWithoutWaitCalled = true + err := errors.New("InstallReleaseWithoutWait test error") + return nil, err +} + +//UpgradeRelease mocks a call to helm client's UpgradeRelease function +func (mehc *MockErrorHelmClient) UpgradeRelease(chartDir, releaseName, overrides string) (*rls.UpdateReleaseResponse, error) { + mehc.UpgradeReleaseCalled = true + err := errors.New("UpgradeRelease test error") + return nil, err +} + +//ReleaseStatus mocks a call to helm client's ReleaseStatus function +func (mehc *MockErrorHelmClient) ReleaseStatus(rname string) (string, error) { + mehc.ReleaseStatusCalled = true + return "Release test info", nil +} + +//ListReleases mocks a call to helm client's ListRelease function +func (mehc *MockErrorHelmClient) ListReleases() (*rls.ListReleasesResponse, error) { return nil, nil } + +//InstallReleaseFromChart mocks a call to helm client's InstallReleaseFromChart function +func (mehc *MockErrorHelmClient) InstallReleaseFromChart(chartdir, ns, releaseName, overrides string) (*rls.InstallReleaseResponse, error) { + return nil, nil +} + +//DeleteRelease mocks a call to helm client's DeleteRelease function +func (mehc *MockErrorHelmClient) DeleteRelease(releaseName string) (*rls.UninstallReleaseResponse, error) { + return nil, nil +} + +// PrintRelease mocks a call to helm client's PrintRelease function +func (mehc *MockErrorHelmClient) PrintRelease(release *release.Release) {} + +//MockCommandExecutor . +type MockCommandExecutor struct { + TimesMockCommandExecutorCalled int + TimesMockBashCommandExecutorCalled int +} + +//RunCommand . +func (kymaCommandExecutor *MockCommandExecutor) RunCommand(execPath string, execArgs ...string) error { + kymaCommandExecutor.TimesMockCommandExecutorCalled++ + return nil +} + +//RunBashCommand . +func (kymaCommandExecutor *MockCommandExecutor) RunBashCommand(scriptPath string, execArgs ...string) error { + kymaCommandExecutor.TimesMockBashCommandExecutorCalled++ + return nil +} + +//MockFailingCommandExecutor . +type MockFailingCommandExecutor struct { + MockFailingCommandExecutorCalled bool + MockFailingBashCommandExecutorCalled bool +} + +//RunCommand . +func (kymaFailingCommandExecutor *MockFailingCommandExecutor) RunCommand(execPath string, execArgs ...string) error { + kymaFailingCommandExecutor.MockFailingCommandExecutorCalled = true + err := errors.New("RunCommand test error") + return err +} + +//RunBashCommand . +func (kymaFailingCommandExecutor *MockFailingCommandExecutor) RunBashCommand(scriptPath string, execArgs ...string) error { + kymaFailingCommandExecutor.MockFailingBashCommandExecutorCalled = true + err := errors.New("RunBashCommand test error") + return err +} + +func getTestSetup() (*InstallationSteps, *config.InstallationData, *MockHelmClient, *MockCommandExecutor) { + + mockCommandExecutor := &MockCommandExecutor{} + mockHelmClient := &MockHelmClient{} + installationData, kymaTestSteps := getCommonTestSetup(mockHelmClient, mockCommandExecutor) + + return kymaTestSteps, installationData, mockHelmClient, mockCommandExecutor +} + +func getFailingTestSetup() (*InstallationSteps, *config.InstallationData, *MockErrorHelmClient, *MockFailingCommandExecutor) { + + mockFailingCommandExecutor := &MockFailingCommandExecutor{} + mockErrorHelmClient := &MockErrorHelmClient{} + installationData, kymaTestSteps := getCommonTestSetup(mockErrorHelmClient, mockFailingCommandExecutor) + + return kymaTestSteps, installationData, mockErrorHelmClient, mockFailingCommandExecutor +} + +func getCommonTestSetup(mockHelmClient kymahelm.ClientInterface, mockCommandExecutor toolkit.CommandExecutor) (*config.InstallationData, *InstallationSteps) { + + fakeClient := fake.NewSimpleClientset() + informers := installationInformers.NewSharedInformerFactory(fakeClient, time.Second*0) + mockStatusManager := statusmanager.NewKymaStatusManager(fakeClient, informers.Installer().V1alpha1().Installations().Lister()) + + installationData := toolkit.NewInstallationDataCreator().GetData() + kymaTestSteps := New(mockHelmClient, nil, nil, *TestChartDir, mockStatusManager, nil, mockCommandExecutor, nil) + + return &installationData, kymaTestSteps +} diff --git a/components/installer/pkg/steps/prometheus.go b/components/installer/pkg/steps/prometheus.go new file mode 100644 index 000000000000..c40d272096ff --- /dev/null +++ b/components/installer/pkg/steps/prometheus.go @@ -0,0 +1,78 @@ +package steps + +import ( + "log" + "path" + "strings" + + "github.com/kyma-project/kyma/components/installer/pkg/config" +) + +//InstallPrometheus . +func (steps *InstallationSteps) InstallPrometheus(installationData *config.InstallationData) error { + const stepName string = "Installing Prometheus operator" + const namespace = "kyma-system" + + releaseName := "prometheus-operator" + chartDir := path.Join(steps.chartDir, releaseName) + overrides := steps.getPrometheusOverrides(installationData, chartDir) + + steps.PrintInstallationStep(stepName) + steps.statusManager.InProgress(stepName) + + installResp, installErr := steps.helmClient.InstallRelease( + chartDir, + namespace, + releaseName, + overrides) + + if steps.errorHandlers.CheckError("Install Error: ", installErr) { + steps.statusManager.Error(stepName) + return installErr + } + + steps.helmClient.PrintRelease(installResp.Release) + log.Println(stepName + "...DONE") + + return nil +} + +//UpdatePrometheus . +func (steps *InstallationSteps) UpdatePrometheus(installationData *config.InstallationData) error { + const stepName string = "Updating Prometheus operator" + const namespace = "kyma-system" + + releaseName := "prometheus-operator" + chartDir := path.Join(steps.chartDir, releaseName) + overrides := steps.getPrometheusOverrides(installationData, chartDir) + + steps.PrintInstallationStep(stepName) + steps.statusManager.InProgress(stepName) + + upgradeResp, upgradeErr := steps.helmClient.UpgradeRelease( + chartDir, + releaseName, + overrides) + + if steps.errorHandlers.CheckError("Upgrade Error: ", upgradeErr) { + steps.statusManager.Error(stepName) + return upgradeErr + } + + steps.helmClient.PrintRelease(upgradeResp.Release) + log.Println(stepName + "...DONE") + + return nil +} +func (steps *InstallationSteps) getPrometheusOverrides(installationData *config.InstallationData, chartDir string) string { + var allOverrides []string + + fileOverrides := steps.getStaticFileOverrides(installationData, chartDir) + if fileOverrides.HasOverrides() == true { + fileOverridesStr, err := fileOverrides.GetOverrides() + steps.errorHandlers.LogError("Couldn't get additional overrides: ", err) + allOverrides = append(allOverrides, *fileOverridesStr) + } + + return strings.Join(allOverrides, "\n") +} diff --git a/components/installer/pkg/steps/prometheus_test.go b/components/installer/pkg/steps/prometheus_test.go new file mode 100644 index 000000000000..1c4405989945 --- /dev/null +++ b/components/installer/pkg/steps/prometheus_test.go @@ -0,0 +1,39 @@ +package steps + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestInstallPrometheus(t *testing.T) { + + Convey("InstallPrometheus function", t, func() { + + Convey("In case no error occurs, should", func() { + + kymaTestSteps, installationData, mockHelmClient, _ := getTestSetup() + + Convey("call UpdateInstallationStatus once and call InstallRelease returning no error", func() { + + err := kymaTestSteps.InstallPrometheus(installationData) + + So(mockHelmClient.InstallReleaseCalled, ShouldBeTrue) + So(err, ShouldBeNil) + }) + }) + + Convey("In case helm client error occurs, should", func() { + + kymaTestSteps, installationData, mockErrorHelmClient, _ := getFailingTestSetup() + + Convey("call UpdateInstallationStatus once, call InstallRelease, call UpdateInstallationStatus again and return the error", func() { + + err := kymaTestSteps.InstallPrometheus(installationData) + + So(mockErrorHelmClient.InstallReleaseCalled, ShouldBeTrue) + So(err, ShouldNotBeNil) + }) + }) + }) +} diff --git a/components/installer/pkg/steps/provision-bundles.go b/components/installer/pkg/steps/provision-bundles.go new file mode 100644 index 000000000000..d708e2604352 --- /dev/null +++ b/components/installer/pkg/steps/provision-bundles.go @@ -0,0 +1,49 @@ +package steps + +import ( + "log" + "path" + + "github.com/kyma-project/kyma/components/installer/pkg/config" + "github.com/kyma-project/kyma/components/installer/pkg/toolkit" +) + +// ProvisionBundles . +func (steps InstallationSteps) ProvisionBundles(installationData *config.InstallationData) error { + const stepName string = "Provisioning helm broker repo bundles" + steps.PrintInstallationStep(stepName) + steps.statusManager.InProgress(stepName) + + helmBrokerScriptPath := path.Join(steps.chartDir, "helm-broker-repo/install.sh") + + err := steps.kymaCommandExecutor.RunCommand("/bin/bash", helmBrokerScriptPath) + + if steps.errorHandlers.CheckError("Script Error: ", err) { + steps.statusManager.Error(stepName) + return err + } + + log.Println(stepName + "...DONE") + + return nil +} + +// UpdateBundles . +func (steps InstallationSteps) UpdateBundles(installationData *config.InstallationData) error { + const stepName string = "Updating helm broker repo bundles" + steps.PrintInstallationStep(stepName) + steps.statusManager.InProgress(stepName) + + helmBrokerScriptPath := path.Join(steps.chartDir, "helm-broker-repo/update.sh") + + err := steps.kymaCommandExecutor.RunBashCommand(helmBrokerScriptPath, toolkit.EmptyArgs) + + if steps.errorHandlers.CheckError("Script Error: ", err) { + steps.statusManager.Error(stepName) + return err + } + + log.Println(stepName + "...DONE") + + return nil +} diff --git a/components/installer/pkg/steps/remote-environment.go b/components/installer/pkg/steps/remote-environment.go new file mode 100644 index 000000000000..07b30de02a98 --- /dev/null +++ b/components/installer/pkg/steps/remote-environment.go @@ -0,0 +1,170 @@ +package steps + +import ( + "log" + "path" + "strings" + + "github.com/kyma-project/kyma/components/installer/pkg/config" + "github.com/kyma-project/kyma/components/installer/pkg/overrides" +) + +//InstallHmcDefaultRemoteEnvironments function will install Hmc Remote Environments +func (steps *InstallationSteps) InstallHmcDefaultRemoteEnvironments(installationData *config.InstallationData) error { + const stepName string = "Installing Hmc Remote Environment" + steps.PrintInstallationStep(stepName) + + steps.statusManager.InProgress(stepName) + releaseName := "hmc-default" + chartDir := path.Join(steps.chartDir, "remote-environments") + hmcOverrides := steps.getHmcOverrides(installationData, chartDir) + + installErr := steps.installRemoteEnvironment(releaseName, chartDir, hmcOverrides) + + if steps.errorHandlers.CheckError("Install Error: ", installErr) { + steps.statusManager.Error(stepName) + return installErr + } + + log.Println(stepName + "...DONE") + + return nil +} + +//UpdateHmcDefaultRemoteEnvironments function will install Hmc Remote Environments +func (steps *InstallationSteps) UpdateHmcDefaultRemoteEnvironments(installationData *config.InstallationData) error { + const stepName string = "Updating Hmc Remote Environment" + steps.PrintInstallationStep(stepName) + + steps.statusManager.InProgress(stepName) + + releaseName := "hmc-default" + chartDir := path.Join(steps.chartDir, "remote-environments") + hmcOverrides := steps.getHmcOverrides(installationData, chartDir) + + upgradeErr := steps.updateRemoteEnvironment(releaseName, chartDir, hmcOverrides) + + if steps.errorHandlers.CheckError("Update Error: ", upgradeErr) { + steps.statusManager.Error(stepName) + return upgradeErr + } + + log.Println(stepName + "...DONE") + + return nil +} + +//InstallEcDefaultRemoteEnvironments function will install EC Remote Environments +func (steps *InstallationSteps) InstallEcDefaultRemoteEnvironments(installationData *config.InstallationData) error { + const stepName string = "Installing Ec Remote Environment" + steps.PrintInstallationStep(stepName) + + steps.statusManager.InProgress(stepName) + releaseName := "ec-default" + chartDir := path.Join(steps.chartDir, "remote-environments") + ecOverrides := steps.getEcOverrides(installationData, chartDir) + + installErr := steps.installRemoteEnvironment(releaseName, chartDir, ecOverrides) + + if steps.errorHandlers.CheckError("Install Error: ", installErr) { + steps.statusManager.Error(stepName) + return installErr + } + + log.Println(stepName + "...DONE") + + return nil +} + +//UpdateEcDefaultRemoteEnvironments function will install EC Remote Environments +func (steps *InstallationSteps) UpdateEcDefaultRemoteEnvironments(installationData *config.InstallationData) error { + const stepName string = "Updating Ec Remote Environment" + steps.PrintInstallationStep(stepName) + + steps.statusManager.InProgress(stepName) + releaseName := "ec-default" + chartDir := path.Join(steps.chartDir, "remote-environments") + ecOverrides := steps.getEcOverrides(installationData, chartDir) + + upgradeErr := steps.updateRemoteEnvironment(releaseName, chartDir, ecOverrides) + + if steps.errorHandlers.CheckError("Update Error: ", upgradeErr) { + steps.statusManager.Error(stepName) + return upgradeErr + } + + log.Println(stepName + "...DONE") + + return nil +} + +func (steps *InstallationSteps) getHmcOverrides(installationData *config.InstallationData, chartDir string) string { + var allOverrides []string + + globalOverrides, err := overrides.GetGlobalOverrides(installationData) + steps.errorHandlers.LogError("Couldn't get global overrides: ", err) + allOverrides = append(allOverrides, globalOverrides) + + hmcDefaultOverride := overrides.GetHmcDefaultOverrides() + steps.errorHandlers.LogError("Couldn't get Hmc default overrides: ", err) + allOverrides = append(allOverrides, hmcDefaultOverride) + + fileOverrides := steps.getStaticFileOverrides(installationData, chartDir) + if fileOverrides.HasOverrides() == true { + fileOverridesStr, err := fileOverrides.GetOverrides() + steps.errorHandlers.LogError("Couldn't get additional overrides: ", err) + allOverrides = append(allOverrides, *fileOverridesStr) + } + + return strings.Join(allOverrides, "\n") +} + +func (steps *InstallationSteps) getEcOverrides(installationData *config.InstallationData, chartDir string) string { + var allOverrides []string + + globalOverrides, err := overrides.GetGlobalOverrides(installationData) + steps.errorHandlers.LogError("Couldn't get global overrides: ", err) + allOverrides = append(allOverrides, globalOverrides) + + ecDefaultOverride := overrides.GetEcDefaultOverrides() + steps.errorHandlers.LogError("Couldn't get Ec default overrides: ", err) + allOverrides = append(allOverrides, ecDefaultOverride) + + fileOverrides := steps.getStaticFileOverrides(installationData, chartDir) + if fileOverrides.HasOverrides() == true { + fileOverridesStr, err := fileOverrides.GetOverrides() + steps.errorHandlers.LogError("Couldn't get additional overrides: ", err) + allOverrides = append(allOverrides, *fileOverridesStr) + } + + return strings.Join(allOverrides, "\n") +} + +func (steps *InstallationSteps) installRemoteEnvironment(installationName, chartDir, overrides string) error { + installResp, installErr := steps.helmClient.InstallRelease( + chartDir, + "kyma-integration", + installationName, + overrides) + + if installErr != nil { + return installErr + } + + steps.helmClient.PrintRelease(installResp.Release) + return nil +} + +func (steps *InstallationSteps) updateRemoteEnvironment(installationName, chartDir, overrides string) error { + upgradeResp, upgradeErr := steps.helmClient.UpgradeRelease( + chartDir, + installationName, + overrides) + + if upgradeErr != nil { + return upgradeErr + } + + steps.helmClient.PrintRelease(upgradeResp.Release) + return nil +} diff --git a/components/installer/pkg/steps/remove-kyma-components.go b/components/installer/pkg/steps/remove-kyma-components.go new file mode 100644 index 000000000000..5d8f67d42d32 --- /dev/null +++ b/components/installer/pkg/steps/remove-kyma-components.go @@ -0,0 +1,57 @@ +package steps + +import ( + "log" +) + +//RemoveKymaComponents . +func (steps InstallationSteps) RemoveKymaComponents() { + + log.Println("Removing Kyma resources...") + + if err := steps.uninstallReleases(); err != nil { + log.Println("Error. Some releases may have not been removed.") + return + } + + if err := steps.removeNamespaces(); err != nil { + log.Println("Error. Some namespaces may not have been removed") + return + } + + log.Println("All Kyma resources have been successfully removed") +} + +func (steps InstallationSteps) uninstallReleases() error { + log.Println("Uninstalling releases...") + releasesToBeDeleted := []string{"ec-default", "hmc-default", "dex", "core", "prometheus-operator", "istio", "cluster-essentials"} + + for _, releaseName := range releasesToBeDeleted { + log.Println("Uninstalling release", releaseName) + uninstallReleaseResponse, uninstallError := steps.helmClient.DeleteRelease(releaseName) + if steps.errorHandlers.CheckError("Uninstall Error: ", uninstallError) { + return uninstallError + } + steps.helmClient.PrintRelease(uninstallReleaseResponse.Release) + } + + log.Println("All releases have been successfully uninstalled!") + return nil +} + +func (steps InstallationSteps) removeNamespaces() error { + log.Println("Removing namespaces") + namespaceManager := steps.kubeClientset.CoreV1().Namespaces() + namespacesToBeDeleted := []string{"kyma-integration", "kyma-system", "istio-system"} + + for _, namespace := range namespacesToBeDeleted { + log.Println("Removing namespace", namespace) + removeError := namespaceManager.Delete(namespace, nil) + if steps.errorHandlers.CheckError("Remove namespace error: ", removeError) { + return removeError + } + log.Println("Namespace", namespace, "has been successfully removed!") + } + log.Println("All namespaces have been successfully removed!") + return nil +} diff --git a/components/installer/pkg/steps/remove-kyma-sources.go b/components/installer/pkg/steps/remove-kyma-sources.go new file mode 100644 index 000000000000..9aad253d601f --- /dev/null +++ b/components/installer/pkg/steps/remove-kyma-sources.go @@ -0,0 +1,24 @@ +package steps + +import ( + "log" + + "github.com/kyma-project/kyma/components/installer/pkg/config" +) + +//RemoveKymaSources . +func (steps *InstallationSteps) RemoveKymaSources(installationData *config.InstallationData) error { + const stepName string = "Removing Kyma sources" + steps.PrintInstallationStep(stepName) + steps.statusManager.InProgress(stepName) + + err := steps.kymaPackageClient.RemoveDir(steps.kymaPath) + + if steps.errorHandlers.CheckError("Mkdir error: ", err) { + steps.statusManager.Error(stepName) + return err + } + + log.Println(stepName + "...DONE") + return nil +} diff --git a/components/installer/pkg/steps/tiller.go b/components/installer/pkg/steps/tiller.go new file mode 100644 index 000000000000..881e4b33d714 --- /dev/null +++ b/components/installer/pkg/steps/tiller.go @@ -0,0 +1,47 @@ +package steps + +import ( + "log" + "path" + + "github.com/kyma-project/kyma/components/installer/pkg/config" + "github.com/kyma-project/kyma/components/installer/pkg/toolkit" +) + +// InstallTiller will install Tiller +func (steps InstallationSteps) InstallTiller(installationData *config.InstallationData) error { + const stepName string = "Installing Tiller" + steps.PrintInstallationStep(stepName) + + steps.statusManager.InProgress(stepName) + tillerScriptPath := path.Join(steps.chartDir, "tiller/install.sh") + err := steps.kymaCommandExecutor.RunCommand("/bin/bash", tillerScriptPath) + + if steps.errorHandlers.CheckError("Script error: ", err) { + steps.statusManager.Error(stepName) + return err + } + + log.Println(stepName + "...DONE") + + return nil +} + +// UpdateTiller will update Tiller +func (steps InstallationSteps) UpdateTiller(installationData *config.InstallationData) error { + const stepName string = "Updating Tiller" + steps.PrintInstallationStep(stepName) + + steps.statusManager.InProgress(stepName) + tillerScriptPath := path.Join(steps.chartDir, "tiller/update.sh") + err := steps.kymaCommandExecutor.RunBashCommand(tillerScriptPath, toolkit.EmptyArgs) + + if steps.errorHandlers.CheckError("Script error: ", err) { + steps.statusManager.Error(stepName) + return err + } + + log.Println(stepName + "...DONE") + + return nil +} diff --git a/components/installer/pkg/steps/tiller_test.go b/components/installer/pkg/steps/tiller_test.go new file mode 100644 index 000000000000..c08c11ffd118 --- /dev/null +++ b/components/installer/pkg/steps/tiller_test.go @@ -0,0 +1,39 @@ +package steps + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestInstallTiller(t *testing.T) { + + Convey("InstallTiller function", t, func() { + + Convey("In case no error occurs, should", func() { + + kymaTestSteps, testInst, _, mockCommandExecutor := getTestSetup() + + Convey("call UpdateInstallationStatus once and call RunCommand returning no error", func() { + + err := kymaTestSteps.InstallTiller(testInst) + + So(mockCommandExecutor.TimesMockCommandExecutorCalled, ShouldEqual, 1) + So(err, ShouldBeNil) + }) + }) + + Convey("In case an error occurs, should", func() { + + kymaTestSteps, testInst, _, mockFailingCommandExecutor := getFailingTestSetup() + + Convey("call UpdateInstallationStatus once, call RunCommand, call UpdateInstallationStatus again and return the error", func() { + + err := kymaTestSteps.InstallTiller(testInst) + + So(mockFailingCommandExecutor.MockFailingCommandExecutorCalled, ShouldBeTrue) + So(err, ShouldNotBeNil) + }) + }) + }) +} diff --git a/components/installer/pkg/toolkit/command-script-exec.go b/components/installer/pkg/toolkit/command-script-exec.go new file mode 100644 index 000000000000..5fb66f4cbcd6 --- /dev/null +++ b/components/installer/pkg/toolkit/command-script-exec.go @@ -0,0 +1,58 @@ +package toolkit + +import ( + "bytes" + "log" + "os" + "os/exec" + "strings" +) + +// CommandExecutor . +type CommandExecutor interface { + // RunCommand . + RunCommand(execPath string, execArgs ...string) error + RunBashCommand(scriptPath string, execArgs ...string) error +} + +//EmptyArgs . +const EmptyArgs string = "" + +//KymaCommandExecutor . +type KymaCommandExecutor struct { +} + +//RunCommand . +func (kymaBashExecutor *KymaCommandExecutor) RunCommand(execPath string, execArgs ...string) error { + cmd := exec.Command(execPath, execArgs...) + + var stderr bytes.Buffer + var stdout bytes.Buffer + cmd.Stderr = &stderr + cmd.Stdout = &stdout + err := cmd.Run() + + if err != nil { + errStr := string(stderr.Bytes()) + outStr := string(stdout.Bytes()) + log.Println("Bash script error") + log.Println(strings.Trim(errStr, "\n")) + log.Println("Bash script output") + log.Println(strings.Trim(outStr, "\n")) + return err + } + + return nil +} + +//RunBashCommand . +func (kymaBashExecutor *KymaCommandExecutor) RunBashCommand(scriptPath string, execArgs ...string) error { + if _, err := os.Stat(scriptPath); os.IsNotExist(err) { + log.Printf("%s doesn't exist.", scriptPath) + return nil + } + + execArgs = append([]string{scriptPath}, execArgs...) + + return kymaBashExecutor.RunCommand("/bin/bash", execArgs...) +} diff --git a/components/installer/pkg/toolkit/installdata-creator.go b/components/installer/pkg/toolkit/installdata-creator.go new file mode 100644 index 000000000000..dabb85d69a65 --- /dev/null +++ b/components/installer/pkg/toolkit/installdata-creator.go @@ -0,0 +1,117 @@ +package toolkit + +import ( + "github.com/kyma-project/kyma/components/installer/pkg/config" +) + +// InstallationDataCreator . +type InstallationDataCreator struct { + installationData config.InstallationData +} + +// NewInstallationDataCreator return new instance of InstallationDataCreator +func NewInstallationDataCreator() *InstallationDataCreator { + res := &InstallationDataCreator{ + installationData: config.InstallationData{}, + } + + return res +} + +// WithEmptyAzureCredentials sets azure credentials to empty values +func (sc *InstallationDataCreator) WithEmptyAzureCredentials() *InstallationDataCreator { + sc.installationData.AzureBrokerClientID = "" + sc.installationData.AzureBrokerClientSecret = "" + sc.installationData.AzureBrokerSubscriptionID = "" + sc.installationData.AzureBrokerTenantID = "" + + return sc +} + +// WithDummyAzureCredentials sets azure credentials in InstallationData to dummy values +func (sc *InstallationDataCreator) WithDummyAzureCredentials() *InstallationDataCreator { + sc.installationData.AzureBrokerClientID = "37bb544f-8935-4a00-a934-3999577fb637" + sc.installationData.AzureBrokerClientSecret = "ZGM3ZDlkYTgtZWMxMS00NTg4LTk5OGItOGU5YWJlNWUzYmE4DQo=" + sc.installationData.AzureBrokerSubscriptionID = "d5423a63-0ab6-4455-9efe-569c6e716625" + sc.installationData.AzureBrokerTenantID = "7ffdff3c-daa6-420d-9cff-b04769031acf" + + return sc +} + +// WithEmptyDomain sets Domain property in InstallationData to empty value +func (sc *InstallationDataCreator) WithEmptyDomain() *InstallationDataCreator { + sc.installationData.Domain = "" + + return sc +} + +// WithCert sets Cert and CertKey properties +func (sc *InstallationDataCreator) WithCert(cert, certKey string) *InstallationDataCreator { + sc.installationData.ClusterTLSCert = cert + sc.installationData.ClusterTLSKey = certKey + return sc +} + +// WithDomain sets Domain property in InstallationData +func (sc *InstallationDataCreator) WithDomain(domain string) *InstallationDataCreator { + sc.installationData.Domain = domain + + return sc +} + +// WithEmptyIP sets IP address property in InstallationData to empty value +func (sc *InstallationDataCreator) WithEmptyIP() *InstallationDataCreator { + sc.installationData.ExternalIPAddress = "" + + return sc +} + +// WithIP sets IP address in InstallationData +func (sc *InstallationDataCreator) WithIP(ipAddr string) *InstallationDataCreator { + sc.installationData.ExternalIPAddress = ipAddr + + return sc +} + +// WithRemoteEnvCa sets RemoteEnvCa property in InstallationData +func (sc *InstallationDataCreator) WithRemoteEnvCa(remoteEnvCa string) *InstallationDataCreator { + sc.installationData.RemoteEnvCa = remoteEnvCa + + return sc +} + +// WithRemoteEnvCaKey sets RemoteEnvCaKey property in InstallationData +func (sc *InstallationDataCreator) WithRemoteEnvCaKey(remoteEnvCaKey string) *InstallationDataCreator { + sc.installationData.RemoteEnvCaKey = remoteEnvCaKey + + return sc +} + +// WithRemoteEnvIP sets value for RemoteEnvIP property +func (sc *InstallationDataCreator) WithRemoteEnvIP(ipAddr string) *InstallationDataCreator { + sc.installationData.RemoteEnvIP = ipAddr + + return sc +} + +// WithUITestCredentials sets value for UITestUser and UITestPassword properties +func (sc *InstallationDataCreator) WithUITestCredentials(username, password string) *InstallationDataCreator { + sc.installationData.UITestUser = username + sc.installationData.UITestPassword = password + + return sc +} + +// WithAdminGroup sets value for AdminGroup property +func (sc *InstallationDataCreator) WithAdminGroup(adminGroupName string) *InstallationDataCreator { + sc.installationData.AdminGroup = adminGroupName + + return sc +} + +//////////////////////////////////////// +// GetData returns InstallationData created by InstallationDataCreator +func (sc *InstallationDataCreator) GetData() config.InstallationData { + sc.installationData.IsLocalInstallation = func() bool { return sc.installationData.ExternalIPAddress == "" } + return sc.installationData +} diff --git a/components/installer/scripts/build.sh b/components/installer/scripts/build.sh new file mode 100755 index 000000000000..66e773ca679c --- /dev/null +++ b/components/installer/scripts/build.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +set -o errexit + +CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +ROOT_DIR="$CURRENT_DIR/.." +KYMA_PATH="${ROOT_DIR}/../.." + +echo " +################################################################################ +# Kyma Installer build +################################################################################ +" + +#sed regex engine is greedy - it will try to find longest possible match. +#it means that regardless of how many colons are there, it will match from beginning till the _last_ colon. +IMAGE_VERSION=$(cat ${KYMA_PATH}/installation/resources/installer.yaml | grep "image:" | sed 's/^..*[:]//') +if [[ "${IMAGE_VERSION}" == "" ]]; then + echo "Can't find image version!" + exit 1 +else + echo "Building installer version: ${IMAGE_VERSION}" +fi + +IMAGE_NAME=eu.gcr.io/kyma-project/snapshot/installer:${IMAGE_VERSION} + +pushd ${ROOT_DIR} + +echo "Running update-codegen" +${ROOT_DIR}/hack/update-codegen.sh + +echo "Running go build" +export GOOS=linux && go build -o installer ${ROOT_DIR}/cmd/operator/main.go + +if [[ "${KYMA_PATH}" ]]; then + echo "Building docker image" + rm -rf kyma + mkdir kyma + cp -r ${KYMA_PATH}/resources kyma + cp -r ${KYMA_PATH}/installation kyma + + eval $(minikube docker-env --shell bash) + docker build -t ${IMAGE_NAME} -f deploy/installer/Dockerfile . +fi + +popd diff --git a/components/installer/scripts/run.sh b/components/installer/scripts/run.sh new file mode 100755 index 000000000000..311353f39be6 --- /dev/null +++ b/components/installer/scripts/run.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +set -o errexit + +CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +LOCAL_KYMA="--local" +KYMA_PATH="${CURRENT_DIR}/../../.." +CR_PATH="" + +POSITIONAL=() +while [[ $# -gt 0 ]] +do + key="$1" + + case ${key} in + --skip-minikube-start) + SKIP_MINIKUBE_START=true + shift # past argument + ;; + --local) + LOCAL_KYMA="--local" + shift + ;; + --cr) + CR_PATH="--cr $2" + shift # past argument + shift # past value + ;; + *) # unknown option + POSITIONAL+=("$1") # save it in an array for later + shift # past argument + ;; + esac +done +set -- "${POSITIONAL[@]}" # restore positional parameters + +if [[ ! ${SKIP_MINIKUBE_START} ]]; then + bash ${KYMA_PATH}/installation/scripts/minikube.sh +fi + +bash ${CURRENT_DIR}/build.sh ${KYMA_PATH} +bash ${KYMA_PATH}/installation/scripts/installer.sh ${CR_PATH} ${LOCAL_KYMA} diff --git a/components/istio-webhook/.dockerignore b/components/istio-webhook/.dockerignore new file mode 100644 index 000000000000..bd6fcff8ee2c --- /dev/null +++ b/components/istio-webhook/.dockerignore @@ -0,0 +1 @@ +Jenkinsfile \ No newline at end of file diff --git a/components/istio-webhook/Dockerfile b/components/istio-webhook/Dockerfile new file mode 100644 index 000000000000..2e3ff0acaac6 --- /dev/null +++ b/components/istio-webhook/Dockerfile @@ -0,0 +1,12 @@ +FROM alpine:3.7 + +LABEL source="git@github.com:kyma-project/kyma.git" + +RUN apk update +RUN apk add python2 +RUN apk add py2-flask + +COPY . /istio-webhook +WORKDIR /istio-webhook +ENTRYPOINT ["python", "app.py"] +EXPOSE 5000 diff --git a/components/istio-webhook/Jenkinsfile b/components/istio-webhook/Jenkinsfile new file mode 100644 index 000000000000..b30b1aa11c77 --- /dev/null +++ b/components/istio-webhook/Jenkinsfile @@ -0,0 +1,73 @@ +#!/usr/bin/env groovy +def label = "kyma-${UUID.randomUUID().toString()}" +def application = 'istio-webhook' +def isMaster = params.GIT_BRANCH == 'master' + +def dockerPushRoot = isMaster + ? "${env.DOCKER_REGISTRY}" + : "${env.DOCKER_REGISTRY}snapshot/" + +def dockerImageTag = isMaster + ? params.APP_VERSION + : params.GIT_BRANCH + +echo """ +******************************** +Job started with the following parameters: +DOCKER_REGISTRY=${env.DOCKER_REGISTRY} +DOCKER_CREDENTIALS=${env.DOCKER_CREDENTIALS} +GIT_REVISION=${params.GIT_REVISION} +GIT_BRANCH=${params.GIT_BRANCH} +APP_VERSION=${params.APP_VERSION} +APP_FOLDER=${env.APP_FOLDER} +******************************** +""" + +podTemplate(label: label) { + node(label) { + try { + timestamps { + timeout(time:20, unit:"MINUTES") { + ansiColor('xterm') { + stage("setup") { + checkout scm + + if(dockerImageTag == ""){ + error("No version for docker tag defined, please set APP_VERSION parameter for master branch or GIT_BRANCH parameter for any branch") + } + + withCredentials([usernamePassword(credentialsId: env.DOCKER_CREDENTIALS, passwordVariable: 'pwd', usernameVariable: 'uname')]) { + sh "docker login -u $uname -p '$pwd' $env.DOCKER_REGISTRY" + } + } + + stage("build image $application") { + dir(env.APP_FOLDER){ + sh "docker build -t $application:latest ." + } + } + + + stage("push image $application") { + sh "docker tag ${application}:latest ${dockerPushRoot}${application}:${dockerImageTag}" + sh "docker push ${dockerPushRoot}${application}:${dockerImageTag}" + } + } + } + } + } catch (ex) { + echo "Got exception: ${ex}" + currentBuild.result = "FAILURE" + def body = "${currentBuild.currentResult} ${env.JOB_NAME}${env.BUILD_DISPLAY_NAME}: on branch: ${params.GIT_BRANCH}. See details: ${env.BUILD_URL}" + emailext body: body, recipientProviders: [[$class: 'DevelopersRecipientProvider'], [$class: 'CulpritsRecipientProvider'], [$class: 'RequesterRecipientProvider']], subject: "${currentBuild.currentResult}: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'" + } + } +} + +def execute(command, envs = []) { + def buildpack = 'golang-buildpack:0.0.8' + def repositoryName = 'kyma' + def envText = envs=='' ? '' : "--env $envs" + workDir = pwd() + sh "docker run --rm -v $workDir:/go/src/github.com/kyma-project/$repositoryName/ -w /go/src/github.com/kyma-project/$repositoryName/$env.APP_FOLDER $envText ${env.DOCKER_REGISTRY}$buildpack /bin/bash -c '$command'" +} \ No newline at end of file diff --git a/components/istio-webhook/README.md b/components/istio-webhook/README.md new file mode 100644 index 000000000000..efb1d27095b2 --- /dev/null +++ b/components/istio-webhook/README.md @@ -0,0 +1,13 @@ +``` + ___ _ _ __ __ _ _ _ +|_ _|___| |_(_) ___ \ \ / /__| |__ | |__ ___ ___ | | __ + | |/ __| __| |/ _ \ \ \ /\ / / _ \ '_ \| '_ \ / _ \ / _ \| |/ / + | |\__ \ |_| | (_) | \ V V / __/ |_) | | | | (_) | (_) | < +|___|___/\__|_|\___/ \_/\_/ \___|_.__/|_| |_|\___/ \___/|_|\_\ +``` + + +## Overview + +Webhook for the Istio Pilot, used to serve Lua script with the Envoy filter. +Implementation based on the following example: [https://github.com/mandarjog/istioluawebhook](https://github.com/mandarjog/istioluawebhook). diff --git a/components/istio-webhook/app.py b/components/istio-webhook/app.py new file mode 100644 index 000000000000..e00d0c73fd73 --- /dev/null +++ b/components/istio-webhook/app.py @@ -0,0 +1,167 @@ +from flask import Flask +from flask import request +from flask import jsonify +import threading +import os +import time +import argparse + +app = Flask(__name__) + + +def getid(node): + ret = "unknown" + sl = node.split('~') + if len(sl) > 2: + ret = sl[2] + return ret + +# example +# /v1/listeners/istio-proxy/sidecar~10.32.1.20~httpbin-57db476f4d-svs9h.default~default.svc.cluster.local + + +@app.route('/v1/listeners//', methods=['POST']) +def lds(cluster, node): + op = insert_lua(request.get_json(), getid(node)) + return jsonify(op) + +# example +# /v1/clusters/istio-proxy/sidecar~10.32.1.20~httpbin-57db476f4d-svs9h.default~default.svc.cluster.local + + +@app.route('/v1/clusters//', methods=['POST']) +def cds(cluster, node): + output = request.data + return output + +# example +# /v1/routes/15003/istio-proxy/sidecar~10.32.1.20~httpbin-57db476f4d-svs9h.default~default.svc.cluster.local + + +@app.route('/v1/routes///', methods=['POST']) +def rds(name, cluster, node): + output = request.data + return output + + +""" +# example listener configuration +listeners: +- address: tcp://0.0.0.0:80 + bind_to_port: true + filters: + - name: http_connection_manager + config: + access_log: + - path: /dev/stdout + codec_type: auto + filters: + - name: mixer + config: {} + - name: lua + config: + inline_code: + +""" + +# +# inserts lua as a filter in the http_connection_manager +# + + +def insert_lua(listeners, nodeid): + for l in listeners.get("listeners", []): + for f in l.get("filters", []): + if f["name"] != "http_connection_manager": + continue + + ff = f["config"].get("filters", []) + ff.insert(0, lua_config(nodeid)) + + return listeners + + +def lua_config(nodeid): + s = FILE_STORE[SCRIPT] + return {"name": "lua", + "config": + {"inline_code": s}} + + +DEFAULT_LUA_SCRIPT = """ +-- Called on the request path. +function envoy_on_request(request_handle) + request_handle:headers():add("x-lua-header", "true") +end + +-- Called on the response path. +function envoy_on_response(response_handle) + response_handle:headers():add("x-lua-resp-header", "{nodeid}") +end +""" + +SCRIPT = "SCRIPT" +FILE_STORE = { + SCRIPT: DEFAULT_LUA_SCRIPT +} + + +# polls for file chage +class poller(object): + + def __init__(self, filepath, cfg): + self.filepath = filepath + self.done = False + self.cfg = cfg + + def cancel(self): + self.done = True + + def __call__(self): + modtime = 0 + while not self.done: + modtime = self.read_if_changed(modtime) + time.sleep(5) + + def read_if_changed(self, modtime): + if not os.path.isfile(self.filepath): + print self.filepath, "not found" + return modtime + + new_modtime = os.path.getmtime(self.filepath) + if new_modtime == modtime: + return modtime + + with open(self.filepath, "rt") as fl: + ls = fl.read() + print "File updated" + print ls + self.cfg[SCRIPT] = ls + + return new_modtime + + +def get_args_parser(): + parser = argparse.ArgumentParser( + description="Run pilot webhook") + + parser.add_argument("--script", help="path of the lua script to inject", + default="scripts/plugin.lua") + parser.add_argument("--port", help="port to listen on", + type=int, default=5000) + return parser + + +def main(args): + p = poller(args.script, FILE_STORE) + threading.Thread(target=p).start() + ret = app.run(host="0.0.0.0", port=args.port) + p.cancel() + return ret + + +if __name__ == "__main__": + import sys + parser = get_args_parser() + args = parser.parse_args() + sys.exit(main(args)) diff --git a/components/remote-environment-broker/.gitignore b/components/remote-environment-broker/.gitignore new file mode 100644 index 000000000000..d32118a337c1 --- /dev/null +++ b/components/remote-environment-broker/.gitignore @@ -0,0 +1,104 @@ +### LOCAL +# info.json is always generated by before-commit.sh +/info.json + +/.idea +/.vscode +/scripts + +### macOS template +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/dictionaries + +# Sensitive or high-churn files: +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.xml +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml + +# Gradle: +.idea/**/gradle.xml +.idea/**/libraries + +# CMake +cmake-build-debug/ + +# Mongo Explorer plugin: +.idea/**/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties +### Go template +# Binaries for programs and plugins +*.exe +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 +.glide/ + +/remote-environment-broker.iml +/remote-environment-broker +/vendor diff --git a/components/remote-environment-broker/Gopkg.lock b/components/remote-environment-broker/Gopkg.lock new file mode 100644 index 000000000000..0863575f87af --- /dev/null +++ b/components/remote-environment-broker/Gopkg.lock @@ -0,0 +1,712 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/PuerkitoBio/purell" + packages = ["."] + revision = "0bcb03f4b4d0a9428594752bd2a3b9aa0a9d4bd4" + version = "v1.1.0" + +[[projects]] + branch = "master" + name = "github.com/PuerkitoBio/urlesc" + packages = ["."] + revision = "de5bf2ad457846296e2031421a34e2568e304e35" + +[[projects]] + name = "github.com/asaskevich/govalidator" + packages = ["."] + revision = "521b25f4b05fd26bec69d9dedeb8f9c9a83939a8" + version = "v8" + +[[projects]] + name = "github.com/davecgh/go-spew" + packages = ["spew"] + revision = "346938d642f2ec3594ed81d874461961cd0faa76" + version = "v1.1.0" + +[[projects]] + name = "github.com/docker/distribution" + packages = [ + "digestset", + "reference" + ] + revision = "edc3ab29cdff8694dd6feb85cfeb4b5f1b38ed9c" + +[[projects]] + name = "github.com/emicklei/go-restful" + packages = [ + ".", + "log" + ] + revision = "26b41036311f2da8242db402557a0dbd09dc83da" + version = "v2.6.0" + +[[projects]] + name = "github.com/ghodss/yaml" + packages = ["."] + revision = "0ca9ea5df5451ffdf184b4428c902747c2c11cd7" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "github.com/go-openapi/jsonpointer" + packages = ["."] + revision = "779f45308c19820f1a69e9a4cd965f496e0da10f" + +[[projects]] + branch = "master" + name = "github.com/go-openapi/jsonreference" + packages = ["."] + revision = "36d33bfe519efae5632669801b180bf1a245da3b" + +[[projects]] + branch = "master" + name = "github.com/go-openapi/spec" + packages = ["."] + revision = "1de3e0542de65ad8d75452a595886fdd0befb363" + +[[projects]] + branch = "master" + name = "github.com/go-openapi/swag" + packages = ["."] + revision = "84f4bee7c0a6db40e3166044c7983c1c32125429" + +[[projects]] + name = "github.com/gogo/protobuf" + packages = [ + "proto", + "sortkeys" + ] + revision = "1adfc126b41513cc696b209667c8656ea7aac67c" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "github.com/golang/glog" + packages = ["."] + revision = "23def4e6c14b4da8ac2ed8007337bc5eb5007998" + +[[projects]] + branch = "master" + name = "github.com/golang/groupcache" + packages = ["lru"] + revision = "66deaeb636dff1ac7d938ce666d090556056a4b0" + +[[projects]] + branch = "travis-1.9" + name = "github.com/golang/lint" + packages = [ + ".", + "golint" + ] + revision = "883fe33ffc4344bad1ecd881f61afd5ec5d80e0a" + +[[projects]] + name = "github.com/golang/protobuf" + packages = [ + "proto", + "ptypes", + "ptypes/any", + "ptypes/duration", + "ptypes/timestamp" + ] + revision = "925541529c1fa6821df4e44ce2723319eb2be768" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "github.com/google/gofuzz" + packages = ["."] + revision = "24818f796faf91cd76ec7bddd72458fbced7a6c1" + +[[projects]] + name = "github.com/googleapis/gnostic" + packages = [ + "OpenAPIv2", + "compiler", + "extensions" + ] + revision = "ee43cbb60db7bd22502942cccbc39059117352ab" + version = "v0.1.0" + +[[projects]] + name = "github.com/gorilla/context" + packages = ["."] + revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a" + version = "v1.1" + +[[projects]] + name = "github.com/gorilla/mux" + packages = ["."] + revision = "53c1911da2b537f792e7cafcb446b05ffe33b996" + version = "v1.6.1" + +[[projects]] + branch = "master" + name = "github.com/hashicorp/errwrap" + packages = ["."] + revision = "7554cd9344cec97297fa6649b055a8c98c2a1e55" + +[[projects]] + branch = "master" + name = "github.com/hashicorp/go-multierror" + packages = ["."] + revision = "b7773ae218740a7be65057fc60b366a49b538a44" + +[[projects]] + branch = "master" + name = "github.com/hashicorp/golang-lru" + packages = [ + ".", + "simplelru" + ] + revision = "0fb14efe8c47ae851c0034ed7a448854d3d34cf3" + +[[projects]] + branch = "master" + name = "github.com/howeyc/gopass" + packages = ["."] + revision = "bf9dde6d0d2c004a008c27aaee91170c786f6db8" + +[[projects]] + name = "github.com/imdario/mergo" + packages = ["."] + revision = "163f41321a19dd09362d4c63cc2489db2015f1f4" + version = "0.3.2" + +[[projects]] + name = "github.com/json-iterator/go" + packages = ["."] + revision = "be70f29b04dea7efb4d82a8989c33aee989b74d1" + version = "1.1.0" + +[[projects]] + name = "github.com/kubernetes-incubator/service-catalog" + packages = [ + "pkg/apis/servicecatalog", + "pkg/apis/servicecatalog/v1beta1", + "pkg/apis/settings", + "pkg/apis/settings/v1alpha1", + "pkg/client/clientset_generated/clientset", + "pkg/client/clientset_generated/clientset/fake", + "pkg/client/clientset_generated/clientset/scheme", + "pkg/client/clientset_generated/clientset/typed/servicecatalog/v1beta1", + "pkg/client/clientset_generated/clientset/typed/servicecatalog/v1beta1/fake", + "pkg/client/clientset_generated/clientset/typed/settings/v1alpha1", + "pkg/client/clientset_generated/clientset/typed/settings/v1alpha1/fake", + "pkg/client/informers_generated/externalversions", + "pkg/client/informers_generated/externalversions/internalinterfaces", + "pkg/client/informers_generated/externalversions/servicecatalog", + "pkg/client/informers_generated/externalversions/servicecatalog/v1beta1", + "pkg/client/informers_generated/externalversions/settings", + "pkg/client/informers_generated/externalversions/settings/v1alpha1", + "pkg/client/listers_generated/servicecatalog/v1beta1", + "pkg/client/listers_generated/settings/v1alpha1", + "pkg/svcat/service-catalog" + ] + revision = "98af5889bb75a9472313e288ed5c06c591a19a19" + version = "v0.1.11" + +[[projects]] + branch = "master" + name = "github.com/mailru/easyjson" + packages = [ + "buffer", + "jlexer", + "jwriter" + ] + revision = "32fa128f234d041f196a9f3e0fea5ac9772c08e1" + +[[projects]] + name = "github.com/mcuadros/go-defaults" + packages = ["."] + revision = "ac8540f0fc7e0fb5f1eb9e25c6fd0b8db8f97eed" + version = "v1.1.0" + +[[projects]] + branch = "master" + name = "github.com/meatballhat/negroni-logrus" + packages = ["."] + revision = "31067281800f66f57548a7a32d9c6c5f963fef83" + +[[projects]] + name = "github.com/oklog/ulid" + packages = ["."] + revision = "d311cb43c92434ec4072dfbbda3400741d0a6337" + version = "v0.3.0" + +[[projects]] + name = "github.com/opencontainers/go-digest" + packages = ["."] + revision = "279bed98673dd5bef374d3b6e4b09e2af76183bf" + version = "v1.0.0-rc1" + +[[projects]] + name = "github.com/pborman/uuid" + packages = ["."] + revision = "e790cca94e6cc75c7064b1332e63811d4aae1a53" + version = "v1.1" + +[[projects]] + name = "github.com/pkg/errors" + packages = ["."] + revision = "645ef00459ed84a119197bfb8d8205042c6df63d" + version = "v0.8.0" + +[[projects]] + name = "github.com/pmezard/go-difflib" + packages = ["difflib"] + revision = "792786c7400a136282c1664665ae0a8db921c6c2" + version = "v1.0.0" + +[[projects]] + name = "github.com/pmorie/go-open-service-broker-client" + packages = ["v2"] + revision = "4c53612134f3e9dc633016a9dfe556fe915119d7" + version = "0.0.1" + +[[projects]] + name = "github.com/sirupsen/logrus" + packages = ["."] + revision = "d682213848ed68c0a260ca37d6dd5ace8423f5ba" + version = "v1.0.4" + +[[projects]] + name = "github.com/spf13/pflag" + packages = ["."] + revision = "e57e3eeb33f795204c1ca35f56c44f83227c6e66" + version = "v1.0.0" + +[[projects]] + name = "github.com/stretchr/objx" + packages = ["."] + revision = "facf9a85c22f48d2f52f2380e4efce1768749a89" + version = "v0.1" + +[[projects]] + name = "github.com/stretchr/testify" + packages = [ + "assert", + "mock", + "require" + ] + revision = "12b6f73e6084dad08a7c6e575284b177ecafbc71" + version = "v1.2.1" + +[[projects]] + name = "github.com/urfave/negroni" + packages = ["."] + revision = "5dbbc83f748fc3ad38585842b0aedab546d0ea1e" + version = "v0.3.0" + +[[projects]] + name = "github.com/v2pro/plz" + packages = [ + "concurrent", + "reflect2" + ] + revision = "571356917fca907b069b7b47a14cd32ba0b340d4" + version = "0.9.0" + +[[projects]] + branch = "master" + name = "github.com/vrischmann/envconfig" + packages = ["."] + revision = "757beaaeac8d14bcc7ea3f71488d65cf45cf2eff" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = [ + "ed25519", + "ed25519/internal/edwards25519", + "ssh/terminal" + ] + revision = "9de5f2eaf759b4c4550b3db39fed2e9e5f86f45c" + +[[projects]] + branch = "master" + name = "golang.org/x/net" + packages = [ + "context", + "http2", + "http2/hpack", + "idna", + "lex/httplex" + ] + revision = "cbe0f9307d0156177f9dd5dc85da1a31abc5f2fb" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = [ + "unix", + "windows" + ] + revision = "37707fdb30a5b38865cfb95e5aab41707daec7fd" + +[[projects]] + name = "golang.org/x/text" + packages = [ + "collate", + "collate/build", + "internal/colltab", + "internal/gen", + "internal/tag", + "internal/triegen", + "internal/ucd", + "language", + "secure/bidirule", + "transform", + "unicode/bidi", + "unicode/cldr", + "unicode/norm", + "unicode/rangetable", + "width" + ] + revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" + version = "v0.3.0" + +[[projects]] + branch = "master" + name = "golang.org/x/time" + packages = ["rate"] + revision = "fbb02b2291d28baffd63558aa44b4b56f178d650" + +[[projects]] + branch = "master" + name = "golang.org/x/tools" + packages = [ + "cmd/goimports", + "go/ast/astutil", + "go/gcexportdata", + "go/gcimporter15", + "go/types/typeutil", + "imports" + ] + revision = "059bec968c61383b574810040ba9410712de36c5" + +[[projects]] + name = "gopkg.in/inf.v0" + packages = ["."] + revision = "3887ee99ecf07df5b447e9b00d9c0b2adaa9f3e4" + version = "v0.9.0" + +[[projects]] + name = "gopkg.in/square/go-jose.v2" + packages = [ + ".", + "cipher", + "json", + "jwt" + ] + revision = "76dd09796242edb5b897103a75df2645c028c960" + version = "v2.1.6" + +[[projects]] + branch = "v2" + name = "gopkg.in/yaml.v2" + packages = ["."] + revision = "d670f9405373e636a5a2765eea47fac0c9bc91a4" + +[[projects]] + name = "k8s.io/api" + packages = [ + "admissionregistration/v1alpha1", + "admissionregistration/v1beta1", + "apps/v1", + "apps/v1beta1", + "apps/v1beta2", + "authentication/v1", + "authentication/v1beta1", + "authorization/v1", + "authorization/v1beta1", + "autoscaling/v1", + "autoscaling/v2beta1", + "batch/v1", + "batch/v1beta1", + "batch/v2alpha1", + "certificates/v1beta1", + "core/v1", + "events/v1beta1", + "extensions/v1beta1", + "networking/v1", + "policy/v1beta1", + "rbac/v1", + "rbac/v1alpha1", + "rbac/v1beta1", + "scheduling/v1alpha1", + "settings/v1alpha1", + "storage/v1", + "storage/v1alpha1", + "storage/v1beta1" + ] + revision = "73d903622b7391f3312dcbac6483fed484e185f8" + version = "kubernetes-1.10.0" + +[[projects]] + branch = "master" + name = "k8s.io/apiextensions-apiserver" + packages = ["pkg/features"] + revision = "84a6f0ee4772b381fa8de878a538a9a17c4fd610" + +[[projects]] + name = "k8s.io/apimachinery" + packages = [ + "pkg/api/equality", + "pkg/api/errors", + "pkg/api/meta", + "pkg/api/resource", + "pkg/api/validation", + "pkg/apimachinery", + "pkg/apimachinery/announced", + "pkg/apimachinery/registered", + "pkg/apis/meta/internalversion", + "pkg/apis/meta/v1", + "pkg/apis/meta/v1/unstructured", + "pkg/apis/meta/v1/validation", + "pkg/apis/meta/v1beta1", + "pkg/conversion", + "pkg/conversion/queryparams", + "pkg/fields", + "pkg/labels", + "pkg/runtime", + "pkg/runtime/schema", + "pkg/runtime/serializer", + "pkg/runtime/serializer/json", + "pkg/runtime/serializer/protobuf", + "pkg/runtime/serializer/recognizer", + "pkg/runtime/serializer/streaming", + "pkg/runtime/serializer/versioning", + "pkg/selection", + "pkg/types", + "pkg/util/cache", + "pkg/util/clock", + "pkg/util/diff", + "pkg/util/errors", + "pkg/util/framer", + "pkg/util/intstr", + "pkg/util/json", + "pkg/util/mergepatch", + "pkg/util/net", + "pkg/util/runtime", + "pkg/util/sets", + "pkg/util/strategicpatch", + "pkg/util/uuid", + "pkg/util/validation", + "pkg/util/validation/field", + "pkg/util/wait", + "pkg/util/yaml", + "pkg/version", + "pkg/watch", + "third_party/forked/golang/json", + "third_party/forked/golang/reflect" + ] + revision = "302974c03f7e50f16561ba237db776ab93594ef6" + version = "kubernetes-1.10.0" + +[[projects]] + branch = "master" + name = "k8s.io/apiserver" + packages = [ + "pkg/authentication/authenticator", + "pkg/authentication/serviceaccount", + "pkg/authentication/user", + "pkg/features", + "pkg/util/feature" + ] + revision = "08ff95861cf509679463c2cbeed0ce5c0cc55054" + +[[projects]] + name = "k8s.io/client-go" + packages = [ + "discovery", + "discovery/fake", + "informers/core/v1", + "informers/internalinterfaces", + "kubernetes", + "kubernetes/fake", + "kubernetes/scheme", + "kubernetes/typed/admissionregistration/v1alpha1", + "kubernetes/typed/admissionregistration/v1alpha1/fake", + "kubernetes/typed/admissionregistration/v1beta1", + "kubernetes/typed/admissionregistration/v1beta1/fake", + "kubernetes/typed/apps/v1", + "kubernetes/typed/apps/v1/fake", + "kubernetes/typed/apps/v1beta1", + "kubernetes/typed/apps/v1beta1/fake", + "kubernetes/typed/apps/v1beta2", + "kubernetes/typed/apps/v1beta2/fake", + "kubernetes/typed/authentication/v1", + "kubernetes/typed/authentication/v1/fake", + "kubernetes/typed/authentication/v1beta1", + "kubernetes/typed/authentication/v1beta1/fake", + "kubernetes/typed/authorization/v1", + "kubernetes/typed/authorization/v1/fake", + "kubernetes/typed/authorization/v1beta1", + "kubernetes/typed/authorization/v1beta1/fake", + "kubernetes/typed/autoscaling/v1", + "kubernetes/typed/autoscaling/v1/fake", + "kubernetes/typed/autoscaling/v2beta1", + "kubernetes/typed/autoscaling/v2beta1/fake", + "kubernetes/typed/batch/v1", + "kubernetes/typed/batch/v1/fake", + "kubernetes/typed/batch/v1beta1", + "kubernetes/typed/batch/v1beta1/fake", + "kubernetes/typed/batch/v2alpha1", + "kubernetes/typed/batch/v2alpha1/fake", + "kubernetes/typed/certificates/v1beta1", + "kubernetes/typed/certificates/v1beta1/fake", + "kubernetes/typed/core/v1", + "kubernetes/typed/core/v1/fake", + "kubernetes/typed/events/v1beta1", + "kubernetes/typed/events/v1beta1/fake", + "kubernetes/typed/extensions/v1beta1", + "kubernetes/typed/extensions/v1beta1/fake", + "kubernetes/typed/networking/v1", + "kubernetes/typed/networking/v1/fake", + "kubernetes/typed/policy/v1beta1", + "kubernetes/typed/policy/v1beta1/fake", + "kubernetes/typed/rbac/v1", + "kubernetes/typed/rbac/v1/fake", + "kubernetes/typed/rbac/v1alpha1", + "kubernetes/typed/rbac/v1alpha1/fake", + "kubernetes/typed/rbac/v1beta1", + "kubernetes/typed/rbac/v1beta1/fake", + "kubernetes/typed/scheduling/v1alpha1", + "kubernetes/typed/scheduling/v1alpha1/fake", + "kubernetes/typed/settings/v1alpha1", + "kubernetes/typed/settings/v1alpha1/fake", + "kubernetes/typed/storage/v1", + "kubernetes/typed/storage/v1/fake", + "kubernetes/typed/storage/v1alpha1", + "kubernetes/typed/storage/v1alpha1/fake", + "kubernetes/typed/storage/v1beta1", + "kubernetes/typed/storage/v1beta1/fake", + "listers/core/v1", + "pkg/apis/clientauthentication", + "pkg/apis/clientauthentication/v1alpha1", + "pkg/version", + "plugin/pkg/client/auth/exec", + "rest", + "rest/watch", + "testing", + "tools/auth", + "tools/cache", + "tools/clientcmd", + "tools/clientcmd/api", + "tools/clientcmd/api/latest", + "tools/clientcmd/api/v1", + "tools/metrics", + "tools/pager", + "tools/record", + "tools/reference", + "transport", + "util/buffer", + "util/cert", + "util/flowcontrol", + "util/homedir", + "util/integer", + "util/retry", + "util/workqueue" + ] + revision = "989be4278f353e42f26c416c53757d16fcff77db" + version = "kubernetes-1.10.1" + +[[projects]] + branch = "release-1.10" + name = "k8s.io/code-generator" + packages = [ + "cmd/client-gen", + "cmd/client-gen/args", + "cmd/client-gen/generators", + "cmd/client-gen/generators/fake", + "cmd/client-gen/generators/scheme", + "cmd/client-gen/generators/util", + "cmd/client-gen/path", + "cmd/client-gen/types", + "cmd/conversion-gen", + "cmd/conversion-gen/args", + "cmd/conversion-gen/generators", + "cmd/deepcopy-gen", + "cmd/deepcopy-gen/args", + "cmd/defaulter-gen", + "cmd/defaulter-gen/args", + "cmd/informer-gen", + "cmd/informer-gen/args", + "cmd/informer-gen/generators", + "cmd/lister-gen", + "cmd/lister-gen/args", + "cmd/lister-gen/generators", + "cmd/openapi-gen", + "cmd/openapi-gen/args", + "pkg/util" + ] + revision = "9de8e796a74d16d2a285165727d04c185ebca6dc" + +[[projects]] + name = "k8s.io/gengo" + packages = [ + "args", + "examples/deepcopy-gen/generators", + "examples/defaulter-gen/generators", + "examples/set-gen/sets", + "generator", + "namer", + "parser", + "types" + ] + revision = "01a732e01d00cb9a81bb0ca050d3e6d2b947927b" + +[[projects]] + branch = "master" + name = "k8s.io/kube-openapi" + packages = [ + "pkg/common", + "pkg/generators", + "pkg/util/proto" + ] + revision = "50ae88d24ede7b8bad68e23c805b5d3da5c8abaf" + +[[projects]] + branch = "release-1.10" + name = "k8s.io/kubernetes" + packages = [ + "pkg/api/legacyscheme", + "pkg/api/service", + "pkg/api/v1/pod", + "pkg/apis/autoscaling", + "pkg/apis/core", + "pkg/apis/core/helper", + "pkg/apis/core/install", + "pkg/apis/core/pods", + "pkg/apis/core/v1", + "pkg/apis/core/v1/helper", + "pkg/apis/core/validation", + "pkg/apis/extensions", + "pkg/apis/networking", + "pkg/capabilities", + "pkg/controller", + "pkg/features", + "pkg/fieldpath", + "pkg/kubelet/types", + "pkg/master/ports", + "pkg/scheduler/api", + "pkg/security/apparmor", + "pkg/serviceaccount", + "pkg/util/file", + "pkg/util/hash", + "pkg/util/net/sets", + "pkg/util/parsers", + "pkg/util/pointer", + "pkg/util/taints" + ] + revision = "eac305f3bd6da8af77c93c8d6d9a5a538e778cb9" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "6742e0eea5f51bdb081952bfc5ab76f4e933a549e3b329489635e812cb520f6b" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/components/remote-environment-broker/Gopkg.toml b/components/remote-environment-broker/Gopkg.toml new file mode 100644 index 000000000000..3eaf37b84d40 --- /dev/null +++ b/components/remote-environment-broker/Gopkg.toml @@ -0,0 +1,132 @@ + +# Gopkg.toml example +# +# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" + +required = [ + "github.com/golang/lint/golint", + "golang.org/x/tools/cmd/goimports", + + "k8s.io/code-generator/cmd/defaulter-gen", + "k8s.io/code-generator/cmd/deepcopy-gen", + "k8s.io/code-generator/cmd/conversion-gen", + "k8s.io/code-generator/cmd/client-gen", + "k8s.io/code-generator/cmd/lister-gen", + "k8s.io/code-generator/cmd/informer-gen", + "k8s.io/code-generator/cmd/openapi-gen", + "k8s.io/gengo/args", +] + +[prune] + unused-packages = true + go-tests = true + non-go = true + +[[constraint]] + name = "github.com/asaskevich/govalidator" + version = "8.0.0" + +[[constraint]] + name = "github.com/ghodss/yaml" + version = "1.0.0" + +[[constraint]] + name = "github.com/gorilla/mux" + version = "1.6.1" + +[[constraint]] + name = "github.com/imdario/mergo" + version = "0.3.2" + +[[constraint]] + name = "github.com/mcuadros/go-defaults" + version = "1.1.0" + +[[constraint]] + branch = "master" + name = "github.com/meatballhat/negroni-logrus" + +[[constraint]] + name = "github.com/pkg/errors" + version = "0.8.0" + +[[constraint]] + name = "github.com/pmorie/go-open-service-broker-client" + version = "0.0.1" + +[[constraint]] + name = "github.com/sirupsen/logrus" + version = "1.0.4" + +[[constraint]] + name = "github.com/urfave/negroni" + version = "0.3.0" + +[[constraint]] + branch = "master" + name = "github.com/vrischmann/envconfig" + +[[constraint]] + name = "k8s.io/api" + version = "kubernetes-1.10.1" + +[[constraint]] + name = "k8s.io/apimachinery" + version = "kubernetes-1.10.1" + +[[constraint]] + name = "k8s.io/client-go" + version = "kubernetes-1.10.1" + +[[constraint]] + name = "k8s.io/code-generator" + branch = "release-1.10" + +[[constraint]] + name = "github.com/kubernetes-incubator/service-catalog" + version = "=v0.1.11" + +[[constraint]] + name = "k8s.io/kubernetes" + branch = "release-1.10" + +[[constraint]] + name = "k8s.io/gengo" + revision = "01a732e01d00cb9a81bb0ca050d3e6d2b947927b" + +[[override]] + name = "github.com/docker/distribution" + revision = "edc3ab29cdff8694dd6feb85cfeb4b5f1b38ed9c" + +[[override]] + name = "k8s.io/api" + version = "kubernetes-1.10.1" + +[[override]] + name = "k8s.io/apimachinery" + version = "kubernetes-1.10.1" + +[[override]] + name = "k8s.io/client-go" + version = "kubernetes-1.10.1" + +[[override]] + name = "k8s.io/code-generator" + branch = "release-1.10" diff --git a/components/remote-environment-broker/Jenkinsfile b/components/remote-environment-broker/Jenkinsfile new file mode 100644 index 000000000000..9715cc946111 --- /dev/null +++ b/components/remote-environment-broker/Jenkinsfile @@ -0,0 +1,86 @@ +#!/usr/bin/env groovy +def label = "kyma-${UUID.randomUUID().toString()}" +def application = 'remote-environment-broker' +def isMaster = params.GIT_BRANCH == 'master' + +def dockerPushRoot = isMaster +? "${env.DOCKER_REGISTRY}" +: "${env.DOCKER_REGISTRY}snapshot/" + +def dockerImageTag = isMaster +? params.APP_VERSION +: params.GIT_BRANCH + +echo """ +******************************** +Job started with the following parameters: +DOCKER_REGISTRY=${env.DOCKER_REGISTRY} +DOCKER_CREDENTIALS=${env.DOCKER_CREDENTIALS} +GIT_REVISION=${params.GIT_REVISION} +GIT_BRANCH=${params.GIT_BRANCH} +APP_VERSION=${params.APP_VERSION} +APP_FOLDER=${env.APP_FOLDER} +******************************** +""" + +podTemplate(label: label) { + node(label) { + try { + timestamps { + timeout(time:20, unit:"MINUTES") { + ansiColor('xterm') { + stage("setup") { + checkout scm + + if(dockerImageTag == ""){ + error("No version for docker tag defined, please set APP_VERSION parameter for master branch or GIT_BRANCH parameter for any branch") + } + + withCredentials([usernamePassword(credentialsId: env.DOCKER_CREDENTIALS, passwordVariable: 'pwd', usernameVariable: 'uname')]) { + sh "docker login -u $uname -p '$pwd' $env.DOCKER_REGISTRY" + } + } + + stage("build and test $application") { + execute "./before-commit.sh ci" + } + + if (isMaster) { + stage("IP scan $application (Sourceclear)"){ + withCredentials([string(credentialsId: 'SRCCLR_API_TOKEN', variable: 'SRCCLR_API_TOKEN')]) { + execute('curl -sSL https://download.sourceclear.com/ci.sh | sh', "SRCCLR_API_TOKEN=$SRCCLR_API_TOKEN") + } + } + } + + stage("build image $application") { + dir(env.APP_FOLDER){ + sh "cp ./$application deploy/broker/$application" + sh "docker build -t $application:latest deploy/broker" + } + } + + + stage("push image $application") { + sh "docker tag ${application}:latest ${dockerPushRoot}${application}:${dockerImageTag}" + sh "docker push ${dockerPushRoot}${application}:${dockerImageTag}" + } + } + } + } + } catch (ex) { + echo "Got exception: ${ex}" + currentBuild.result = "FAILURE" + def body = "${currentBuild.currentResult} ${env.JOB_NAME}${env.BUILD_DISPLAY_NAME}: on branch: ${params.GIT_BRANCH}. See details: ${env.BUILD_URL}" + emailext body: body, recipientProviders: [[$class: 'DevelopersRecipientProvider'], [$class: 'CulpritsRecipientProvider'], [$class: 'RequesterRecipientProvider']], subject: "${currentBuild.currentResult}: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'" + } + } +} + +def execute(command, envs = []) { + def buildpack = 'golang-buildpack:0.0.8' + def repositoryName = 'kyma' + def envText = envs=='' ? '' : "--env $envs" + workDir = pwd() + sh "docker run --rm -v $workDir:/go/src/github.com/kyma-project/$repositoryName/ -w /go/src/github.com/kyma-project/$repositoryName/$env.APP_FOLDER $envText ${env.DOCKER_REGISTRY}$buildpack /bin/bash -c '$command'" +} diff --git a/components/remote-environment-broker/README.md b/components/remote-environment-broker/README.md new file mode 100755 index 000000000000..6019ffc8a031 --- /dev/null +++ b/components/remote-environment-broker/README.md @@ -0,0 +1,30 @@ +# Remote Environment Broker + +## Overview + +The Remote Environment Broker (REB) provides remote environments in the [Service Catalog](../../docs/service-catalog/docs/001-overview-service-catalog.md). +A remote environment represents the environment connected to the Kyma instance. +The REB implements the [Service Broker API](https://github.com/openservicebrokerapi/servicebroker/blob/master/spec.md). + +The REB fetches all the remote environments' custom resources and exposes their APIs and Events as service classes to the Service Catalog. +When the remote environments list is available in the Service Catalog, you can provision those service classes and enable Kyma services to use them. + +For more details about provisioning, deprovisioning, binding, and unbinding, see the [Service Broker API](https://github.com/openservicebrokerapi/servicebroker/blob/master/spec.md) documentation. + +## Prerequisites + +You need the following tools to set up the project: +* The 1.9 or higher version of [Go](https://golang.org/dl/) +* The latest version of [Docker](https://www.docker.com/) +* The latest version of [Dep](https://github.com/golang/dep) + +## Development + +Before each commit, use the `before-commit.sh` script, which tests your changes. + +## Code generation + +Structs related to Custom Resource Definitions are defined in `pkg/apis/remoteenvironment/v1alpha1/types.go` and registered in `pkg/apis/remoteenvironment/v1alpha1/`. After making any changes there, please run: +```bash +./hack/update-codegen.sh +``` diff --git a/components/remote-environment-broker/before-commit.sh b/components/remote-environment-broker/before-commit.sh new file mode 100755 index 000000000000..4ee2a652b07a --- /dev/null +++ b/components/remote-environment-broker/before-commit.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash + +readonly CI_FLAG=ci + +RED='\033[0;31m' +GREEN='\033[0;32m' +INVERTED='\033[7m' +NC='\033[0m' # No Color + +echo -e "${INVERTED}" +echo "USER: " + $USER +echo "PATH: " + $PATH +echo "GOPATH:" + $GOPATH +echo -e "${NC}" + +## +# DEP ENSURE +## +dep ensure -v --vendor-only +ensureResult=$? +if [ ${ensureResult} != 0 ]; then + echo -e "${RED}✗ dep ensure -v --vendor-only${NC}\n$ensureResult${NC}" + exit 1 +else echo -e "${GREEN}√ dep ensure -v --vendor-only${NC}" +fi + +## +# GO BUILD +## +buildEnv="" +if [ "$1" == "$CI_FLAG" ]; then + # build binary statically + buildEnv="env CGO_ENABLED=0" +fi + +${buildEnv} go build -o remote-environment-broker ./cmd/broker + +goBuildResult=$? +if [ ${goBuildResult} != 0 ]; then + echo -e "${RED}✗ go build${NC}\n$goBuildResult${NC}" + exit 1 +else echo -e "${GREEN}√ go build${NC}" +fi + +## +# DEP STATUS +## +echo "? dep status" +depResult=$(dep status -v) +if [ $? != 0 ]; then + echo -e "${RED}✗ dep status\n$depResult${NC}" + exit 1 +else echo -e "${GREEN}√ dep status${NC}" +fi + +## +# GO TEST +## +echo "? go test" +go test ./... +# Check if tests passed +if [ $? != 0 ]; then + echo -e "${RED}✗ go test\n${NC}" + exit 1 +else echo -e "${GREEN}√ go test${NC}" +fi + +goFilesToCheck=$(find . -type f -name "*.go" | egrep -v "\/vendor\/|_*/automock/|_*/testdata/|/pkg\/|_*export_test.go") + +## +# GO LINT +## +go build -o golint-vendored ./vendor/github.com/golang/lint/golint +buildLintResult=$? +if [ ${buildLintResult} != 0 ]; then + echo -e "${RED}✗ go build lint${NC}\n$buildLintResult${NC}" + exit 1 +fi + +golintResult=$(echo "${goFilesToCheck}" | xargs -L1 ./golint-vendored) +rm golint-vendored + +if [ $(echo ${#golintResult}) != 0 ]; then + echo -e "${RED}✗ golint\n$golintResult${NC}" + exit 1 +else echo -e "${GREEN}√ golint${NC}" +fi + +## +# GO IMPORTS & FMT +## +go build -o goimports-vendored ./vendor/golang.org/x/tools/cmd/goimports +buildGoImportResult=$? +if [ ${buildGoImportResult} != 0 ]; then + echo -e "${RED}✗ go build goimports${NC}\n$buildGoImportResult${NC}" + exit 1 +fi + +goImportsResult=$(echo "${goFilesToCheck}" | xargs -L1 ./goimports-vendored -w -l) +rm goimports-vendored + +if [ $(echo ${#goImportsResult}) != 0 ]; then + echo -e "${RED}✗ goimports and fmt${NC}\n$goImportsResult${NC}" + exit 1 +else echo -e "${GREEN}√ goimports and fmt${NC}" +fi + +## +# GO VET +## +packagesToVet=("./cmd/..." "./internal/...") + +for vPackage in "${packagesToVet[@]}"; do + vetResult=$(go vet ${vPackage}) + if [ $(echo ${#vetResult}) != 0 ]; then + echo -e "${RED}✗ go vet ${vPackage} ${NC}\n$vetResult${NC}" + exit 1 + else echo -e "${GREEN}√ go vet ${vPackage} ${NC}" + fi +done \ No newline at end of file diff --git a/components/remote-environment-broker/cmd/broker/main.go b/components/remote-environment-broker/cmd/broker/main.go new file mode 100644 index 000000000000..fdb743fbf002 --- /dev/null +++ b/components/remote-environment-broker/cmd/broker/main.go @@ -0,0 +1,136 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + scCs "github.com/kubernetes-incubator/service-catalog/pkg/client/clientset_generated/clientset" + catalogInformers "github.com/kubernetes-incubator/service-catalog/pkg/client/informers_generated/externalversions" + "github.com/kubernetes-incubator/service-catalog/pkg/svcat/service-catalog" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/access" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/broker" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/config" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/labeler" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/storage" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/storage/populator" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/syncer" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/informers/externalversions" + "github.com/kyma-project/kyma/components/remote-environment-broker/platform/logger" + "github.com/sirupsen/logrus" + "k8s.io/client-go/informers/core/v1" + "k8s.io/client-go/kubernetes" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/tools/cache" +) + +// informerResyncPeriod defines how often informer will execute relist action. Setting to zero disable resync. +// BEWARE: too short period time will increase the CPU load. +const informerResyncPeriod = 30 * time.Minute + +func main() { + verbose := flag.Bool("verbose", false, "specify if log verbosely loading configuration") + flag.Parse() + cfg, err := config.Load(*verbose) + fatalOnError(err) + + log := logger.New(&cfg.Logger) + + // create storage factory + storageConfig := storage.ConfigList(cfg.Storage) + sFact, err := storage.NewFactory(&storageConfig) + fatalOnError(err) + + k8sConfig, err := restclient.InClusterConfig() + fatalOnError(err) + + // k8s + k8sClient, err := kubernetes.NewForConfig(k8sConfig) + fatalOnError(err) + nsInformer := v1.NewNamespaceInformer(k8sClient, informerResyncPeriod, cache.Indexers{}) + + // ServiceCatalog + scClientSet, err := scCs.NewForConfig(k8sConfig) + fatalOnError(err) + scSDK := &servicecatalog.SDK{ServiceCatalogClient: scClientSet} + + scInformerFactory := catalogInformers.NewSharedInformerFactory(scClientSet, informerResyncPeriod) + scInformersGroup := scInformerFactory.Servicecatalog().V1beta1() + + // instance populator + instancePopulator := populator.NewInstances(scClientSet, sFact.Instance(), cfg.BrokerName) + popCtx, popCancelFunc := context.WithTimeout(context.Background(), time.Minute) + defer popCancelFunc() + log.Info("Instance storage population...") + err = instancePopulator.Do(popCtx) + fatalOnError(err) + log.Info("Instance storage populated") + + // RemoteEnvironments + reClient, err := versioned.NewForConfig(k8sConfig) + fatalOnError(err) + reInformerFactory := externalversions.NewSharedInformerFactory(reClient, informerResyncPeriod) + reInformersGroup := reInformerFactory.Remoteenvironment().V1alpha1() + + // internal services + relistRequester := syncer.NewRelistRequester(scSDK, cfg.BrokerName, cfg.BrokerRelistDurationWindow, log) + siFacade := broker.NewServiceInstanceFacade(scInformersGroup.ServiceInstances().Informer()) + accessChecker := access.New(sFact.RemoteEnvironment(), reClient.RemoteenvironmentV1alpha1(), sFact.Instance()) + + reSyncCtrl := syncer.New(reInformersGroup.RemoteEnvironments(), sFact.RemoteEnvironment(), sFact.RemoteEnvironment(), relistRequester, log) + labelerCtrl := labeler.New(reInformersGroup.EnvironmentMappings().Informer(), nsInformer, k8sClient.CoreV1().Namespaces(), sFact.RemoteEnvironment(), log) + + // create broker + srv := broker.New(sFact.RemoteEnvironment(), sFact.Instance(), sFact.InstanceOperation(), accessChecker, + reClient.RemoteenvironmentV1alpha1(), siFacade, log) + + // setup graceful shutdown signals + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + + stopCh := make(chan struct{}) + cancelOnInterrupt(ctx, stopCh, cancelFunc) + + // start informers + scInformerFactory.Start(stopCh) + reInformerFactory.Start(stopCh) + go nsInformer.Run(stopCh) + + // wait for cache sync + scInformerFactory.WaitForCacheSync(stopCh) + reInformerFactory.WaitForCacheSync(stopCh) + cache.WaitForCacheSync(stopCh, nsInformer.HasSynced) + + // start services & ctrl + go reSyncCtrl.Run(stopCh) + go labelerCtrl.Run(stopCh) + go relistRequester.Run(stopCh) + + fatalOnError(srv.Run(ctx, fmt.Sprintf(":%d", cfg.Port))) +} + +func fatalOnError(err error) { + if err != nil { + logrus.Fatal(err.Error()) + } +} + +// cancelOnInterrupt closes given channel and also calls cancel func when os.Interrupt or SIGTERM is received +func cancelOnInterrupt(ctx context.Context, ch chan<- struct{}, cancel context.CancelFunc) { + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + go func() { + select { + case <-ctx.Done(): + close(ch) + case <-c: + close(ch) + cancel() + } + }() +} diff --git a/components/remote-environment-broker/cmd/poc-events/events.go b/components/remote-environment-broker/cmd/poc-events/events.go new file mode 100644 index 000000000000..1a91ece6f0c5 --- /dev/null +++ b/components/remote-environment-broker/cmd/poc-events/events.go @@ -0,0 +1,84 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/scheme" + "github.com/pkg/errors" + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/reference" + + "k8s.io/client-go/kubernetes" + + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/record" + + typedV1 "k8s.io/client-go/kubernetes/typed/core/v1" +) + +func main() { + var kubeconfig *string + if home := os.Getenv("HOME"); home != "" { + kubeconfig = flag.String("kubeconfig", filepath.Join(home, ".kube", "config"), "(optional) absolute path to the kubeconfig file") + } else { + kubeconfig = flag.String("kubeconfig", "", "absolute path to the kubeconfig file") + } + + flag.Parse() + + config, err := clientcmd.BuildConfigFromFlags("", *kubeconfig) + if err != nil { + panic(err) + } + + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + panic(err) + } + + reClient, err := versioned.NewForConfig(config) + if err != nil { + panic(err) + } + + broadcaster := record.NewBroadcaster() + broadcaster.StartLogging(func(format string, args ...interface{}) { + fmt.Printf(format, args...) + }) + broadcaster.StartRecordingToSink(&typedV1.EventSinkImpl{Interface: clientset.CoreV1().Events(metav1.NamespaceDefault)}) + eventRecorder := broadcaster.NewRecorder(scheme.Scheme, v1.EventSource{Component: "Remote-Environment-Broker"}) + + re, err := reClient.RemoteenvironmentV1alpha1().RemoteEnvironments().Get("ec-prod", metav1.GetOptions{}) + if err != nil { + panic(errors.Wrap(err, "on getting remote environment")) + } + + re.Status.Conditions = append(re.Status.Conditions, v1alpha1.ReCondition{ + Status: v1alpha1.ConditionTrue, + Type: v1alpha1.Stage1Done, + Message: "Message contains additional information", + Reason: "OneWordCamelCase", + }) + _, err = reClient.RemoteenvironmentV1alpha1().RemoteEnvironments().UpdateStatus(re) + if err != nil { + panic(errors.Wrap(err, "while updating status")) + } + + ref, err := reference.GetReference(scheme.Scheme, re) + if err != nil { + panic(errors.Wrap(err, "on getting reference for Remote Environment")) + } + + eventRecorder.Event(ref, v1.EventTypeWarning, "SomeReason", "Some additional message") + eventRecorder.Event(ref, v1.EventTypeWarning, "SomeReason", "Some additional message") + eventRecorder.Event(ref, v1.EventTypeWarning, "SomeReason", "Some additional message") + + time.Sleep(time.Second) +} diff --git a/components/remote-environment-broker/cmd/poc-events/findings.md b/components/remote-environment-broker/cmd/poc-events/findings.md new file mode 100644 index 000000000000..11f092271c14 --- /dev/null +++ b/components/remote-environment-broker/cmd/poc-events/findings.md @@ -0,0 +1,45 @@ +# POC: CRs status changing and event sending + +## Run +Example can be executed outside of the cluster: +``` +go run cmd/poc-events/events.go +``` +## Results + +### Events +See `events.go` which follow example from: +http://blog.kubernetes.io/2018/01/reporting-errors-using-kubernetes-events.html . + +Beware, that events by default, have **retention set to 1 hour**, see [kube-apiserver configuration options](https://kubernetes.io/docs/reference/generated/kube-apiserver/): +> --event-ttl duration Amount of time to retain events. (default 1h0m0s) + +### Status changing +#### TLDR +Currently changing status of Custom Resource does not work. Probably will be released in k8s v1.10 + +#### Detailed description +Currently changing status of Custom Resource does not work, but [proposal](https://github.com/kubernetes/community/pull/913) is already accepted. +Implementation is also merged to master and tagged with version: "v1.10.0-beta.4". See [Add subresources for custom resources](https://github.com/kubernetes/kubernetes/pull/55168/commits/6fbe8157e39f6bd7ad885a8a6f8614e2fe45dc5e) + +So currently we get following error when calling UpdateStatus method. +```text +panic: while updating status: the server could not find the requested resource (put remoteenvironments.remoteenvironment.kyma.io ec-prod) +``` + +To add `UpdateStatus` method to Remote Environment client, following actions has to be performed: +- add Status field for type `RemoteEnvironment` +- remove annotation `+genclient:noStatus` + +[API Conventions](https://github.com/kubernetes/community/blob/master/contributors/devel/api-conventions.md#spec-and-status) contains information how Status should looks like: + +>Spec and Status: + +>By convention, the Kubernetes API makes a distinction between the specification of the desired state of an object (a nested object field called "spec") and the status of the object at the current time (a nested object field called "status"). +>The PUT and POST verbs on objects MUST ignore the "status" values, to avoid accidentally overwriting the status in read-modify-write scenarios. A /status subresource MUST be provided to enable system components to update statuses of resources they manage. + +>Conditions represent the latest available observations of an object's current state. Objects may report multiple conditions, and new types of conditions may be added in the future. Therefore, conditions are represented using a list/slice, where all have similar structure. + +>The FooCondition type for some resource type Foo must contain at least type and status fields. + +I found [implementation](https://github.com/jetstack/cert-manager/blob/master/pkg/apis/certmanager/v1alpha1/types.go) which follows those conventions about Status field. Currently they are using `Update`, instead `UpdateStatus` which seems to be against conventions. \ No newline at end of file diff --git a/components/remote-environment-broker/cmd/poc-finalizers/controller.go b/components/remote-environment-broker/cmd/poc-finalizers/controller.go new file mode 100644 index 000000000000..ff2365fdec21 --- /dev/null +++ b/components/remote-environment-broker/cmd/poc-finalizers/controller.go @@ -0,0 +1,202 @@ +package main + +import ( + "time" + + "github.com/sirupsen/logrus" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" + "k8s.io/kubernetes/pkg/controller" + + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + v1alpha12 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1" + informers "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/v1alpha1" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" +) + +// FinalizerName is the protection finalizer name +const FinalizerName = "protection-finalizer" + +// Controller add finalizers logic the RemoteEnvironment resources. Blocks deletion until all connected EnvironmentMapping are removed. +type Controller struct { + queue workqueue.RateLimitingInterface + reInformer cache.SharedIndexInformer + emInterface v1alpha12.EnvironmentMappingInterface + reInterface v1alpha12.RemoteEnvironmentInterface + log *logrus.Entry +} + +// NewProtectionController creates protection controller instance +func NewProtectionController(remoteEnvironmentInformer informers.RemoteEnvironmentInformer, + emInterface v1alpha12.EnvironmentMappingInterface, + environmentInterface v1alpha12.RemoteEnvironmentInterface, + log *logrus.Entry) *Controller { + + reInformer := remoteEnvironmentInformer.Informer() + + c := &Controller{ + queue: workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "reprotection"), + reInformer: remoteEnvironmentInformer.Informer(), + emInterface: emInterface, + log: log.WithField("service", "re-protection-controller"), + reInterface: environmentInterface, + } + + reInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + c.log.Infof("Event: added") + c.reAddedUpdated(obj) + }, + UpdateFunc: func(old, new interface{}) { + c.log.Infof("Event: updated") + c.reAddedUpdated(new) + }, + DeleteFunc: func(obj interface{}) { + c.log.Infof("Event: deleted") + }, + }) + + return c +} + +func (c *Controller) reAddedUpdated(obj interface{}) { + re, ok := obj.(*v1alpha1.RemoteEnvironment) + if !ok { + c.log.Errorf("RE informer returned non-RemoteEnvironment object: %#v", obj) + return + } + key, err := cache.MetaNamespaceKeyFunc(re) + if err != nil { + c.log.Errorf("couldn't get key for Remote Environment %#v: %v", re, err) + return + } + c.log.Infof("Got RemoteEnvironment: %s", key) + + if needToAddFinalizer(re) || isDeletionCandidate(re) { + c.queue.AddRateLimited(key) + } +} + +// Run runs the controller +func (c *Controller) Run(workers int, stopCh <-chan struct{}) { + defer utilruntime.HandleCrash() + defer c.queue.ShutDown() + + c.log.Info("Starting protection controller") + defer c.log.Info("Shutting down protection controller") + + if !controller.WaitForCacheSync("Protection", stopCh) { + return + } + + for i := 0; i < workers; i++ { + go wait.Until(c.runWorker, time.Second, stopCh) + } + c.reInformer.Run(stopCh) + + <-stopCh +} + +func (c *Controller) runWorker() { + for c.processNextWorkItem() { + } +} + +// processNextWorkItem process item from the queue and returns false when it's time to quit. +func (c *Controller) processNextWorkItem() bool { + key, quit := c.queue.Get() + if quit { + return false + } + defer c.queue.Done(key) + + err := c.processRE(key.(string)) + if err != nil { + c.log.Errorf("Could not process RemoteEnvironment %s: %s", key, err.Error()) + c.queue.AddRateLimited(key) + return true + } + + c.queue.Forget(key) + return true +} + +func (c *Controller) processRE(key string) error { + _, reName, err := cache.SplitMetaNamespaceKey(key) + if err != nil { + return err + } + + re, err := c.reInterface.Get(reName, v1.GetOptions{}) + if err != nil { + return err + } + + if needToAddFinalizer(re) { + clone := re.DeepCopy() + clone.ObjectMeta.Finalizers = append(clone.ObjectMeta.Finalizers, FinalizerName) + c.log.Info("Adding finalizer") + _, err := c.reInterface.Update(clone) + c.log.Infof("Finalizer added") + if err != nil { + return err + } + } + + if isDeletionCandidate(re) { + items, _ := c.emInterface.List(v1.ListOptions{}) + exists := false + + // find if environment mapping exists + for _, item := range items.Items { + if item.Name == reName { + exists = true + break + } + } + + // if EnvironmentMapping does not exists - remove finalizer + if !exists { + clone := re.DeepCopy() + clone.ObjectMeta.Finalizers = removeString(clone.ObjectMeta.Finalizers, FinalizerName) + + c.log.Info("Removing finalizer") + _, err := c.reInterface.Update(clone) + c.log.Infof("Finalizer removed") + if err != nil { + return err + } + } + } + return nil +} + +func isDeletionCandidate(re *v1alpha1.RemoteEnvironment) bool { + return re.ObjectMeta.DeletionTimestamp != nil && containsString(re.ObjectMeta.Finalizers, FinalizerName) +} + +func needToAddFinalizer(re *v1alpha1.RemoteEnvironment) bool { + return re.ObjectMeta.DeletionTimestamp == nil && !containsString(re.ObjectMeta.Finalizers, FinalizerName) +} + +func removeString(slice []string, s string) []string { + newSlice := make([]string, 0) + for _, item := range slice { + if item == s { + continue + } + newSlice = append(newSlice, item) + } + return newSlice +} + +func containsString(slice []string, s string) bool { + for _, item := range slice { + if item == s { + return true + } + } + return false +} diff --git a/components/remote-environment-broker/cmd/poc-finalizers/finalizers-findings.md b/components/remote-environment-broker/cmd/poc-finalizers/finalizers-findings.md new file mode 100644 index 000000000000..85028b507079 --- /dev/null +++ b/components/remote-environment-broker/cmd/poc-finalizers/finalizers-findings.md @@ -0,0 +1,94 @@ +## Finalizers + +### Overview + +Finalizers concept is described in the [k8s finalizers page](https://kubernetes.io/docs/tasks/access-kubernetes-api/extend-api-custom-resource-definitions/#finalizers). This pull branch contains a separate cmd, which implements protection controller. Such controller plays with finalizers +Finalizers allow controllers to implement asynchronous pre-delete hooks. Custom objects support finalizers just like built-in objects. + +### Description + +The protection controller adds a finalizer to RemoteEnvironment, which checks, if any EnvironmentMapping exists. It there is no EnvironmentMapping with the same name - finalizer is removed and object is deleted. + +The protection controller implementation is similar to the [k8s PVC protection controller](https://github.com/kubernetes/kubernetes/blob/f4472b1a92877ed4b1576e7e44496b0de7a8efe2/pkg/controller/volume/pvcprotection/pvc_protection_controller.go) + +### Building and running + +Build: +```bash +go build cmd/poc-finalizers/main.go cmd/poc-finalizers/controller.go +``` + +Run: +```bash +./main +``` + +### How the controller works + +The protection controller listens on RemoteEnvironment events. In case of an update, checks if must be performed any change on the object. If the object is just created - the controller adds the finalizer. If the object is being deleted (`metadata.deletionTimestamp` is set) - the controller checks, if the object can be deleted. If yes - removes the finalizer. The k8s resource update is safe because an optimistick lock failure will occur. It can be handled by `IsConflict` from `k8s.io/apimachinery/pkg/api/errors` package. You can find more about optimistic lock in [Resource Operation section k8s api doc](https://v1-10.docs.kubernetes.io/docs/reference/generated/kubernetes-api/v1.10/#resource-operations). + +### Demo + +1. Run the controller: +Run: +```bash +> ./main +``` + +2. Create remote environment resource +```bash +> kubectl apply -f cmd/poc-finalizers/remote-environment-prod.yaml +``` + +3. Check, if finalizers were applied +```bash +> kubectl get re ec-prod -o jsonpath='{.metadata.finalizers}'; echo +[protection-finalizer] +``` + +4. Create EnvironmentMapping, which blocks RemoteEnvironment deletion +```bash +> kubectl apply -f cmd/poc-finalizers/mapping-prod.yaml +``` + +5. Try to remove RemoteEnvironment +```bash +> kubectl delete re ec-prod +remoteenvironment "ec-prod" deleted +``` + +6. RemoteEnvironment still exists, let's check the metadata of the object: +```bash +> kubectl describe re ec-prod +Metadata: + Cluster Name: + Creation Timestamp: 2018-03-19T10:11:59Z + Deletion Grace Period Seconds: 0 + Deletion Timestamp: 2018-03-19T10:26:09Z + Finalizers: + protection-finalizer + Generation: 0 + Resource Version: 13479 + Self Link: /apis/remoteenvironment.kyma.io/v1alpha1/ec-prod + UID: f5eaea15-2b5d-11e8-9892-080027ab8e2d +``` + +7. The finalizers was not removed because EnvironmentMapping still exists. Let's remove EnvironmentMapping: +```bash +> kubectl delete em ec-prod +``` + +8. and do update on RemoteEnvironment (the controller is not watching EnvironmentMapping, which must be done in the production code) to trigger the controller: +```bash +> kubectl delete em ec-prod +environmentmapping "ec-prod" deleted + +> kubectl label re ec-prod my-label=awesome +remoteenvironment "ec-prod" labeled +``` + +9. List RemoteEnvironments: +```bash +> kubectl get re +No resources found. +``` diff --git a/components/remote-environment-broker/cmd/poc-finalizers/main.go b/components/remote-environment-broker/cmd/poc-finalizers/main.go new file mode 100644 index 000000000000..53ec3934fde8 --- /dev/null +++ b/components/remote-environment-broker/cmd/poc-finalizers/main.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "flag" + "os" + "os/signal" + "syscall" + "time" + + "path/filepath" + + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/informers/externalversions" + "github.com/sirupsen/logrus" + restclient "k8s.io/client-go/rest" + + "k8s.io/client-go/tools/clientcmd" +) + +// informerResyncPeriod defines how often informer will execute relist action. Setting to zero disable resync. +// BEWARE: too short period time will increase the CPU load. +const informerResyncPeriod = 30 * time.Minute + +func main() { + var kubeconfig *string + if home := os.Getenv("HOME"); home != "" { + kubeconfig = flag.String("kubeconfig", filepath.Join(home, ".kube", "config"), "(optional) absolute path to the kubeconfig file") + } else { + kubeconfig = flag.String("kubeconfig", "", "absolute path to the kubeconfig file") + } + flag.Parse() + + log := (&logrus.Logger{ + Out: os.Stdout, + Formatter: &logrus.TextFormatter{ + FullTimestamp: true, + TimestampFormat: time.StampMicro, + }, + Hooks: make(logrus.LevelHooks), + Level: logrus.InfoLevel, + }).WithField("service", "main") + + // create sync-job + k8sConfig := newRestClientConfig(*kubeconfig) + + reClient, err := versioned.NewForConfig(k8sConfig) + fatalOnError(err) + + // Always prefer using an informer factory to get a shared informer instead of getting an independent + // one. This reduces memory footprint and number of connections to the server. + informerFactory := externalversions.NewSharedInformerFactory(reClient, informerResyncPeriod) + + v1alpha1Interface := informerFactory.Remoteenvironment().V1alpha1() + + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + stopCh := make(chan struct{}) + cancelOnInterrupt(ctx, stopCh, cancelFunc) + + /* protection controller */ + protectionController := NewProtectionController(v1alpha1Interface.RemoteEnvironments(), + reClient.RemoteenvironmentV1alpha1().EnvironmentMappings("default"), + reClient.RemoteenvironmentV1alpha1().RemoteEnvironments(), log) + protectionController.Run(1, stopCh) + + informerFactory.Start(stopCh) + + <-stopCh +} + +func fatalOnError(err error) { + if err != nil { + logrus.Fatal(err.Error()) + } +} + +// cancelOnInterrupt closes given channel and also calls cancel func when os.Interrupt or SIGTERM is received +func cancelOnInterrupt(ctx context.Context, ch chan<- struct{}, cancel context.CancelFunc) { + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + go func() { + select { + case <-ctx.Done(): + close(ch) + case <-c: + close(ch) + cancel() + } + }() +} + +func newRestClientConfig(kubeconfigPath string) *restclient.Config { + var config *restclient.Config + var err error + if kubeconfigPath != "" { + config, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath) + } else { + config, err = restclient.InClusterConfig() + } + fatalOnError(err) + return config +} diff --git a/components/remote-environment-broker/cmd/poc-finalizers/mapping-prod.yaml b/components/remote-environment-broker/cmd/poc-finalizers/mapping-prod.yaml new file mode 100644 index 000000000000..8f35de51b650 --- /dev/null +++ b/components/remote-environment-broker/cmd/poc-finalizers/mapping-prod.yaml @@ -0,0 +1,5 @@ +apiVersion: remoteenvironment.kyma.io/v1alpha1 +kind: EnvironmentMapping +metadata: + # Remote Environment "ec-prod" is enabled in "production" environment + name: ec-prod diff --git a/components/remote-environment-broker/cmd/poc-finalizers/remote-environment-prod.yaml b/components/remote-environment-broker/cmd/poc-finalizers/remote-environment-prod.yaml new file mode 100644 index 000000000000..46266442c362 --- /dev/null +++ b/components/remote-environment-broker/cmd/poc-finalizers/remote-environment-prod.yaml @@ -0,0 +1,55 @@ +apiVersion: remoteenvironment.kyma.io/v1alpha1 +kind: RemoteEnvironment +metadata: + name: ec-prod +spec: + # source identifies remote environment in the cluster + source: + environment: "prod" + type: "commerce" + namespace: "com.hakuna.matata" + + description: "EC production" + + # accessLabel is used to label namespace + accessLabel: "access-label-ec-prod" + + # list of services + services: + # mandatory, unique ID + - id: "ac031e8c-9aa4-4cb7-8999-0d358726ffaa" # required, uuid, immutable + + # required, mapped to metadata.displayName, used by catalog UI + displayName: "Promotions" + # required, mapped to metadata.longDescription, used by catalog UI + longDescription: "Promotions APIs" + # required, maps to metadata.providerDisplayName, provider name in catalog UI + providerDisplayName: "HakunaMatata" + + # mapped to tags, not required + tags: + - occ + - promotions + + # Entries defines, what can be enabled by activation (instantiating and/or binding) of the service + entries: + # type API defines remote API + - type: API + # the url, which can be used by service/lambda to make a call to remote API + gatewayUrl: "http://promotions-gateway.production.svc.cluster.local/" + # used in istio rules, generated by Wormhole, must be unique in k8s cluster + accessLabel: "access-label-1" + - type: Events + + # second service provided by remote environment + - id: "48ab05bf-9aa4-4cb7-8999-0d3587265ac3" + displayName: "Orders" + longDescription: "Orders API" + providerDisplayName: "HakunaMatata" + tags: + - tag1 + entries: + - type: API + gatewayUrl: "http://orders-gateway.production.svc.cluster.local/" + accessLabel: "access-label-2" + diff --git a/components/remote-environment-broker/contrib/examples/binding.yaml b/components/remote-environment-broker/contrib/examples/binding.yaml new file mode 100644 index 000000000000..5a68c66c1e0d --- /dev/null +++ b/components/remote-environment-broker/contrib/examples/binding.yaml @@ -0,0 +1,8 @@ +apiVersion: servicecatalog.k8s.io/v1beta1 +kind: ServiceBinding +metadata: + name: promotions-service + namespace: production +spec: + instanceRef: + name: promotions-service diff --git a/components/remote-environment-broker/contrib/examples/event-activation.yaml b/components/remote-environment-broker/contrib/examples/event-activation.yaml new file mode 100644 index 000000000000..5c549156d538 --- /dev/null +++ b/components/remote-environment-broker/contrib/examples/event-activation.yaml @@ -0,0 +1,13 @@ +apiVersion: remoteenvironment.kyma.cx/v1alpha1 +kind: EventActivation +metadata: + # name is exactly the same as RemoteEnvironment service ID + name: "ac031e8c-9aa4-4cb7-8999-0d358726ffaa" + namespace: production +spec: + displayName: "Orders" + # source identifies remote environment in the cluster + source: + environment: "prod" + type: "commerce" + namespace: "com.hakuna.matata" \ No newline at end of file diff --git a/components/remote-environment-broker/contrib/examples/mapping.yaml b/components/remote-environment-broker/contrib/examples/mapping.yaml new file mode 100644 index 000000000000..3c83b8abce37 --- /dev/null +++ b/components/remote-environment-broker/contrib/examples/mapping.yaml @@ -0,0 +1,8 @@ +apiVersion: remoteenvironment.kyma.cx/v1alpha1 +kind: EnvironmentMapping +metadata: + # Instance of the EnvrionmentMapping enables + # Remote Environment in the given namespace. + # The name must be the same as the name of RemoteEnvironment. + name: ec-prod + namespace: production diff --git a/components/remote-environment-broker/contrib/examples/osb-catalog-response.json b/components/remote-environment-broker/contrib/examples/osb-catalog-response.json new file mode 100644 index 000000000000..c1f217b74e34 --- /dev/null +++ b/components/remote-environment-broker/contrib/examples/osb-catalog-response.json @@ -0,0 +1,61 @@ +[ +{ + "name": "ac031e8c-9aa4-4cb7-8999-0d358726ffaa", + "id": "ac031e8c-9aa4-4cb7-8999-0d358726ffaa", + "description": "All OCC APIs", + "metadata": { + "displayName": "Promotions", + "longDescription": "Promotions APIs", + "providerDisplayName": "HakunaMatata", + + "bindingLabels": { + "access-label-1" : "true" + }, + "remoteEnvironmentServiceId": "ac031e8c-9aa4-4cb7-8999-0d358726ffaa", + "source": { + "environment": "production", + "type": "commerce", + "namespace": "com.hakuna.matata" + } + }, + "tags": ["occ", "promotions"], + + "plans":[ + { + "name": "default", + "id": "global unique GUID", + "description": "Default plan", + "metadata": { + "displayName": "Default" + } + } + ] +}, + { + "name": "48ab05bf-9aa4-4cb7-8999-0d3587265ac3", + "id": "48ab05bf-9aa4-4cb7-8999-0d3587265ac3", + "description": "Orders API", + "metadata": { + "displayName": "Orders", + "longDescription": "Orders API", + "providerDisplayName": "HakunaMatata", + + "bindingLabels": { + "access-label-1" : "true" + }, + "remoteEnvironmentServiceId": "48ab05bf-9aa4-4cb7-8999-0d3587265ac3" + }, + "tags": [], + + "plans":[ + { + "name": "default", + "id": "global unique GUID", + "description": "Default plan", + "metadata": { + "displayName": "Default" + } + } + ] + } +] \ No newline at end of file diff --git a/components/remote-environment-broker/contrib/examples/remote-env.yaml b/components/remote-environment-broker/contrib/examples/remote-env.yaml new file mode 100644 index 000000000000..b32b2a518aa3 --- /dev/null +++ b/components/remote-environment-broker/contrib/examples/remote-env.yaml @@ -0,0 +1,52 @@ +apiVersion: remoteenvironment.kyma.cx/v1alpha1 +kind: RemoteEnvironment +metadata: + name: ec-prod +spec: + accessLabel: access-label-1 + + # source identifies remote environment in the cluster + source: + environment: "production" + type: "commerce" + namespace: "com.hakuna.matata" + + description: "EC description" + + # list of services + services: + # mandatory, unique ID + - id: "ac031e8c-9aa4-4cb7-8999-0d358726ffaa" # required, uuid, immutable + + # required, mapped to metadata.displayName, used by catalog UI + displayName: "Promotions" + # required, mapped to metadata.longDescription, used by catalog UI + longDescription: "Promotions APIs" + # mandatory, maps to metadata.providerDisplayName, provider name in catalog UI + providerDisplayName: "HakunaMatata" + + # mapped to tags, not required + tags: + - occ + - promotions + + # Entries defines, what can be enabled by activation (instantiating and/or binding) of the service + entries: + # type API defines remote API + - type: API + # the url, which can be used by service/lambda to make a call to remote API + gatewayUrl: "http://promotions-gateway.production.svc.cluster.local/" + # used in istio rules, generated by Wormhole, must be unique in k8s cluster + accessLabel: "access-label-1" + - type: Events + + # second service provided by remote environment + - id: "48ab05bf-9aa4-4cb7-8999-0d3587265ac3" + displayName: "Orders" + longDescription: "Orders API" + providerDisplayName: "HakunaMatata" + entries: + - type: API + gatewayUrl: "http://orders-gateway.production.svc.cluster.local/" + accessLabel: "access-label-2" + diff --git a/components/remote-environment-broker/contrib/examples/service-instance.yaml b/components/remote-environment-broker/contrib/examples/service-instance.yaml new file mode 100644 index 000000000000..a728c450fa14 --- /dev/null +++ b/components/remote-environment-broker/contrib/examples/service-instance.yaml @@ -0,0 +1,14 @@ +apiVersion: servicecatalog.k8s.io/v1beta1 +kind: ServiceInstance +metadata: + name: promotions-service + namespace: production +spec: + # References one of the previously returned services + clusterServiceClassExternalName: promotions + clusterServicePlanExternalName: default + parameters: + ##### + # Additional parameters can be added here, + # which may be used by the service broker. + #### diff --git a/components/remote-environment-broker/deploy/broker/Dockerfile b/components/remote-environment-broker/deploy/broker/Dockerfile new file mode 100644 index 000000000000..82efcd078ed6 --- /dev/null +++ b/components/remote-environment-broker/deploy/broker/Dockerfile @@ -0,0 +1,11 @@ +FROM alpine:3.7 + +RUN apk --no-cache add ca-certificates +RUN apk add --no-cache curl + +# Variables used for labeling created images +LABEL source=git@github.com:kyma-project/kyma.git + +COPY ./remote-environment-broker /root/remote-environment-broker + +ENTRYPOINT ["/root/remote-environment-broker"] \ No newline at end of file diff --git a/components/remote-environment-broker/hack/boilerplate.go.txt b/components/remote-environment-broker/hack/boilerplate.go.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/components/remote-environment-broker/hack/generate-groups.sh b/components/remote-environment-broker/hack/generate-groups.sh new file mode 100755 index 000000000000..bf4890248c5d --- /dev/null +++ b/components/remote-environment-broker/hack/generate-groups.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +# This file was copied from k8s.io/code-generator project +# The only one modification was to specify path in `go install` execution (line 52). + +# Copyright 2017 The Kubernetes Authors. +# +# 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. + +set -o errexit +set -o nounset +set -o pipefail + +# generate-groups generates everything for a project with external types only, e.g. a project based +# on CustomResourceDefinitions. + +if [ "$#" -lt 4 ] || [ "${1}" == "--help" ]; then + cat < ... + + the generators comma separated to run (deepcopy,defaulter,client,lister,informer) or "all". + the output package name (e.g. github.com/example/project/pkg/generated). + the external types dir (e.g. github.com/example/api or github.com/example/project/pkg/apis). + the groups and their versions in the format "groupA:v1,v2 groupB:v1 groupC:v2", relative + to . + ... arbitrary flags passed to all generator binaries. + + +Examples: + $(basename $0) all github.com/example/project/pkg/client github.com/example/project/pkg/apis "foo:v1 bar:v1alpha1,v1beta1" + $(basename $0) deepcopy,client github.com/example/project/pkg/client github.com/example/project/pkg/apis "foo:v1 bar:v1alpha1,v1beta1" +EOF + exit 0 +fi + +GENS="$1" +OUTPUT_PKG="$2" +APIS_PKG="$3" +GROUPS_WITH_VERSIONS="$4" +shift 4 + +go install ./vendor/k8s.io/code-generator/cmd/{defaulter-gen,client-gen,lister-gen,informer-gen,deepcopy-gen} +function codegen::join() { local IFS="$1"; shift; echo "$*"; } + +# enumerate group versions +FQ_APIS=() # e.g. k8s.io/api/apps/v1 +for GVs in ${GROUPS_WITH_VERSIONS}; do + IFS=: read G Vs <<<"${GVs}" + + # enumerate versions + for V in ${Vs//,/ }; do + FQ_APIS+=(${APIS_PKG}/${G}/${V}) + done +done + +if [ "${GENS}" = "all" ] || grep -qw "deepcopy" <<<"${GENS}"; then + echo "Generating deepcopy funcs" + ${GOPATH}/bin/deepcopy-gen --input-dirs $(codegen::join , "${FQ_APIS[@]}") -O zz_generated.deepcopy --bounding-dirs ${APIS_PKG} "$@" +fi + +if [ "${GENS}" = "all" ] || grep -qw "client" <<<"${GENS}"; then + echo "Generating clientset for ${GROUPS_WITH_VERSIONS} at ${OUTPUT_PKG}/clientset" + ${GOPATH}/bin/client-gen --clientset-name versioned --input-base "" --input $(codegen::join , "${FQ_APIS[@]}") --clientset-path ${OUTPUT_PKG}/clientset "$@" +fi + +if [ "${GENS}" = "all" ] || grep -qw "lister" <<<"${GENS}"; then + echo "Generating listers for ${GROUPS_WITH_VERSIONS} at ${OUTPUT_PKG}/listers" + ${GOPATH}/bin/lister-gen --input-dirs $(codegen::join , "${FQ_APIS[@]}") --output-package ${OUTPUT_PKG}/listers "$@" +fi + +if [ "${GENS}" = "all" ] || grep -qw "informer" <<<"${GENS}"; then + echo "Generating informers for ${GROUPS_WITH_VERSIONS} at ${OUTPUT_PKG}/informers" + ${GOPATH}/bin/informer-gen \ + --input-dirs $(codegen::join , "${FQ_APIS[@]}") \ + --versioned-clientset-package ${OUTPUT_PKG}/clientset/versioned \ + --listers-package ${OUTPUT_PKG}/listers \ + --output-package ${OUTPUT_PKG}/informers \ + "$@" +fi diff --git a/components/remote-environment-broker/hack/update-codegen.sh b/components/remote-environment-broker/hack/update-codegen.sh new file mode 100755 index 000000000000..950b5ccf60c8 --- /dev/null +++ b/components/remote-environment-broker/hack/update-codegen.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -o errexit +set -o nounset +set -o pipefail + +SCRIPT_ROOT=$(dirname ${BASH_SOURCE})/.. +CODEGEN_PKG=${CODEGEN_PKG:-$(cd ${SCRIPT_ROOT}; ls -d -1 ./vendor/k8s.io/code-generator 2>/dev/null || echo ${GOPATH}/src/k8s.io/code-generator)} +REB_ROOT_PKG="github.com/kyma-project/kyma/components/remote-environment-broker/pkg" + +./hack/generate-groups.sh all \ + ${REB_ROOT_PKG}/client ${REB_ROOT_PKG}/apis \ + remoteenvironment:v1alpha1 \ + --go-header-file ${SCRIPT_ROOT}/hack/boilerplate.go.txt \ No newline at end of file diff --git a/components/remote-environment-broker/internal/access/access.go b/components/remote-environment-broker/internal/access/access.go new file mode 100644 index 000000000000..4885d9858cf0 --- /dev/null +++ b/components/remote-environment-broker/internal/access/access.go @@ -0,0 +1,60 @@ +package access + +import ( + "time" + + "github.com/pkg/errors" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + versioned "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1" +) + +//go:generate mockery -name=ProvisionChecker -output=automock -outpkg=automock -case=underscore + +// ProvisionChecker define methods for checking if provision can succeed +type ProvisionChecker interface { + CanProvision(iID internal.InstanceID, rsID internal.RemoteServiceID, namespace internal.Namespace, maxWaitTime time.Duration) (CanProvisionOutput, error) +} + +// CanProvisionOutput aggregates following information: if provision can be performed and reason +type CanProvisionOutput struct { + Allowed bool + Reason string +} + +// New creates new aggregated checker +func New(reFinder remoteEnvironmentFinder, reInterface versioned.RemoteenvironmentV1alpha1Interface, iFind instanceFinder) *AggregatedChecker { + return &AggregatedChecker{ + mappingExistsProvisionChecker: NewMappingExistsProvisionChecker(reFinder, reInterface), + uniquenessProvisionChecker: NewUniquenessProvisionChecker(iFind), + } +} + +// AggregatedChecker is a checker which aggregates multiple checks. +// All checks are performed sequentially. First failed check stops further ones. +type AggregatedChecker struct { + mappingExistsProvisionChecker interface { + CanProvision(rsID internal.RemoteServiceID, ns internal.Namespace, maxWaitTime time.Duration) (CanProvisionOutput, error) + } + uniquenessProvisionChecker interface { + CanProvision(iID internal.InstanceID, rsID internal.RemoteServiceID, ns internal.Namespace) (CanProvisionOutput, error) + } +} + +// CanProvision performs actual check +func (c *AggregatedChecker) CanProvision(iID internal.InstanceID, rsID internal.RemoteServiceID, ns internal.Namespace, maxWaitTime time.Duration) (CanProvisionOutput, error) { + res, err := c.mappingExistsProvisionChecker.CanProvision(rsID, ns, maxWaitTime) + if err != nil { + return CanProvisionOutput{}, errors.Wrap(err, "while calling mappingExistsProvisionChecker") + } + if !res.Allowed { + return res, nil + } + + res, err = c.uniquenessProvisionChecker.CanProvision(iID, rsID, ns) + if err != nil { + return CanProvisionOutput{}, errors.Wrap(err, "while calling uniquenessProvisionChecker") + } + + return res, nil +} diff --git a/components/remote-environment-broker/internal/access/automock/provision_checker.go b/components/remote-environment-broker/internal/access/automock/provision_checker.go new file mode 100644 index 000000000000..e05a3a5bb7d2 --- /dev/null +++ b/components/remote-environment-broker/internal/access/automock/provision_checker.go @@ -0,0 +1,33 @@ +// Code generated by mockery v1.0.0 +package automock + +import access "github.com/kyma-project/kyma/components/remote-environment-broker/internal/access" +import internal "github.com/kyma-project/kyma/components/remote-environment-broker/internal" +import mock "github.com/stretchr/testify/mock" +import time "time" + +// ProvisionChecker is an autogenerated mock type for the ProvisionChecker type +type ProvisionChecker struct { + mock.Mock +} + +// CanProvision provides a mock function with given fields: iID, rsID, namespace, maxWaitTime +func (_m *ProvisionChecker) CanProvision(iID internal.InstanceID, rsID internal.RemoteServiceID, namespace internal.Namespace, maxWaitTime time.Duration) (access.CanProvisionOutput, error) { + ret := _m.Called(iID, rsID, namespace, maxWaitTime) + + var r0 access.CanProvisionOutput + if rf, ok := ret.Get(0).(func(internal.InstanceID, internal.RemoteServiceID, internal.Namespace, time.Duration) access.CanProvisionOutput); ok { + r0 = rf(iID, rsID, namespace, maxWaitTime) + } else { + r0 = ret.Get(0).(access.CanProvisionOutput) + } + + var r1 error + if rf, ok := ret.Get(1).(func(internal.InstanceID, internal.RemoteServiceID, internal.Namespace, time.Duration) error); ok { + r1 = rf(iID, rsID, namespace, maxWaitTime) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/remote-environment-broker/internal/access/automock/remote_environment_finder.go b/components/remote-environment-broker/internal/access/automock/remote_environment_finder.go new file mode 100644 index 000000000000..c34205270729 --- /dev/null +++ b/components/remote-environment-broker/internal/access/automock/remote_environment_finder.go @@ -0,0 +1,33 @@ +// Code generated by mockery v1.0.0 +package automock + +import internal "github.com/kyma-project/kyma/components/remote-environment-broker/internal" +import mock "github.com/stretchr/testify/mock" + +// remoteEnvironmentFinder is an autogenerated mock type for the remoteEnvironmentFinder type +type RemoteEnvironmentFinder struct { + mock.Mock +} + +// FindOneByServiceID provides a mock function with given fields: id +func (_m *RemoteEnvironmentFinder) FindOneByServiceID(id internal.RemoteServiceID) (*internal.RemoteEnvironment, error) { + ret := _m.Called(id) + + var r0 *internal.RemoteEnvironment + if rf, ok := ret.Get(0).(func(internal.RemoteServiceID) *internal.RemoteEnvironment); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*internal.RemoteEnvironment) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(internal.RemoteServiceID) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/remote-environment-broker/internal/access/provision_mapping.go b/components/remote-environment-broker/internal/access/provision_mapping.go new file mode 100644 index 000000000000..dcf6597fa762 --- /dev/null +++ b/components/remote-environment-broker/internal/access/provision_mapping.go @@ -0,0 +1,93 @@ +package access + +import ( + "fmt" + "time" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + versioned "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1" + "github.com/pkg/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/tools/cache" +) + +//go:generate mockery -name=remoteEnvironmentFinder -output=automock -outpkg=automock -case=underscore +type remoteEnvironmentFinder interface { + FindOneByServiceID(id internal.RemoteServiceID) (*internal.RemoteEnvironment, error) +} + +// NewMappingExistsProvisionChecker creates new access checker +func NewMappingExistsProvisionChecker(reFinder remoteEnvironmentFinder, reInterface versioned.RemoteenvironmentV1alpha1Interface) *MappingExistsProvisionChecker { + return &MappingExistsProvisionChecker{ + reInterface: reInterface, + reFinder: reFinder, + } +} + +// MappingExistsProvisionChecker is a checker which can wait some time for EnvironmentMapping before it forbids provisioning +type MappingExistsProvisionChecker struct { + reInterface versioned.RemoteenvironmentV1alpha1Interface + reFinder remoteEnvironmentFinder +} + +// CanProvision checks if service instance can be provisioned in the namespace +func (c *MappingExistsProvisionChecker) CanProvision(serviceID internal.RemoteServiceID, namespace internal.Namespace, maxWaitTime time.Duration) (CanProvisionOutput, error) { + re, err := c.reFinder.FindOneByServiceID(serviceID) + if err != nil { + return CanProvisionOutput{}, errors.Wrapf(err, "while finding remote environment which contains service [%s]", serviceID) + } + if re == nil { + return CanProvisionOutput{}, fmt.Errorf("cannot find remote environment which contains service serviceID: [%s]", serviceID) + + } + demandedRemoteEnvName := string(re.Name) + + lw := &cache.ListWatch{ + ListFunc: func(options metav1.ListOptions) (runtime.Object, error) { + return c.reInterface.EnvironmentMappings(string(namespace)).List(options) + }, + WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) { + return c.reInterface.EnvironmentMappings(string(namespace)).Watch(options) + }, + } + + _, err = cache.ListWatchUntil(maxWaitTime, lw, func(event watch.Event) (bool, error) { + deepCopy := event.Object.DeepCopyObject() + envMapping, ok := deepCopy.(*v1alpha1.EnvironmentMapping) + if !ok { + return false, fmt.Errorf("cannot covert object [%+v] of type %T to *EnvironmentMapping", deepCopy, deepCopy) + } + + if envMapping.Name == demandedRemoteEnvName { + return true, nil + } + return false, nil + }) + + switch err { + case nil: + return c.responseAllow(), nil + case wait.ErrWaitTimeout: + return c.responseDeny(namespace), nil + default: + return CanProvisionOutput{}, errors.Wrapf(err, "while watching for EnvironmentMapping with name: [%s] in the namespace [%s]", demandedRemoteEnvName, namespace) + } + +} + +func (c *MappingExistsProvisionChecker) responseAllow() CanProvisionOutput { + return CanProvisionOutput{ + Allowed: true, + } +} + +func (c *MappingExistsProvisionChecker) responseDeny(ns internal.Namespace) CanProvisionOutput { + return CanProvisionOutput{ + Allowed: false, + Reason: fmt.Sprintf("EnvironmentMapping does not exist in the [%s] namespace", ns), + } +} diff --git a/components/remote-environment-broker/internal/access/provision_mapping_test.go b/components/remote-environment-broker/internal/access/provision_mapping_test.go new file mode 100644 index 000000000000..fb24f8f38b8f --- /dev/null +++ b/components/remote-environment-broker/internal/access/provision_mapping_test.go @@ -0,0 +1,111 @@ +package access_test + +import ( + "testing" + "time" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/access" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/access/automock" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/fake" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestMappingExistsProvisionCheckerWhenProvisionAcceptable(t *testing.T) { + // GIVEN + re := fixRemoteEnv() + rm := fixProdMapping() + mockClientSet := fake.NewSimpleClientset(re, rm) + + mockREStorage := &automock.RemoteEnvironmentFinder{} + defer mockREStorage.AssertExpectations(t) + mockREStorage.On("FindOneByServiceID", fixRemoteServiceID()).Return(fixRemoteEnvModel(), nil) + + sut := access.NewMappingExistsProvisionChecker(mockREStorage, mockClientSet.RemoteenvironmentV1alpha1()) + // WHEN + canProvisionOutput, err := sut.CanProvision(fixRemoteServiceID(), internal.Namespace(fixProdNs()), time.Nanosecond) + // THEN + assert.NoError(t, err) + assert.True(t, canProvisionOutput.Allowed) +} + +func TestMappingExistsProvisionCheckerWhenProvisionNotAcceptable(t *testing.T) { + // GIVEN + re := fixRemoteEnv() + rm := fixProdMapping() + mockClientSet := fake.NewSimpleClientset(re, rm) + + mockREStorage := &automock.RemoteEnvironmentFinder{} + defer mockREStorage.AssertExpectations(t) + mockREStorage.On("FindOneByServiceID", fixRemoteServiceID()).Return(fixRemoteEnvModel(), nil) + + sut := access.NewMappingExistsProvisionChecker(mockREStorage, mockClientSet.RemoteenvironmentV1alpha1()) + // WHEN + canProvisionOutput, err := sut.CanProvision(fixRemoteServiceID(), internal.Namespace("stage"), time.Nanosecond) + // THEN + assert.NoError(t, err) + assert.False(t, canProvisionOutput.Allowed) + assert.Equal(t, "EnvironmentMapping does not exist in the [stage] namespace", canProvisionOutput.Reason) +} + +func TestMappingExistsProvisionCheckerReturnsErrorWhenRENotFound(t *testing.T) { + // GIVEN + mockREStorage := &automock.RemoteEnvironmentFinder{} + defer mockREStorage.AssertExpectations(t) + mockREStorage.On("FindOneByServiceID", fixRemoteServiceID()).Return(nil, nil) + + sut := access.NewMappingExistsProvisionChecker(mockREStorage, nil) + // WHEN + _, err := sut.CanProvision(fixRemoteServiceID(), internal.Namespace("ns"), time.Nanosecond) + // THEN + assert.Error(t, err) +} + +func fixRemoteEnv() *v1alpha1.RemoteEnvironment { + return &v1alpha1.RemoteEnvironment{ + ObjectMeta: metav1.ObjectMeta{ + Name: fixREName(), + }, + Spec: v1alpha1.RemoteEnvironmentSpec{ + Services: []v1alpha1.Service{ + { + ID: "service-id", + }, + }, + }, + } +} + +func fixRemoteEnvModel() *internal.RemoteEnvironment { + return &internal.RemoteEnvironment{ + Name: internal.RemoteEnvironmentName(fixREName()), + Services: []internal.Service{ + { + ID: internal.RemoteServiceID("service-id"), + }, + }, + } +} + +func fixRemoteServiceID() internal.RemoteServiceID { + return internal.RemoteServiceID("service-id") +} + +func fixProdMapping() *v1alpha1.EnvironmentMapping { + return &v1alpha1.EnvironmentMapping{ + ObjectMeta: metav1.ObjectMeta{ + Name: fixREName(), + Namespace: fixProdNs(), + }, + } +} + +func fixProdNs() string { + return "production" +} + +func fixREName() string { + return "ec-prod" +} diff --git a/components/remote-environment-broker/internal/access/provision_unique.go b/components/remote-environment-broker/internal/access/provision_unique.go new file mode 100644 index 000000000000..45d8cbc14c74 --- /dev/null +++ b/components/remote-environment-broker/internal/access/provision_unique.go @@ -0,0 +1,60 @@ +package access + +import ( + "github.com/pkg/errors" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" +) + +type instanceFinder interface { + FindOne(m func(i *internal.Instance) bool) (*internal.Instance, error) +} + +// NewUniquenessProvisionChecker creates new access checker +func NewUniquenessProvisionChecker(iFind instanceFinder) *UniquenessProvisionChecker { + return &UniquenessProvisionChecker{ + InstanceFinder: iFind, + } +} + +// UniquenessProvisionChecker is a checker which ensures that only one instance of a specific class is in namespace. +type UniquenessProvisionChecker struct { + InstanceFinder instanceFinder +} + +// CanProvision performs actual check +func (c *UniquenessProvisionChecker) CanProvision(iID internal.InstanceID, rsID internal.RemoteServiceID, ns internal.Namespace) (CanProvisionOutput, error) { + i, err := c.InstanceFinder.FindOne(func(i *internal.Instance) bool { + // exclude itself + if i.ID == iID { + return false + } + if i.Namespace != ns { + return false + } + if i.ServiceID != internal.ServiceID(rsID) { + return false + } + if i.State == internal.InstanceStateFailed { + return false + } + return true + }) + + if err != nil { + return CanProvisionOutput{}, errors.Wrapf(err, "while finding instance") + } + if i == nil { + return c.responseAllow(), nil + } + + return c.responseDeny(), nil +} + +func (c *UniquenessProvisionChecker) responseAllow() CanProvisionOutput { + return CanProvisionOutput{Allowed: true} +} + +func (c *UniquenessProvisionChecker) responseDeny() CanProvisionOutput { + return CanProvisionOutput{Allowed: false, Reason: "already activated"} +} diff --git a/components/remote-environment-broker/internal/access/provision_unique_test.go b/components/remote-environment-broker/internal/access/provision_unique_test.go new file mode 100644 index 000000000000..fbfce4f31026 --- /dev/null +++ b/components/remote-environment-broker/internal/access/provision_unique_test.go @@ -0,0 +1,59 @@ +package access_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/access" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/storage" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/storage/driver/memory" +) + +func TestUniquenessProvisionChecker(t *testing.T) { + genStore := func() storage.Instance { + iSeed := []struct{ iID, rsID, ns string }{ + {"instance-1", "service-A", "ns-prod"}, + {"instance-2", "service-A", "ns-stage"}, + {"instance-3", "service-C", "ns-prod"}, + {"instance-4", "service-C", "ns-prod"}, + } + + iStore := memory.NewInstance() + for _, iS := range iSeed { + iO := &internal.Instance{ + ID: internal.InstanceID(iS.iID), + ServiceID: internal.ServiceID(iS.rsID), + Namespace: internal.Namespace(iS.ns), + } + require.NoError(t, iStore.Insert(iO)) + } + + return iStore + } + + cases := []struct { + iID, rsID, ns string + canProvisionOutput access.CanProvisionOutput + }{ + {"instance-2", "service-A", "ns-stage", access.CanProvisionOutput{Allowed: true}}, + {"instance-4", "service-C", "ns-prod", access.CanProvisionOutput{Allowed: false, Reason: "already activated"}}, + } + + for i, tc := range cases { + t.Run(fmt.Sprintf("#%d", i), func(t *testing.T) { + // GIVEN + sut := access.NewUniquenessProvisionChecker(genStore()) + + // WHEN + gotCanProvisionOutput, err := sut.CanProvision(internal.InstanceID(tc.iID), internal.RemoteServiceID(tc.rsID), internal.Namespace(tc.ns)) + + // THEN + assert.NoError(t, err) + assert.Equal(t, gotCanProvisionOutput, tc.canProvisionOutput) + }) + } +} diff --git a/components/remote-environment-broker/internal/broker/automock/instance_getter.go b/components/remote-environment-broker/internal/broker/automock/instance_getter.go new file mode 100644 index 000000000000..4edc3f0e1ea2 --- /dev/null +++ b/components/remote-environment-broker/internal/broker/automock/instance_getter.go @@ -0,0 +1,33 @@ +// Code generated by mockery v1.0.0 +package automock + +import internal "github.com/kyma-project/kyma/components/remote-environment-broker/internal" +import mock "github.com/stretchr/testify/mock" + +// InstanceGetter is an autogenerated mock type for the InstanceGetter type +type InstanceGetter struct { + mock.Mock +} + +// Get provides a mock function with given fields: id +func (_m *InstanceGetter) Get(id internal.InstanceID) (*internal.Instance, error) { + ret := _m.Called(id) + + var r0 *internal.Instance + if rf, ok := ret.Get(0).(func(internal.InstanceID) *internal.Instance); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*internal.Instance) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(internal.InstanceID) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/remote-environment-broker/internal/broker/automock/instance_state_getter.go b/components/remote-environment-broker/internal/broker/automock/instance_state_getter.go new file mode 100644 index 000000000000..b1225772dc39 --- /dev/null +++ b/components/remote-environment-broker/internal/broker/automock/instance_state_getter.go @@ -0,0 +1,107 @@ +package automock + +import "github.com/kyma-project/kyma/components/remote-environment-broker/internal" +import "github.com/stretchr/testify/mock" + +// InstanceStateGetter is an autogenerated mock type for the InstanceStateGetter type +type InstanceStateGetter struct { + mock.Mock +} + +// IsDeprovisioned provides a mock function with given fields: _a0 +func (_m *InstanceStateGetter) IsDeprovisioned(_a0 internal.InstanceID) (bool, error) { + ret := _m.Called(_a0) + + var r0 bool + if rf, ok := ret.Get(0).(func(internal.InstanceID) bool); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func(internal.InstanceID) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// IsDeprovisioningInProgress provides a mock function with given fields: _a0 +func (_m *InstanceStateGetter) IsDeprovisioningInProgress(_a0 internal.InstanceID) (internal.OperationID, bool, error) { + ret := _m.Called(_a0) + + var r0 internal.OperationID + if rf, ok := ret.Get(0).(func(internal.InstanceID) internal.OperationID); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(internal.OperationID) + } + + var r1 bool + if rf, ok := ret.Get(1).(func(internal.InstanceID) bool); ok { + r1 = rf(_a0) + } else { + r1 = ret.Get(1).(bool) + } + + var r2 error + if rf, ok := ret.Get(2).(func(internal.InstanceID) error); ok { + r2 = rf(_a0) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// IsProvisioned provides a mock function with given fields: _a0 +func (_m *InstanceStateGetter) IsProvisioned(_a0 internal.InstanceID) (bool, error) { + ret := _m.Called(_a0) + + var r0 bool + if rf, ok := ret.Get(0).(func(internal.InstanceID) bool); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func(internal.InstanceID) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// IsProvisioningInProgress provides a mock function with given fields: _a0 +func (_m *InstanceStateGetter) IsProvisioningInProgress(_a0 internal.InstanceID) (internal.OperationID, bool, error) { + ret := _m.Called(_a0) + + var r0 internal.OperationID + if rf, ok := ret.Get(0).(func(internal.InstanceID) internal.OperationID); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(internal.OperationID) + } + + var r1 bool + if rf, ok := ret.Get(1).(func(internal.InstanceID) bool); ok { + r1 = rf(_a0) + } else { + r1 = ret.Get(1).(bool) + } + + var r2 error + if rf, ok := ret.Get(2).(func(internal.InstanceID) error); ok { + r2 = rf(_a0) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} diff --git a/components/remote-environment-broker/internal/broker/automock/instance_storage.go b/components/remote-environment-broker/internal/broker/automock/instance_storage.go new file mode 100644 index 000000000000..a944bf12cdbc --- /dev/null +++ b/components/remote-environment-broker/internal/broker/automock/instance_storage.go @@ -0,0 +1,98 @@ +// Code generated by mockery v1.0.0 +package automock + +import internal "github.com/kyma-project/kyma/components/remote-environment-broker/internal" +import mock "github.com/stretchr/testify/mock" + +// InstanceStorage is an autogenerated mock type for the InstanceStorage type +type InstanceStorage struct { + mock.Mock +} + +// FindOne provides a mock function with given fields: m +func (_m *InstanceStorage) FindOne(m func(*internal.Instance) bool) (*internal.Instance, error) { + ret := _m.Called(m) + + var r0 *internal.Instance + if rf, ok := ret.Get(0).(func(func(*internal.Instance) bool) *internal.Instance); ok { + r0 = rf(m) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*internal.Instance) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(func(*internal.Instance) bool) error); ok { + r1 = rf(m) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Get provides a mock function with given fields: id +func (_m *InstanceStorage) Get(id internal.InstanceID) (*internal.Instance, error) { + ret := _m.Called(id) + + var r0 *internal.Instance + if rf, ok := ret.Get(0).(func(internal.InstanceID) *internal.Instance); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*internal.Instance) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(internal.InstanceID) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Insert provides a mock function with given fields: i +func (_m *InstanceStorage) Insert(i *internal.Instance) error { + ret := _m.Called(i) + + var r0 error + if rf, ok := ret.Get(0).(func(*internal.Instance) error); ok { + r0 = rf(i) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Remove provides a mock function with given fields: id +func (_m *InstanceStorage) Remove(id internal.InstanceID) error { + ret := _m.Called(id) + + var r0 error + if rf, ok := ret.Get(0).(func(internal.InstanceID) error); ok { + r0 = rf(id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdateState provides a mock function with given fields: iID, state +func (_m *InstanceStorage) UpdateState(iID internal.InstanceID, state internal.InstanceState) error { + ret := _m.Called(iID, state) + + var r0 error + if rf, ok := ret.Get(0).(func(internal.InstanceID, internal.InstanceState) error); ok { + r0 = rf(iID, state) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/components/remote-environment-broker/internal/broker/automock/operation_storage.go b/components/remote-environment-broker/internal/broker/automock/operation_storage.go new file mode 100644 index 000000000000..75f7dfd52c29 --- /dev/null +++ b/components/remote-environment-broker/internal/broker/automock/operation_storage.go @@ -0,0 +1,111 @@ +package automock + +import "github.com/kyma-project/kyma/components/remote-environment-broker/internal" +import "github.com/stretchr/testify/mock" + +// OperationStorage is an autogenerated mock type for the OperationStorage type +type OperationStorage struct { + mock.Mock +} + +// Get provides a mock function with given fields: iID, opID +func (_m *OperationStorage) Get(iID internal.InstanceID, opID internal.OperationID) (*internal.InstanceOperation, error) { + ret := _m.Called(iID, opID) + + var r0 *internal.InstanceOperation + if rf, ok := ret.Get(0).(func(internal.InstanceID, internal.OperationID) *internal.InstanceOperation); ok { + r0 = rf(iID, opID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*internal.InstanceOperation) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(internal.InstanceID, internal.OperationID) error); ok { + r1 = rf(iID, opID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetAll provides a mock function with given fields: iID +func (_m *OperationStorage) GetAll(iID internal.InstanceID) ([]*internal.InstanceOperation, error) { + ret := _m.Called(iID) + + var r0 []*internal.InstanceOperation + if rf, ok := ret.Get(0).(func(internal.InstanceID) []*internal.InstanceOperation); ok { + r0 = rf(iID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*internal.InstanceOperation) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(internal.InstanceID) error); ok { + r1 = rf(iID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Insert provides a mock function with given fields: io +func (_m *OperationStorage) Insert(io *internal.InstanceOperation) error { + ret := _m.Called(io) + + var r0 error + if rf, ok := ret.Get(0).(func(*internal.InstanceOperation) error); ok { + r0 = rf(io) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Remove provides a mock function with given fields: iID, opID +func (_m *OperationStorage) Remove(iID internal.InstanceID, opID internal.OperationID) error { + ret := _m.Called(iID, opID) + + var r0 error + if rf, ok := ret.Get(0).(func(internal.InstanceID, internal.OperationID) error); ok { + r0 = rf(iID, opID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdateState provides a mock function with given fields: iID, opID, state +func (_m *OperationStorage) UpdateState(iID internal.InstanceID, opID internal.OperationID, state internal.OperationState) error { + ret := _m.Called(iID, opID, state) + + var r0 error + if rf, ok := ret.Get(0).(func(internal.InstanceID, internal.OperationID, internal.OperationState) error); ok { + r0 = rf(iID, opID, state) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdateStateDesc provides a mock function with given fields: iID, opID, state, desc +func (_m *OperationStorage) UpdateStateDesc(iID internal.InstanceID, opID internal.OperationID, state internal.OperationState, desc *string) error { + ret := _m.Called(iID, opID, state, desc) + + var r0 error + if rf, ok := ret.Get(0).(func(internal.InstanceID, internal.OperationID, internal.OperationState, *string) error); ok { + r0 = rf(iID, opID, state, desc) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/components/remote-environment-broker/internal/broker/automock/re_finder.go b/components/remote-environment-broker/internal/broker/automock/re_finder.go new file mode 100644 index 000000000000..0cb46732e758 --- /dev/null +++ b/components/remote-environment-broker/internal/broker/automock/re_finder.go @@ -0,0 +1,79 @@ +// Code generated by mockery v1.0.0 +package automock + +import internal "github.com/kyma-project/kyma/components/remote-environment-broker/internal" +import mock "github.com/stretchr/testify/mock" + +// ReFinder is an autogenerated mock type for the ReFinder type +type ReFinder struct { + mock.Mock +} + +// FindAll provides a mock function with given fields: +func (_m *ReFinder) FindAll() ([]*internal.RemoteEnvironment, error) { + ret := _m.Called() + + var r0 []*internal.RemoteEnvironment + if rf, ok := ret.Get(0).(func() []*internal.RemoteEnvironment); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*internal.RemoteEnvironment) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FindOneByServiceID provides a mock function with given fields: id +func (_m *ReFinder) FindOneByServiceID(id internal.RemoteServiceID) (*internal.RemoteEnvironment, error) { + ret := _m.Called(id) + + var r0 *internal.RemoteEnvironment + if rf, ok := ret.Get(0).(func(internal.RemoteServiceID) *internal.RemoteEnvironment); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*internal.RemoteEnvironment) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(internal.RemoteServiceID) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Get provides a mock function with given fields: name +func (_m *ReFinder) Get(name internal.RemoteEnvironmentName) (*internal.RemoteEnvironment, error) { + ret := _m.Called(name) + + var r0 *internal.RemoteEnvironment + if rf, ok := ret.Get(0).(func(internal.RemoteEnvironmentName) *internal.RemoteEnvironment); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*internal.RemoteEnvironment) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(internal.RemoteEnvironmentName) error); ok { + r1 = rf(name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/remote-environment-broker/internal/broker/automock/service_instance_getter.go b/components/remote-environment-broker/internal/broker/automock/service_instance_getter.go new file mode 100644 index 000000000000..f7f9c6120e32 --- /dev/null +++ b/components/remote-environment-broker/internal/broker/automock/service_instance_getter.go @@ -0,0 +1,33 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import v1beta1 "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + +// ServiceInstanceGetter is an autogenerated mock type for the serviceInstanceGetter type +type ServiceInstanceGetter struct { + mock.Mock +} + +// GetByNamespaceAndExternalID provides a mock function with given fields: namespace, extID +func (_m *ServiceInstanceGetter) GetByNamespaceAndExternalID(namespace string, extID string) (*v1beta1.ServiceInstance, error) { + ret := _m.Called(namespace, extID) + + var r0 *v1beta1.ServiceInstance + if rf, ok := ret.Get(0).(func(string, string) *v1beta1.ServiceInstance); ok { + r0 = rf(namespace, extID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1beta1.ServiceInstance) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(namespace, extID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/remote-environment-broker/internal/broker/bind.go b/components/remote-environment-broker/internal/broker/bind.go new file mode 100644 index 000000000000..0cba54bbaa8b --- /dev/null +++ b/components/remote-environment-broker/internal/broker/bind.go @@ -0,0 +1,46 @@ +package broker + +import ( + "context" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/pkg/errors" + osb "github.com/pmorie/go-open-service-broker-client/v2" +) + +type bindService struct { + reSvcFinder reSvcFinder +} + +const fieldNameGatewayURL = "GATEWAY_URL" + +func (svc *bindService) Bind(ctx context.Context, osbCtx osbContext, req *osb.BindRequest) (*osb.BindResponse, error) { + if len(req.Parameters) > 0 { + return nil, errors.New("remote-environment-broker does not support configuration options for the service binding") + } + + re, err := svc.reSvcFinder.FindOneByServiceID(internal.RemoteServiceID(req.ServiceID)) + if err != nil { + return nil, errors.Wrapf(err, "cannot get RemoteEnvironment: %s", req.ServiceID) + } + + creds, err := svc.getCredentials(internal.RemoteServiceID(req.ServiceID), re) + if err != nil { + return nil, errors.Wrap(err, "cannot get credentials from remote environments") + } + + return &osb.BindResponse{ + Credentials: creds, + }, nil +} + +func (*bindService) getCredentials(rsID internal.RemoteServiceID, re *internal.RemoteEnvironment) (map[string]interface{}, error) { + creds := make(map[string]interface{}) + for _, svc := range re.Services { + if svc.ID == rsID { + creds[fieldNameGatewayURL] = svc.APIEntry.GatewayURL + return creds, nil + } + } + return nil, errors.Errorf("cannot get credentials to bind instance with RemoteServiceID: %s, from RemoteEnvironment: %s", rsID, re.Name) +} diff --git a/components/remote-environment-broker/internal/broker/bind_export_test.go b/components/remote-environment-broker/internal/broker/bind_export_test.go new file mode 100644 index 000000000000..f97d567c3c0b --- /dev/null +++ b/components/remote-environment-broker/internal/broker/bind_export_test.go @@ -0,0 +1,13 @@ +package broker + +import "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + +func NewBindService(reFinder reFinder) *bindService { + return &bindService{ + reSvcFinder: reFinder, + } +} + +func (svc *bindService) GetCredentials(id internal.RemoteServiceID, re *internal.RemoteEnvironment) (map[string]interface{}, error) { + return svc.getCredentials(id, re) +} diff --git a/components/remote-environment-broker/internal/broker/bind_test.go b/components/remote-environment-broker/internal/broker/bind_test.go new file mode 100644 index 000000000000..7eb0bc0b2f1c --- /dev/null +++ b/components/remote-environment-broker/internal/broker/bind_test.go @@ -0,0 +1,96 @@ +package broker_test + +import ( + "context" + "testing" + + osb "github.com/pmorie/go-open-service-broker-client/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/broker" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/broker/automock" +) + +const fieldNameGatewayURL = "GATEWAY_URL" + +func TestBindServiceBindSuccess(t *testing.T) { + // given + reFinder := &automock.ReFinder{} + defer reFinder.AssertExpectations(t) + + fixRE := fixRE() + + reFinder.On("FindOneByServiceID", fixRE.Services[0].ID). + Return(&fixRE, nil). + Once() + + osbCtx := broker.NewOSBContext("not", "important") + svc := broker.NewBindService(reFinder) + // when + resp, err := svc.Bind(context.Background(), *osbCtx, fixBindRequest()) + + // then + require.NoError(t, err) + require.NotNil(t, resp.Credentials) + assert.Equal(t, "www.gate.com", resp.Credentials[fieldNameGatewayURL]) + +} + +func TestBindServiceBindFailure(t *testing.T) { + t.Run("On credentials get error", func(t *testing.T) { + // given + fixRE := fixRE() + fixID := fixBindRequest().InstanceID + + svc := broker.NewBindService(nil) + + // when + resp, err := svc.GetCredentials(internal.RemoteServiceID(fixID), &fixRE) + + // then + assert.EqualErrorf(t, err, err.Error(), "cannot get credentials to bind instance wit RemoteServiceID: %s, from RemoteEnvironment: %s", fixID, fixRE.Name) + assert.Zero(t, resp) + }) + + t.Run("On unexpected req params", func(t *testing.T) { + //given + fixReq := fixBindRequest() + fixReq.Parameters = map[string]interface{}{ + "some-key": "some-value", + } + + svc := broker.NewBindService(nil) + osbCtx := broker.NewOSBContext("not", "important") + + // when + resp, err := svc.Bind(context.Background(), *osbCtx, fixReq) + + // then + assert.EqualError(t, err, "remote-environment-broker does not support configuration options for the service binding") + assert.Zero(t, resp) + }) +} + +func fixBindRequest() *osb.BindRequest { + return &osb.BindRequest{ + InstanceID: "instance-id", + ServiceID: "123", + PlanID: "plan-id", + } +} +func fixRE() internal.RemoteEnvironment { + return internal.RemoteEnvironment{ + Name: "ec-prod", + Services: []internal.Service{ + { + ID: "123", + APIEntry: &internal.APIEntry{ + GatewayURL: "www.gate.com", + AccessLabel: "free", + }, + }, + }, + } +} diff --git a/components/remote-environment-broker/internal/broker/broker.go b/components/remote-environment-broker/internal/broker/broker.go new file mode 100644 index 000000000000..5863ba30a1d0 --- /dev/null +++ b/components/remote-environment-broker/internal/broker/broker.go @@ -0,0 +1,124 @@ +package broker + +import ( + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/access" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1" + "github.com/kyma-project/kyma/components/remote-environment-broker/platform/idprovider" + "github.com/sirupsen/logrus" +) + +//go:generate mockery -name=instanceStorage -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=accessChecker -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=reFinder -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=instanceGetter -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=serviceInstanceGetter -output=automock -outpkg=automock -case=underscore + +type ( + remoteEnvironmentFinder interface { + FindAll() ([]*internal.RemoteEnvironment, error) + Get(name internal.RemoteEnvironmentName) (*internal.RemoteEnvironment, error) + } + reSvcFinder interface { + FindOneByServiceID(id internal.RemoteServiceID) (*internal.RemoteEnvironment, error) + } + reFinder interface { + remoteEnvironmentFinder + reSvcFinder + } + operationInserter interface { + Insert(io *internal.InstanceOperation) error + } + operationGetter interface { + Get(iID internal.InstanceID, opID internal.OperationID) (*internal.InstanceOperation, error) + } + operationCollectionGetter interface { + GetAll(iID internal.InstanceID) ([]*internal.InstanceOperation, error) + } + operationUpdater interface { + UpdateState(iID internal.InstanceID, opID internal.OperationID, state internal.OperationState) error + UpdateStateDesc(iID internal.InstanceID, opID internal.OperationID, state internal.OperationState, desc *string) error + } + operationRemover interface { + Remove(iID internal.InstanceID, opID internal.OperationID) error + } + operationStorage interface { + operationInserter + operationGetter + operationCollectionGetter + operationUpdater + operationRemover + } + + instanceInserter interface { + Insert(i *internal.Instance) error + } + instanceGetter interface { + Get(id internal.InstanceID) (*internal.Instance, error) + } + instanceRemover interface { + Remove(id internal.InstanceID) error + } + instanceFinder interface { + FindOne(m func(i *internal.Instance) bool) (*internal.Instance, error) + } + instanceStateUpdater interface { + UpdateState(iID internal.InstanceID, state internal.InstanceState) error + } + instanceStorage interface { + instanceInserter + instanceGetter + instanceRemover + instanceFinder + instanceStateUpdater + } + + instanceStateProvisionGetter interface { + IsProvisioned(internal.InstanceID) (bool, error) + IsProvisioningInProgress(internal.InstanceID) (internal.OperationID, bool, error) + } + + instanceStateDeprovisionGetter interface { + IsDeprovisioned(internal.InstanceID) (bool, error) + IsDeprovisioningInProgress(internal.InstanceID) (internal.OperationID, bool, error) + } + + instanceStateGetter interface { + instanceStateProvisionGetter + instanceStateDeprovisionGetter + } + + serviceInstanceGetter interface { + GetByNamespaceAndExternalID(namespace string, extID string) (*v1beta1.ServiceInstance, error) + } +) + +// New creates instance of broker server. +func New(remoteEnvironmentFinder reFinder, instStorage instanceStorage, opStorage operationStorage, accessChecker access.ProvisionChecker, reClient v1alpha1.RemoteenvironmentV1alpha1Interface, serviceInstanceGetter serviceInstanceGetter, log *logrus.Entry) *Server { + idpRaw := idprovider.New() + idp := func() (internal.OperationID, error) { + idRaw, err := idpRaw() + if err != nil { + return internal.OperationID(""), err + } + return internal.OperationID(idRaw), nil + } + + stateService := &instanceStateService{operationCollectionGetter: opStorage} + return &Server{ + catalogGetter: &catalogService{ + finder: remoteEnvironmentFinder, + conv: &reToServiceConverter{}, + }, + provisioner: NewProvisioner(instStorage, stateService, opStorage, opStorage, accessChecker, remoteEnvironmentFinder, serviceInstanceGetter, reClient, instStorage, idp, log), + deprovisioner: NewDeprovisioner(instStorage, stateService, opStorage, opStorage, idp, log), + binder: &bindService{ + reSvcFinder: remoteEnvironmentFinder, + }, + lastOpGetter: &getLastOperationService{ + getter: opStorage, + }, + logger: log.WithField("service", "broker:server"), + } +} diff --git a/components/remote-environment-broker/internal/broker/catalog.go b/components/remote-environment-broker/internal/broker/catalog.go new file mode 100644 index 000000000000..755793b44f5b --- /dev/null +++ b/components/remote-environment-broker/internal/broker/catalog.go @@ -0,0 +1,151 @@ +package broker + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "fmt" + "regexp" + "strings" + "unicode" + + "github.com/pkg/errors" + osb "github.com/pmorie/go-open-service-broker-client/v2" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" +) + +//go:generate mockery -name=converter -output=automock -outpkg=automock -case=underscore +type converter interface { + Convert(source *internal.Source, service *internal.Service) (osb.Service, error) +} + +type catalogService struct { + finder remoteEnvironmentFinder + conv converter +} + +func (svc *catalogService) GetCatalog(ctx context.Context, osbCtx osbContext) (*osb.CatalogResponse, error) { + + reList, err := svc.finder.FindAll() + if err != nil { + return nil, errors.Wrap(err, "while finding Remote Environments") + } + + resp := osb.CatalogResponse{} + resp.Services = make([]osb.Service, 0) + for _, re := range reList { + + for _, service := range re.Services { + s, err := svc.conv.Convert(&re.Source, &service) + if err != nil { + return nil, errors.Wrap(err, "while converting bundle to service") + } + resp.Services = append(resp.Services, s) + } + + } + return &resp, nil +} + +type reToServiceConverter struct{} + +const ( + defaultPlanName = "default" + defaultDisplayPlanName = "Default" + defaultPlanDescription = "Default plan" +) + +var nonAlphaNumeric = regexp.MustCompile("[^A-Za-z0-9]+") + +func (f *reToServiceConverter) Convert(source *internal.Source, service *internal.Service) (osb.Service, error) { + bindable := true + + plan := osb.Plan{ + ID: fmt.Sprintf("%s-plan", service.ID), + Name: defaultPlanName, + Description: defaultPlanDescription, + Metadata: map[string]interface{}{ + "displayName": defaultDisplayPlanName, + }, + } + metadata := map[string]interface{}{ + "displayName": service.DisplayName, + "providerDisplayName": service.ProviderDisplayName, + "longDescription": service.LongDescription, + "remoteEnvironmentServiceId": string(service.ID), + "source": map[string]interface{}{ + "environment": source.Environment, + "type": source.Type, + "namespace": source.Namespace, + }, + } + + //TODO(entry-simplification): this is an accepted simplification until + // explicit support of many APIEntry and EventEntry + if service.APIEntry != nil { + // future: comma separated labels, must be supported on Service API + bindingLabels, err := f.buildBindingLabels(service.APIEntry.AccessLabel) + if err != nil { + return osb.Service{}, errors.Wrap(err, "cannot create binding labels") + } + metadata["bindingLabels"] = bindingLabels + } else { + // service provides only events so it is not bindable + bindable = false + } + + osbService := osb.Service{ + ID: string(service.ID), + // Name is converted to clusterServiceClassExternalName, so it need to be normalized. + // MUST only contain lowercase characters, numbers and hyphens (no spaces). + // MUST be unique across all service objects returned in this response. MUST be a non-empty string. + Name: normalizeDisplayName(service.DisplayName, string(service.ID)), + Description: service.LongDescription, + Bindable: bindable, + Metadata: metadata, + Plans: []osb.Plan{plan}, + Tags: service.Tags, + } + + return osbService, nil +} + +func (*reToServiceConverter) buildBindingLabels(accLabel string) (map[string]string, error) { + if accLabel == "" { + return nil, errors.New("accessLabel field is required to build bindingLabels") + } + bindingLabels := make(map[string]string) + + // value is set to true to ensure backward compatibility + bindingLabels[accLabel] = "true" + + return bindingLabels, nil +} + +func normalizeDisplayName(name, id string) string { + // generate 5 characters suffix from the id + sha := sha1.New() + sha.Write([]byte(id)) + suffix := hex.EncodeToString(sha.Sum(nil))[:5] + + // remove all characters, which is not alpha numeric + name = nonAlphaNumeric.ReplaceAllString(name, "-") + + // to lower + name = strings.Map(unicode.ToLower, name) + + // trim dashes if exists + name = strings.TrimSuffix(name, "-") + if len(name) > 57 { + name = name[:57] + } + + // add suffix + name = fmt.Sprintf("%s-%s", name, suffix) + + // remove dash prefix if exists + // - can happen, if the name was empty before adding suffix empty or had dash prefix + name = strings.TrimPrefix(name, "-") + return name +} diff --git a/components/remote-environment-broker/internal/broker/catalog_export_test.go b/components/remote-environment-broker/internal/broker/catalog_export_test.go new file mode 100644 index 000000000000..255ae4aed789 --- /dev/null +++ b/components/remote-environment-broker/internal/broker/catalog_export_test.go @@ -0,0 +1,6 @@ +package broker + +//noinspection GoExportedFuncWithUnexportedType +func NewConverter() reToServiceConverter { + return reToServiceConverter{} +} diff --git a/components/remote-environment-broker/internal/broker/catalog_test.go b/components/remote-environment-broker/internal/broker/catalog_test.go new file mode 100644 index 000000000000..fa2d42976348 --- /dev/null +++ b/components/remote-environment-broker/internal/broker/catalog_test.go @@ -0,0 +1,174 @@ +package broker_test + +import ( + "fmt" + "testing" + + osb "github.com/pmorie/go-open-service-broker-client/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/broker" +) + +func TestConvertService(t *testing.T) { + for tn, tc := range map[string]struct { + givenSource internal.Source + givenService func() *internal.Service + + expectedService func() *osb.Service + }{ + "simpleAPIBasedService": { + givenSource: *fixSource(), + givenService: func() *internal.Service { + svc := fixAPIBasedService() + svc.DisplayName = "*Service Name\ną-'#$\tÜ" + return svc + }, + expectedService: func() *osb.Service { + svc := fixOsbService() + svc.Name = "service-name-c7fe3" + svc.Metadata["displayName"] = "*Service Name\ną-'#$\tÜ" + return svc + }, + }, + "emptyDisplayName": { + givenSource: *fixSource(), + givenService: func() *internal.Service { + svc := fixAPIBasedService() + svc.DisplayName = "" + return svc + }, + expectedService: func() *osb.Service { + svc := fixOsbService() + svc.Name = "c7fe3" + svc.Metadata["displayName"] = "" + return svc + }, + }, + "longDisplayName": { + givenSource: *fixSource(), + givenService: func() *internal.Service { + svc := fixAPIBasedService() + svc.DisplayName = "12345678901234567890123456789012345678901234567890123456789012345678901234567890" + return svc + }, + expectedService: func() *osb.Service { + svc := fixOsbService() + svc.Name = "123456789012345678901234567890123456789012345678901234567-c7fe3" + svc.Metadata["displayName"] = "12345678901234567890123456789012345678901234567890123456789012345678901234567890" + return svc + }, + }, + } { + t.Run(tn, func(t *testing.T) { + // given + converter := broker.NewConverter() + + // when + result, err := converter.Convert(&tc.givenSource, tc.givenService()) + require.NoError(t, err) + + // then + assert.Equal(t, *tc.expectedService(), result) + assert.True(t, len(tc.expectedService().Name) < 64) + }) + } + +} + +func TestFailConvertServiceWhenAccessLabelNotProvided(t *testing.T) { + // given + converter := broker.NewConverter() + + // when + _, err := converter.Convert(fixSource(), &internal.Service{ + APIEntry: &internal.APIEntry{}, + }) + + // then + assert.EqualError(t, err, "cannot create binding labels: accessLabel field is required to build bindingLabels") + +} + +func TestIsBindableFalseForEventsBasedService(t *testing.T) { + // given + converter := broker.NewConverter() + + // when + a, err := converter.Convert(fixSource(), fixEventsBasedService()) + + // then + assert.NoError(t, err) + assert.Equal(t, a.Bindable, false) +} +func TestIsBindableTrueForAPIBasedService(t *testing.T) { + // given + converter := broker.NewConverter() + + // when + a, err := converter.Convert(fixSource(), fixAPIBasedService()) + + // then + assert.NoError(t, err) + assert.Equal(t, a.Bindable, true) +} + +func fixAPIBasedService() *internal.Service { + return &internal.Service{ + ID: internal.RemoteServiceID("0023-abcd-2098"), + LongDescription: "long description", + DisplayName: "service name", + ProviderDisplayName: "HakunaMatata", + Tags: []string{"tag1", "tag2"}, + APIEntry: &internal.APIEntry{ + AccessLabel: "access-label-1", + GatewayURL: "www.gate.com", + }, + } +} + +func fixEventsBasedService() *internal.Service { + return &internal.Service{} +} + +func fixSource() *internal.Source { + return &internal.Source{ + Environment: "prod", + Type: "commerce", + Namespace: "com.hakuna.matata", + } +} + +func fixOsbService() *osb.Service { + return &osb.Service{ + ID: "0023-abcd-2098", + Description: "long description", + Bindable: true, + Name: "service-name", + Plans: []osb.Plan{{ + Name: "default", + Description: "Default plan", + ID: fmt.Sprintf("%s-plan", "0023-abcd-2098"), + Metadata: map[string]interface{}{ + "displayName": "Default", + }, + }}, + Tags: []string{"tag1", "tag2"}, + Metadata: map[string]interface{}{ + "providerDisplayName": "HakunaMatata", + "displayName": "service-name", + "longDescription": "long description", + "remoteEnvironmentServiceId": "0023-abcd-2098", + "source": map[string]interface{}{ + "environment": "prod", + "type": "commerce", + "namespace": "com.hakuna.matata", + }, + "bindingLabels": map[string]string{ + "access-label-1": "true", + }, + }, + } +} diff --git a/components/remote-environment-broker/internal/broker/ctx.go b/components/remote-environment-broker/internal/broker/ctx.go new file mode 100644 index 000000000000..167d2d0cf7a9 --- /dev/null +++ b/components/remote-environment-broker/internal/broker/ctx.go @@ -0,0 +1,18 @@ +package broker + +import "context" + +type contextKey int + +const ( + osbContextKey contextKey = 5001 +) + +func contextWithOSB(ctx context.Context, osbCtx osbContext) context.Context { + return context.WithValue(ctx, osbContextKey, osbCtx) +} + +func osbContextFromContext(ctx context.Context) (osbContext, bool) { + osbCtx, ok := ctx.Value(osbContextKey).(osbContext) + return osbCtx, ok +} diff --git a/components/remote-environment-broker/internal/broker/deprovision.go b/components/remote-environment-broker/internal/broker/deprovision.go new file mode 100644 index 000000000000..d3564ca1574e --- /dev/null +++ b/components/remote-environment-broker/internal/broker/deprovision.go @@ -0,0 +1,130 @@ +package broker + +import ( + "context" + "sync" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/pkg/errors" + "github.com/pmorie/go-open-service-broker-client/v2" + osb "github.com/pmorie/go-open-service-broker-client/v2" + "github.com/sirupsen/logrus" +) + +// NewDeprovisioner creates new Deprovisioner +func NewDeprovisioner(instStorage instanceStorage, instanceStateGetter instanceStateGetter, operationInserter operationInserter, operationUpdater operationUpdater, opIDProvider func() (internal.OperationID, error), log logrus.FieldLogger) *DeprovisionService { + return &DeprovisionService{ + instStorage: instStorage, + instanceStateGetter: instanceStateGetter, + operationInserter: operationInserter, + operationUpdater: operationUpdater, + operationIDProvider: opIDProvider, + log: log.WithField("service", "deprovisioner"), + } +} + +// DeprovisionService performs deprovision action +type DeprovisionService struct { + instStorage instanceStorage + instanceStateGetter instanceStateGetter + operationIDProvider func() (internal.OperationID, error) + operationInserter operationInserter + operationUpdater operationUpdater + + log logrus.FieldLogger + mu sync.Mutex + asyncHook func() +} + +// Deprovision action +func (svc *DeprovisionService) Deprovision(ctx context.Context, osbCtx osbContext, req *v2.DeprovisionRequest) (*v2.DeprovisionResponse, error) { + if !req.AcceptsIncomplete { + return nil, errors.New("asynchronous operation mode required") + } + + svc.mu.Lock() + defer svc.mu.Unlock() + + iID := internal.InstanceID(req.InstanceID) + + deprovisioned, err := svc.instanceStateGetter.IsDeprovisioned(iID) + switch { + case IsNotFoundError(err): + return nil, err + case err != nil: + return nil, errors.Wrap(err, "while checking if instance is already deprovisioned") + case deprovisioned: + return &osb.DeprovisionResponse{Async: false}, nil + } + + opIDInProgress, inProgress, err := svc.instanceStateGetter.IsDeprovisioningInProgress(iID) + switch { + case IsNotFoundError(err): + return nil, err + case err != nil: + return nil, errors.Wrap(err, "while checking if instance is being deprovisioned") + case inProgress: + opKeyInProgress := osb.OperationKey(opIDInProgress) + return &osb.DeprovisionResponse{Async: true, OperationKey: &opKeyInProgress}, nil + } + + operationID, err := svc.operationIDProvider() + if err != nil { + return nil, errors.Wrap(err, "while generating ID for operation") + } + + iNs, err := svc.instStorage.Get(iID) + if err != nil { + return nil, errors.Wrap(err, "while getting instance from storage") + } + + paramHash := "TODO" + op := internal.InstanceOperation{ + InstanceID: iID, + OperationID: operationID, + Type: internal.OperationTypeRemove, + State: internal.OperationStateInProgress, + ParamsHash: paramHash, + } + + if err := svc.operationInserter.Insert(&op); err != nil { + return nil, errors.Wrap(err, "while inserting instance operation to storage") + } + + err = svc.instStorage.Remove(iID) + switch { + case IsNotFoundError(err): + return nil, err + case err != nil: + return nil, errors.Wrap(err, "while removing instance from storage") + } + + opKey := osb.OperationKey(operationID) + resp := &osb.DeprovisionResponse{ + Async: true, + OperationKey: &opKey, + } + + svc.doAsync(iID, operationID, req.ServiceID, iNs.Namespace) + return resp, nil +} + +func (svc *DeprovisionService) doAsync(iID internal.InstanceID, opID internal.OperationID, reID string, ns internal.Namespace) { + go svc.do(iID, opID, reID, ns) +} + +func (svc *DeprovisionService) do(iID internal.InstanceID, opID internal.OperationID, reID string, ns internal.Namespace) { + if svc.asyncHook != nil { + defer svc.asyncHook() + } + + opState := internal.OperationStateSucceeded + opDesc := "deprovision succeeded" + + // currently, there is no any action, but it is a place for future - any deprovisioning action should be put here + + if err := svc.operationUpdater.UpdateStateDesc(iID, opID, opState, &opDesc); err != nil { + svc.log.Errorf("Cannot update state for instance [%s]: [%v]\n", iID, err) + return + } +} diff --git a/components/remote-environment-broker/internal/broker/deprovision_test.go b/components/remote-environment-broker/internal/broker/deprovision_test.go new file mode 100644 index 000000000000..a34d801e2bf8 --- /dev/null +++ b/components/remote-environment-broker/internal/broker/deprovision_test.go @@ -0,0 +1,174 @@ +package broker + +import ( + "context" + "testing" + + "time" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/broker/automock" + "github.com/kyma-project/kyma/components/remote-environment-broker/platform/logger/spy" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" +) + +func TestSuccess(t *testing.T) { + // GIVEN + ts := newDeprovisionServiceTestSuite(t) + defer ts.AssertExpectations(t) + + ts.OpIDProviderFake = func() (internal.OperationID, error) { + return fixOperationID(), nil + } + ts.mockInstanceStorage.On("Get", fixInstanceID()).Return(fixNewInstance(), nil) + ts.mockInstanceStorage.On("Remove", fixInstanceID()).Return(nil) + ts.mockOperationStorage.On("Insert", fixNewRemoveInstanceOperation()).Return(nil) + ts.mockOperationStorage.On("UpdateStateDesc", fixInstanceID(), fixOperationID(), internal.OperationStateSucceeded, fixDeprovisionSucceeded()). + Return(nil) + ts.mockInstanceStateGetter.On("IsDeprovisioned", fixInstanceID()).Return(false, nil).Once() + ts.mockInstanceStateGetter.On("IsDeprovisioningInProgress", fixInstanceID()).Return(internal.OperationID(""), false, nil).Once() + + logSink := spy.NewLogSink() + sut := NewDeprovisioner(ts.mockInstanceStorage, ts.mockInstanceStateGetter, ts.mockOperationStorage, ts.mockOperationStorage, ts.OpIDProviderFake, logSink.Logger) + + asyncFinished := make(chan struct{}, 0) + sut.asyncHook = func() { + asyncFinished <- struct{}{} + } + + // WHEN + actResp, err := sut.Deprovision(context.Background(), osbContext{}, fixDeprovisionRequest()) + + // THEN + assert.NoError(t, err) + assert.NotNil(t, actResp) + assert.True(t, actResp.Async) + + select { + case <-asyncFinished: + case <-time.After(time.Second): + assert.Fail(t, "Async processing not finished") + } +} + +func TestErrorInstanceNotFound(t *testing.T) { + // GIVEN + ts := newDeprovisionServiceTestSuite(t) + defer ts.AssertExpectations(t) + + ts.OpIDProviderFake = func() (internal.OperationID, error) { + return fixOperationID(), nil + } + ts.mockInstanceStorage.On("Get", fixInstanceID()).Return(fixNewInstance(), nil) + ts.mockInstanceStorage.On("Remove", fixInstanceID()).Return(mockNotFoundError{}) + ts.mockOperationStorage.On("Insert", fixNewRemoveInstanceOperation()).Return(nil) + ts.mockInstanceStateGetter.On("IsDeprovisioned", fixInstanceID()).Return(false, nil).Once() + ts.mockInstanceStateGetter.On("IsDeprovisioningInProgress", fixInstanceID()).Return(internal.OperationID(""), false, nil).Once() + + sut := NewDeprovisioner(ts.mockInstanceStorage, ts.mockInstanceStateGetter, ts.mockOperationStorage, nil, ts.OpIDProviderFake, spy.NewLogDummy()) + + // WHEN + _, err := sut.Deprovision(context.Background(), osbContext{}, fixDeprovisionRequest()) + + // THEN + assert.Error(t, err) + assert.True(t, IsNotFoundError(err)) + +} + +func TestErrorOnRemovingInstance(t *testing.T) { + // GIVEN + ts := newDeprovisionServiceTestSuite(t) + defer ts.AssertExpectations(t) + + ts.OpIDProviderFake = func() (internal.OperationID, error) { + return fixOperationID(), nil + } + ts.mockInstanceStorage.On("Get", fixInstanceID()).Return(fixNewInstance(), nil) + ts.mockInstanceStorage.On("Remove", fixInstanceID()).Return(errors.New("simple error")) + ts.mockOperationStorage.On("Insert", fixNewRemoveInstanceOperation()).Return(nil) + ts.mockInstanceStateGetter.On("IsDeprovisioned", fixInstanceID()).Return(false, nil).Once() + ts.mockInstanceStateGetter.On("IsDeprovisioningInProgress", fixInstanceID()).Return(internal.OperationID(""), false, nil).Once() + + sut := NewDeprovisioner(ts.mockInstanceStorage, ts.mockInstanceStateGetter, ts.mockOperationStorage, nil, ts.OpIDProviderFake, spy.NewLogDummy()) + + // WHEN + _, err := sut.Deprovision(context.Background(), osbContext{}, fixDeprovisionRequest()) + + // THEN + assert.Error(t, err) + assert.False(t, IsNotFoundError(err)) +} + +func TestErrorOnIsDeprovisionedInstance(t *testing.T) { + // GIVEN + mockStateGetter := &automock.InstanceStateGetter{} + defer mockStateGetter.AssertExpectations(t) + + mockStateGetter.On("IsDeprovisioned", fixInstanceID()).Return(false, fixError()) + + sut := NewDeprovisioner(nil, mockStateGetter, nil, nil, nil, spy.NewLogDummy()) + // WHEN + _, err := sut.Deprovision(context.Background(), osbContext{}, fixDeprovisionRequest()) + + // THEN + assert.Error(t, err) + assert.Contains(t, err.Error(), "while checking if instance is already deprovisioned") +} + +func TestErrorOnDeprovisioningInProgressInstance(t *testing.T) { + // GIVEN + mockStateGetter := &automock.InstanceStateGetter{} + defer mockStateGetter.AssertExpectations(t) + + mockStateGetter.On("IsDeprovisioned", fixInstanceID()).Return(false, nil) + mockStateGetter.On("IsDeprovisioningInProgress", fixInstanceID()).Return(internal.OperationID(""), false, fixError()) + + sut := NewDeprovisioner(nil, mockStateGetter, nil, nil, nil, spy.NewLogDummy()) + // WHEN + _, err := sut.Deprovision(context.Background(), osbContext{}, fixDeprovisionRequest()) + + // THEN + assert.Error(t, err) + assert.Contains(t, err.Error(), "while checking if instance is being deprovisioned") +} + +func newDeprovisionServiceTestSuite(t *testing.T) *deprovisionServiceTestSuite { + return &deprovisionServiceTestSuite{ + t: t, + mockInstanceStateGetter: &automock.InstanceStateGetter{}, + mockInstanceStorage: &automock.InstanceStorage{}, + mockOperationStorage: &automock.OperationStorage{}, + } +} + +type deprovisionServiceTestSuite struct { + t *testing.T + mockInstanceStateGetter *automock.InstanceStateGetter + mockInstanceStorage *automock.InstanceStorage + mockOperationStorage *automock.OperationStorage + OpIDProviderFake func() (internal.OperationID, error) +} + +func (ts *deprovisionServiceTestSuite) AssertExpectations(t *testing.T) { + ts.mockInstanceStateGetter.AssertExpectations(t) + ts.mockInstanceStorage.AssertExpectations(t) + ts.mockOperationStorage.AssertExpectations(t) +} + +func fixDeprovisionSucceeded() *string { + s := "deprovision succeeded" + return &s +} + +type mockNotFoundError struct { +} + +func (mockNotFoundError) Error() string { + return "not found error" +} + +func (mockNotFoundError) NotFound() bool { + return true +} diff --git a/components/remote-environment-broker/internal/broker/dto.go b/components/remote-environment-broker/internal/broker/dto.go new file mode 100644 index 000000000000..494e405f59d6 --- /dev/null +++ b/components/remote-environment-broker/internal/broker/dto.go @@ -0,0 +1,61 @@ +package broker + +import ( + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/pkg/errors" +) + +// DTOs for Open Service Broker v2.12 API + +type contextDTO struct { + Platform string `json:"platform"` + Namespace internal.Namespace `json:"namespace"` +} + +// ProvisionRequestDTO represents provision request +type ProvisionRequestDTO struct { + ServiceID internal.ServiceID `json:"service_id"` + PlanID internal.ServicePlanID `json:"plan_id"` + OrganizationGUID string `json:"organization_guid"` + SpaceGUID string `json:"space_guid"` + Parameters map[string]interface{} `json:"parameters,omitempty"` + Context contextDTO `json:"context,omitempty"` +} + +// ProvisionSuccessResponseDTO represents response after successful provisioning +type ProvisionSuccessResponseDTO struct { + DashboardURL *string `json:"dashboard_url"` + Operation *internal.OperationID `json:"operation,omitempty"` +} + +// DeprovisionSuccessResponseDTO represents response after successful deprovisioning +type DeprovisionSuccessResponseDTO struct { + Operation *internal.OperationID `json:"operation,omitempty"` +} + +// LastOperationSuccessResponseDTO represents info response about last successful operation +type LastOperationSuccessResponseDTO struct { + State internal.OperationState `json:"state"` + Description *string `json:"description,omitempty"` +} + +// BindSuccessResponseDTO represents response with credentials for service instance after successful binding +type BindSuccessResponseDTO struct { + // Credentials is a free-form hash of credentials that can be used by + // applications or users to access the service. + Credentials map[string]interface{} `json:"credentials,omitempty"` +} + +// BindParametersDTO contains parameters sent by Service Catalog in the body of bind request. +type BindParametersDTO struct { + ServiceID string `json:"service_id"` + PlanID string `json:"plan_id"` +} + +// Validate checks if bind parameters aren't empty +func (params *BindParametersDTO) Validate() error { + if params.PlanID == "" || params.ServiceID == "" { + return errors.New("bind parameters cannot be empty") + } + return nil +} diff --git a/components/remote-environment-broker/internal/broker/error.go b/components/remote-environment-broker/internal/broker/error.go new file mode 100644 index 000000000000..799d9e9bad8a --- /dev/null +++ b/components/remote-environment-broker/internal/broker/error.go @@ -0,0 +1,34 @@ +package broker + +// IsNotFoundError check if error is NotFound one. +func IsNotFoundError(err error) bool { + nfe, ok := err.(interface { + NotFound() bool + }) + return ok && nfe.NotFound() +} + +// IsForbiddenError checks if error represent Forbidden one. +func IsForbiddenError(err error) bool { + type forbidden interface { + Forbidden() bool + } + + if t, ok := err.(forbidden); ok { + return t.Forbidden() + } + return false +} + +// ForbiddenError represents situation when operation is forbidden +type ForbiddenError struct { +} + +func (fe *ForbiddenError) Error() string { + return "Forbidden Error" +} + +// Forbidden is a marker method, used in IsForbiddenError method +func (fe *ForbiddenError) Forbidden() bool { + return true +} diff --git a/components/remote-environment-broker/internal/broker/error_test.go b/components/remote-environment-broker/internal/broker/error_test.go new file mode 100644 index 000000000000..688e1a3d4739 --- /dev/null +++ b/components/remote-environment-broker/internal/broker/error_test.go @@ -0,0 +1,40 @@ +package broker_test + +import ( + "testing" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/broker" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" +) + +type notFoundError struct{} + +func (notFoundError) Error() string { return "element not found" } +func (notFoundError) NotFound() bool { return true } + +func TestIsNotForbiddenError(t *testing.T) { + // GIVEN + err := errors.New("simple error") + // WHEN + ok := broker.IsForbiddenError(err) + // THEN + assert.False(t, ok) +} + +func TestIsForbiddenError(t *testing.T) { + // GIVEN + err := &broker.ForbiddenError{} + // WHEN + ok := broker.IsForbiddenError(err) + // THEN + assert.True(t, ok) +} + +func TestIsNotForbiddenErrorWhenNil(t *testing.T) { + // WHEN + ok := broker.IsForbiddenError(nil) + // THEN + assert.False(t, ok) + +} diff --git a/components/remote-environment-broker/internal/broker/export_test.go b/components/remote-environment-broker/internal/broker/export_test.go new file mode 100644 index 000000000000..ac053ba6aee1 --- /dev/null +++ b/components/remote-environment-broker/internal/broker/export_test.go @@ -0,0 +1,8 @@ +package broker + +func NewOSBContext(originatingIdentity, apiVersion string) *osbContext { + return &osbContext{ + OriginatingIdentity: originatingIdentity, + APIVersion: apiVersion, + } +} diff --git a/components/remote-environment-broker/internal/broker/fix_test.go b/components/remote-environment-broker/internal/broker/fix_test.go new file mode 100644 index 000000000000..0600401ce92f --- /dev/null +++ b/components/remote-environment-broker/internal/broker/fix_test.go @@ -0,0 +1,152 @@ +package broker + +import ( + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/pkg/errors" + osb "github.com/pmorie/go-open-service-broker-client/v2" + "k8s.io/apimachinery/pkg/apis/meta/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func fixOperationID() internal.OperationID { + return internal.OperationID("op-id-123") +} + +func fixRe() *internal.RemoteEnvironment { + return &internal.RemoteEnvironment{ + Name: "ec-prod", + Source: internal.Source{ + Namespace: "ec-prod-ns-1", + Type: "ec", + Environment: "ec-env", + }, + Services: []internal.Service{ + { + ID: internal.RemoteServiceID(fixServiceID()), + DisplayName: "Orders", + APIEntry: &internal.APIEntry{ + GatewayURL: "www.gate.com", + AccessLabel: "free", + }, + EventProvider: true, + }, + }, + } +} + +func fixEventActivation() *v1alpha1.EventActivation { + return &v1alpha1.EventActivation{ + ObjectMeta: v1.ObjectMeta{ + Name: fixServiceID(), + Namespace: fixNs(), + OwnerReferences: []metav1.OwnerReference{ + { + UID: fixServiceInstanceUID(), + Name: fixServiceInstanceName(), + APIVersion: "servicecatalog.k8s.io/v1beta1", + Kind: "ServiceInstance", + }, + }, + }, + Spec: v1alpha1.EventActivationSpec{ + DisplayName: "Orders", + Source: v1alpha1.Source{ + Namespace: "ec-prod-ns-1", + Type: "ec", + Environment: "ec-env", + }, + }, + } +} + +func fixInstanceID() internal.InstanceID { + return internal.InstanceID("inst-123") +} + +func fixNs() string { + return "example-namesapce" +} + +func fixNewCreateInstanceOperation() *internal.InstanceOperation { + return &internal.InstanceOperation{ + InstanceID: fixInstanceID(), + OperationID: fixOperationID(), + Type: internal.OperationTypeCreate, + State: internal.OperationStateInProgress, + ParamsHash: "TODO", + } +} +func fixNewRemoveInstanceOperation() *internal.InstanceOperation { + return &internal.InstanceOperation{ + InstanceID: fixInstanceID(), + OperationID: fixOperationID(), + Type: internal.OperationTypeRemove, + State: internal.OperationStateInProgress, + ParamsHash: "TODO", + } +} + +func fixServiceID() string { + return "service-id" +} + +func fixPlanID() string { + return "plan-id" +} + +func fixNewInstance() *internal.Instance { + return &internal.Instance{ + ID: fixInstanceID(), + Namespace: internal.Namespace(fixNs()), + ServiceID: internal.ServiceID(fixServiceID()), + ServicePlanID: internal.ServicePlanID(fixPlanID()), + State: internal.InstanceStatePending, + ParamsHash: "TODO", + } +} +func fixProvisionRequest() *osb.ProvisionRequest { + return &osb.ProvisionRequest{ + AcceptsIncomplete: true, + InstanceID: string(fixInstanceID()), + Context: map[string]interface{}{"namespace": fixNs()}, + ServiceID: fixServiceID(), + PlanID: fixPlanID(), + } +} + +func fixDeprovisionRequest() *osb.DeprovisionRequest { + return &osb.DeprovisionRequest{ + AcceptsIncomplete: true, + InstanceID: string(fixInstanceID()), + ServiceID: fixServiceID(), + PlanID: fixPlanID(), + } +} + +func fixServiceInstanceName() string { + return "service-instance-name" +} + +func fixServiceInstanceUID() types.UID { + return types.UID("service-instance-uid-abcd-000") +} + +func FixServiceInstance() *v1beta1.ServiceInstance { + return &v1beta1.ServiceInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: fixServiceInstanceName(), + UID: fixServiceInstanceUID(), + Namespace: fixNs(), + }, + Spec: v1beta1.ServiceInstanceSpec{ + ExternalID: string(fixInstanceID()), + }, + } +} + +func fixError() error { + return errors.New("some erorr") +} diff --git a/components/remote-environment-broker/internal/broker/fixture_test.go b/components/remote-environment-broker/internal/broker/fixture_test.go new file mode 100644 index 000000000000..79dd66f3e34f --- /dev/null +++ b/components/remote-environment-broker/internal/broker/fixture_test.go @@ -0,0 +1,47 @@ +package broker_test + +import ( + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" +) + +type expAll struct { + InstanceID internal.InstanceID + OperationID internal.OperationID + + Service struct { + ID internal.ServiceID + } + ServicePlan struct { + ID internal.ServicePlanID + } + Namespace internal.Namespace + ParamsHash string +} + +func (exp *expAll) Populate() { + exp.InstanceID = internal.InstanceID("fix-I-ID") + exp.OperationID = internal.OperationID("fix-OP-ID") + + exp.Namespace = internal.Namespace("fix-namespace") + exp.ParamsHash = "TODO" +} + +func (exp *expAll) NewInstance() *internal.Instance { + return &internal.Instance{ + ID: exp.InstanceID, + ServiceID: exp.Service.ID, + ServicePlanID: exp.ServicePlan.ID, + Namespace: exp.Namespace, + ParamsHash: exp.ParamsHash, + } +} + +func (exp *expAll) NewInstanceOperation(tpe internal.OperationType, state internal.OperationState) *internal.InstanceOperation { + return &internal.InstanceOperation{ + InstanceID: exp.InstanceID, + OperationID: exp.OperationID, + Type: tpe, + State: state, + ParamsHash: exp.ParamsHash, + } +} diff --git a/components/remote-environment-broker/internal/broker/lastop.go b/components/remote-environment-broker/internal/broker/lastop.go new file mode 100644 index 000000000000..dfd72b75897d --- /dev/null +++ b/components/remote-environment-broker/internal/broker/lastop.go @@ -0,0 +1,44 @@ +package broker + +import ( + "context" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/pkg/errors" + + osb "github.com/pmorie/go-open-service-broker-client/v2" +) + +type getLastOperationService struct { + getter operationGetter +} + +func (svc *getLastOperationService) GetLastOperation(ctx context.Context, osbCtx osbContext, req *osb.LastOperationRequest) (*osb.LastOperationResponse, error) { + iID := internal.InstanceID(req.InstanceID) + + var opID internal.OperationID + if req.OperationKey != nil { + opID = internal.OperationID(*req.OperationKey) + } + + op, err := svc.getter.Get(iID, opID) + switch { + case IsNotFoundError(err): + return nil, err + case err != nil: + return nil, errors.Wrap(err, "while getting instance operation") + } + + var descPtr *string + if op.StateDescription != nil { + desc := *op.StateDescription + descPtr = &desc + } + + resp := osb.LastOperationResponse{ + State: osb.LastOperationState(op.State), + Description: descPtr, + } + + return &resp, nil +} diff --git a/components/remote-environment-broker/internal/broker/middleware.go b/components/remote-environment-broker/internal/broker/middleware.go new file mode 100644 index 000000000000..d472aa207a5c --- /dev/null +++ b/components/remote-environment-broker/internal/broker/middleware.go @@ -0,0 +1,36 @@ +package broker + +import ( + "net/http" + + osb "github.com/pmorie/go-open-service-broker-client/v2" +) + +// OSBContextMiddleware implements Handler interface +type OSBContextMiddleware struct{} + +// ServeHTTP adds content of Open Service Broker Api headers to the requests +func (OSBContextMiddleware) ServeHTTP(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + osbCtx := osbContext{ + APIVersion: r.Header.Get(osb.APIVersionHeader), + OriginatingIdentity: r.Header.Get(osb.OriginatingIdentityHeader), + } + + r = r.WithContext(contextWithOSB(r.Context(), osbCtx)) + + next(rw, r) +} + +// RequireAsyncMiddleware asserts if request allows for asynchronous response +type RequireAsyncMiddleware struct{} + +// ServeHTTP handling asynchronous HTTP requests in Open Service Broker Api +func (RequireAsyncMiddleware) ServeHTTP(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + if r.URL.Query().Get("accepts_incomplete") != "true" { + // message and desc as defined in https://github.com/openservicebrokerapi/servicebroker/blob/master/spec.md#response-2 + writeErrorResponse(rw, http.StatusUnprocessableEntity, "AsyncRequired", "This service plan requires client support for asynchronous service operations.") + return + } + + next(rw, r) +} diff --git a/components/remote-environment-broker/internal/broker/provision.go b/components/remote-environment-broker/internal/broker/provision.go new file mode 100644 index 000000000000..c8e9a7598fb5 --- /dev/null +++ b/components/remote-environment-broker/internal/broker/provision.go @@ -0,0 +1,244 @@ +package broker + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/access" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + v1client "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1" + "github.com/pkg/errors" + osb "github.com/pmorie/go-open-service-broker-client/v2" + "github.com/sirupsen/logrus" + "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const serviceCatalogAPIVersion = "servicecatalog.k8s.io/v1beta1" + +// NewProvisioner creates provisioner +func NewProvisioner(instanceInserter instanceInserter, instanceStateGetter instanceStateGetter, operationInserter operationInserter, operationUpdater operationUpdater, accessChecker access.ProvisionChecker, reSvcFinder reSvcFinder, serviceInstanceGetter serviceInstanceGetter, reClient v1client.RemoteenvironmentV1alpha1Interface, iStateUpdater instanceStateUpdater, + operationIDProvider func() (internal.OperationID, error), log logrus.FieldLogger) *ProvisionService { + return &ProvisionService{ + instanceInserter: instanceInserter, + instanceStateGetter: instanceStateGetter, + instanceStateUpdater: iStateUpdater, + operationInserter: operationInserter, + operationUpdater: operationUpdater, + operationIDProvider: operationIDProvider, + accessChecker: accessChecker, + reSvcFinder: reSvcFinder, + reClient: reClient, + serviceInstanceGetter: serviceInstanceGetter, + maxWaitTime: time.Minute, + log: log.WithField("service", "provisioner"), + } +} + +// ProvisionService performs provisioning action +type ProvisionService struct { + instanceInserter instanceInserter + instanceStateUpdater instanceStateUpdater + operationInserter operationInserter + operationUpdater operationUpdater + instanceStateGetter instanceStateGetter + operationIDProvider func() (internal.OperationID, error) + reSvcFinder reSvcFinder + reClient v1client.RemoteenvironmentV1alpha1Interface + accessChecker access.ProvisionChecker + serviceInstanceGetter serviceInstanceGetter + + mu sync.Mutex + + maxWaitTime time.Duration + log logrus.FieldLogger + asyncHook func() +} + +// Provision action +func (svc *ProvisionService) Provision(ctx context.Context, osbCtx osbContext, req *osb.ProvisionRequest) (*osb.ProvisionResponse, error) { + if !req.AcceptsIncomplete { + return nil, errors.New("asynchronous operation mode required") + } + + svc.mu.Lock() + defer svc.mu.Unlock() + + iID := internal.InstanceID(req.InstanceID) + + switch state, err := svc.instanceStateGetter.IsProvisioned(iID); true { + case err != nil: + return nil, errors.Wrap(err, "while checking if instance is already provisioned") + case state: + return &osb.ProvisionResponse{Async: false}, nil + } + + switch opIDInProgress, inProgress, err := svc.instanceStateGetter.IsProvisioningInProgress(iID); true { + case err != nil: + return nil, errors.Wrap(err, "while checking if instance is being provisioned") + case inProgress: + opKeyInProgress := osb.OperationKey(opIDInProgress) + return &osb.ProvisionResponse{Async: true, OperationKey: &opKeyInProgress}, nil + } + + id, err := svc.operationIDProvider() + if err != nil { + return nil, errors.Wrap(err, "while generating ID for operation") + } + opID := internal.OperationID(id) + + paramHash := "TODO" + op := internal.InstanceOperation{ + InstanceID: iID, + OperationID: opID, + Type: internal.OperationTypeCreate, + State: internal.OperationStateInProgress, + ParamsHash: paramHash, + } + + if err := svc.operationInserter.Insert(&op); err != nil { + return nil, errors.Wrap(err, "while inserting instance operation to storage") + } + + svcID := internal.ServiceID(req.ServiceID) + svcPlanID := internal.ServicePlanID(req.PlanID) + + re, err := svc.reSvcFinder.FindOneByServiceID(internal.RemoteServiceID(req.ServiceID)) + if err != nil { + return nil, errors.Wrapf(err, "while getting remote environment with id: %s to storage", req.ServiceID) + } + + namespace, err := getNamespaceFromContext(req.Context) + if err != nil { + return nil, errors.Wrap(err, "while getting namespace from context") + } + + service, err := getSvcByID(re.Services, internal.RemoteServiceID(req.ServiceID)) + if err != nil { + return nil, errors.Wrapf(err, "while getting service [%s] from RemoteEnvironment [%s]", req.ServiceID, re.Name) + } + + i := internal.Instance{ + ID: iID, + Namespace: namespace, + ServiceID: svcID, + ServicePlanID: svcPlanID, + State: internal.InstanceStatePending, + ParamsHash: paramHash, + } + + if err = svc.instanceInserter.Insert(&i); err != nil { + return nil, errors.Wrap(err, "while inserting instance to storage") + } + + opKey := osb.OperationKey(op.OperationID) + resp := &osb.ProvisionResponse{ + OperationKey: &opKey, + Async: true, + } + svc.doAsync(iID, opID, getRemoteServiceID(req), namespace, re.Source, service.EventProvider, service.DisplayName) + return resp, nil +} + +func getRemoteServiceID(req *osb.ProvisionRequest) internal.RemoteServiceID { + return internal.RemoteServiceID(req.ServiceID) +} + +func (svc *ProvisionService) doAsync(iID internal.InstanceID, opID internal.OperationID, remEnvID internal.RemoteServiceID, ns internal.Namespace, source internal.Source, eventProvider bool, displayName string) { + go svc.do(iID, opID, remEnvID, ns, source, eventProvider, displayName) +} + +func (svc *ProvisionService) do(iID internal.InstanceID, opID internal.OperationID, remEnvID internal.RemoteServiceID, ns internal.Namespace, source internal.Source, eventProvider bool, displayName string) { + if svc.asyncHook != nil { + defer svc.asyncHook() + } + canProvisionOutput, err := svc.accessChecker.CanProvision(iID, remEnvID, ns, svc.maxWaitTime) + svc.log.Infof("Access checker: canProvisionInstance(remEnvID=[%s], ns=[%s]) returned: canProvisionOutput=[%+v], error=[%v]", remEnvID, ns, canProvisionOutput, err) + + var instanceState internal.InstanceState + var opState internal.OperationState + var opDesc string + + if err != nil { + instanceState = internal.InstanceStateFailed + opState = internal.OperationStateFailed + opDesc = fmt.Sprintf("provisioning failed on error: %s", err.Error()) + } else if !canProvisionOutput.Allowed { + instanceState = internal.InstanceStateFailed + opState = internal.OperationStateFailed + opDesc = fmt.Sprintf("Forbidden provisioning instance [%s] for remote environment [id: %s] in namespace: [%s]. Reason: [%s]", iID, remEnvID, ns, canProvisionOutput.Reason) + } else { + instanceState = internal.InstanceStateSucceeded + opState = internal.OperationStateSucceeded + opDesc = "provisioning succeeded" + if eventProvider { + err := svc.createEaOnSuccessProvision(string(remEnvID), string(ns), source, displayName, iID) + if err != nil { + instanceState = internal.InstanceStateFailed + opState = internal.OperationStateFailed + opDesc = fmt.Sprintf("provisioning failed while creating EventActivation on error: %s", err.Error()) + } + } + } + + if err := svc.instanceStateUpdater.UpdateState(iID, instanceState); err != nil { + svc.log.Errorf("Cannot update state of the stored instance [%s]: [%v]\n", iID, err) + } + + if err := svc.operationUpdater.UpdateStateDesc(iID, opID, opState, &opDesc); err != nil { + svc.log.Errorf("Cannot update state for ServiceInstance [%s]: [%v]\n", iID, err) + return + } +} + +func (svc *ProvisionService) createEaOnSuccessProvision(reID, ns string, source internal.Source, displayName string, iID internal.InstanceID) error { + // instance ID is the serviceInstance.Spec.ExternalID + si, err := svc.serviceInstanceGetter.GetByNamespaceAndExternalID(ns, string(iID)) + if err != nil { + return errors.Wrapf(err, "while getting service instance with external id: %q in namespace: %q", iID, ns) + } + + _, err = svc.reClient.EventActivations(ns).Create( + &v1alpha1.EventActivation{ + ObjectMeta: v1.ObjectMeta{ + Name: reID, + Namespace: ns, + OwnerReferences: []v1.OwnerReference{ + { + APIVersion: serviceCatalogAPIVersion, + Kind: "ServiceInstance", + Name: si.Name, + UID: si.UID, + }, + }, + }, + Spec: v1alpha1.EventActivationSpec{ + DisplayName: displayName, + Source: v1alpha1.Source{ + Namespace: source.Namespace, + Type: source.Type, + Environment: source.Environment, + }, + }, + }) + if err != nil { + return errors.Wrapf(err, "while creating EventActivation with name: %q in namespace: %q", reID, ns) + } + svc.log.Infof("Created EventActivation: [%s], in namespace: [%s]", reID, ns) + return nil +} + +func getNamespaceFromContext(contextProfile map[string]interface{}) (internal.Namespace, error) { + return internal.Namespace(contextProfile["namespace"].(string)), nil +} + +func getSvcByID(services []internal.Service, id internal.RemoteServiceID) (internal.Service, error) { + for _, svc := range services { + if svc.ID == id { + return svc, nil + } + } + return internal.Service{}, errors.Errorf("cannot find service with ID [%s]", id) +} diff --git a/components/remote-environment-broker/internal/broker/provision_test.go b/components/remote-environment-broker/internal/broker/provision_test.go new file mode 100644 index 000000000000..3fdba9653197 --- /dev/null +++ b/components/remote-environment-broker/internal/broker/provision_test.go @@ -0,0 +1,498 @@ +package broker + +import ( + "context" + "testing" + "time" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/access" + accessAutomock "github.com/kyma-project/kyma/components/remote-environment-broker/internal/access/automock" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/broker/automock" + "github.com/pkg/errors" + osb "github.com/pmorie/go-open-service-broker-client/v2" + "github.com/stretchr/testify/assert" + + "fmt" + + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/fake" + "github.com/kyma-project/kyma/components/remote-environment-broker/platform/logger/spy" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + k8testing "k8s.io/client-go/testing" +) + +func TestProvisionAsync(t *testing.T) { + + type testCase struct { + name string + givenCanProvisionOutput access.CanProvisionOutput + givenCanProvisionError error + expectedOpState internal.OperationState + expectedOpDesc string + expectedEventActivationCreated bool + expectedInstanceState internal.InstanceState + } + + for _, tc := range []testCase{ + { + name: "success", + givenCanProvisionOutput: access.CanProvisionOutput{Allowed: true}, + expectedOpState: internal.OperationStateSucceeded, + expectedOpDesc: "provisioning succeeded", + expectedEventActivationCreated: true, + expectedInstanceState: internal.InstanceStateSucceeded, + }, + { + name: "cannot provision", + givenCanProvisionOutput: access.CanProvisionOutput{Allowed: false, Reason: "very important reason"}, + expectedOpState: internal.OperationStateFailed, + expectedOpDesc: "Forbidden provisioning instance [inst-123] for remote environment [id: service-id] in namespace: [example-namesapce]. Reason: [very important reason]", + expectedEventActivationCreated: false, + expectedInstanceState: internal.InstanceStateFailed, + }, + { + name: "error on access checking", + givenCanProvisionError: errors.New("some error"), + expectedOpState: internal.OperationStateFailed, + expectedOpDesc: "provisioning failed on error: some error", + expectedEventActivationCreated: false, + expectedInstanceState: internal.InstanceStateFailed, + }, + } { + t.Run(tc.name, func(t *testing.T) { + // GIVEN + mockInstanceStorage := &automock.InstanceStorage{} + defer mockInstanceStorage.AssertExpectations(t) + mockStateGetter := &automock.InstanceStateGetter{} + defer mockStateGetter.AssertExpectations(t) + mockOperationStorage := &automock.OperationStorage{} + defer mockOperationStorage.AssertExpectations(t) + mockAccessChecker := &accessAutomock.ProvisionChecker{} + defer mockAccessChecker.AssertExpectations(t) + mockReFinder := &automock.ReFinder{} + defer mockReFinder.AssertExpectations(t) + mockServiceInstanceGetter := &automock.ServiceInstanceGetter{} + defer mockServiceInstanceGetter.AssertExpectations(t) + + clientset := fake.NewSimpleClientset() + + defaultWaitTime := time.Minute + + mockStateGetter.On("IsProvisioned", fixInstanceID()). + Return(false, nil).Once() + + mockStateGetter.On("IsProvisioningInProgress", fixInstanceID()). + Return(internal.OperationID(""), false, nil) + + mockOperationIDProvider := func() (internal.OperationID, error) { + return fixOperationID(), nil + } + + mockOperationStorage.On("Insert", fixNewCreateInstanceOperation()). + Return(nil) + + mockOperationStorage.On("UpdateStateDesc", fixInstanceID(), fixOperationID(), tc.expectedOpState, &tc.expectedOpDesc). + Return(nil) + + mockInstanceStorage.On("Insert", fixNewInstance()). + Return(nil) + + mockAccessChecker.On("CanProvision", fixInstanceID(), internal.RemoteServiceID(fixServiceID()), internal.Namespace(fixNs()), defaultWaitTime). + Return(tc.givenCanProvisionOutput, tc.givenCanProvisionError) + + mockReFinder.On("FindOneByServiceID", internal.RemoteServiceID(fixServiceID())). + Return(fixRe(), nil). + Once() + + mockInstanceStorage.On("UpdateState", fixInstanceID(), tc.expectedInstanceState). + Return(nil). + Once() + + if tc.expectedEventActivationCreated { + mockServiceInstanceGetter.On("GetByNamespaceAndExternalID", fixNs(), string(fixInstanceID())).Return(FixServiceInstance(), nil) + } + + sut := NewProvisioner(mockInstanceStorage, + mockStateGetter, + mockOperationStorage, + mockOperationStorage, + mockAccessChecker, + mockReFinder, + mockServiceInstanceGetter, + clientset.RemoteenvironmentV1alpha1(), + mockInstanceStorage, + mockOperationIDProvider, spy.NewLogDummy()) + + asyncFinished := make(chan struct{}, 0) + sut.asyncHook = func() { + asyncFinished <- struct{}{} + } + + // WHEN + actResp, err := sut.Provision(context.Background(), osbContext{}, fixProvisionRequest()) + + // THEN + assert.NoError(t, err) + assert.NotNil(t, actResp) + assert.True(t, actResp.Async) + expOpID := osb.OperationKey(fixOperationID()) + assert.Equal(t, &expOpID, actResp.OperationKey) + + select { + case <-asyncFinished: + if tc.expectedEventActivationCreated == true { + eventActivation, err := sut.reClient.EventActivations(fixNs()).Get(fixServiceID(), v1.GetOptions{}) + assert.NoError(t, err) + assert.Equal(t, fixEventActivation(), eventActivation) + } + case <-time.After(time.Second): + assert.Fail(t, "Async processing not finished") + } + }) + } +} + +func TestProvisionWhenAlreadyProvisioned(t *testing.T) { + // GIVEN + mockStateGetter := &automock.InstanceStateGetter{} + defer mockStateGetter.AssertExpectations(t) + mockStateGetter.On("IsProvisioned", fixInstanceID()).Return(true, nil) + + sut := NewProvisioner(nil, mockStateGetter, nil, nil, nil, nil, nil, nil, nil, nil, spy.NewLogDummy()) + // WHEN + actResp, err := sut.Provision(context.Background(), osbContext{}, fixProvisionRequest()) + + // THEN + assert.NoError(t, err) + assert.NotNil(t, actResp) + assert.False(t, actResp.Async) +} + +func TestProvisionWhenProvisioningInProgress(t *testing.T) { + // GIVEN + mockStateGetter := &automock.InstanceStateGetter{} + defer mockStateGetter.AssertExpectations(t) + mockStateGetter.On("IsProvisioned", fixInstanceID()).Return(false, nil) + mockStateGetter.On("IsProvisioningInProgress", fixInstanceID()).Return(fixOperationID(), true, nil) + + sut := NewProvisioner(nil, mockStateGetter, nil, nil, nil, nil, nil, nil, nil, nil, spy.NewLogDummy()) + // WHEN + actResp, err := sut.Provision(context.Background(), osbContext{}, fixProvisionRequest()) + + // THEN + assert.NoError(t, err) + assert.NotNil(t, actResp) + assert.True(t, actResp.Async) + + expOpKey := osb.OperationKey(fixOperationID()) + assert.Equal(t, &expOpKey, actResp.OperationKey) +} + +func TestProvisionErrorOnCreatingEventActivation(t *testing.T) { + // GIVEN + mockInstanceStorage := &automock.InstanceStorage{} + defer mockInstanceStorage.AssertExpectations(t) + mockStateGetter := &automock.InstanceStateGetter{} + defer mockStateGetter.AssertExpectations(t) + mockOperationStorage := &automock.OperationStorage{} + defer mockOperationStorage.AssertExpectations(t) + mockAccessChecker := &accessAutomock.ProvisionChecker{} + defer mockAccessChecker.AssertExpectations(t) + mockReFinder := &automock.ReFinder{} + defer mockReFinder.AssertExpectations(t) + mockServiceInstanceGetter := &automock.ServiceInstanceGetter{} + defer mockServiceInstanceGetter.AssertExpectations(t) + + clientset := fake.NewSimpleClientset() + clientset.PrependReactor("create", "eventactivations", failingReactor) + + defaultWaitTime := time.Minute + + mockStateGetter.On("IsProvisioned", fixInstanceID()). + Return(false, nil).Once() + + mockStateGetter.On("IsProvisioningInProgress", fixInstanceID()). + Return(internal.OperationID(""), false, nil) + + mockOperationIDProvider := func() (internal.OperationID, error) { + return fixOperationID(), nil + } + + mockOperationStorage.On("Insert", fixNewCreateInstanceOperation()). + Return(nil) + + mockInstanceStorage.On("Insert", fixNewInstance()). + Return(nil) + + mockReFinder.On("FindOneByServiceID", internal.RemoteServiceID(fixServiceID())). + Return(fixRe(), nil). + Once() + + mockAccessChecker.On("CanProvision", fixInstanceID(), internal.RemoteServiceID(fixServiceID()), internal.Namespace(fixNs()), defaultWaitTime). + Return(access.CanProvisionOutput{Allowed: true}, nil) + + mockServiceInstanceGetter.On("GetByNamespaceAndExternalID", fixNs(), string(fixInstanceID())).Return(FixServiceInstance(), nil) + + mockInstanceStorage.On("UpdateState", fixInstanceID(), internal.InstanceStateFailed). + Return(nil). + Once() + + mockOperationStorage.On("UpdateStateDesc", fixInstanceID(), fixOperationID(), internal.OperationStateFailed, fixErrWhileCreatingEA()). + Return(nil) + + sut := NewProvisioner(mockInstanceStorage, + mockStateGetter, + mockOperationStorage, + mockOperationStorage, + mockAccessChecker, + mockReFinder, + mockServiceInstanceGetter, + clientset.RemoteenvironmentV1alpha1(), + mockInstanceStorage, + mockOperationIDProvider, spy.NewLogDummy()) + + asyncFinished := make(chan struct{}, 0) + sut.asyncHook = func() { + asyncFinished <- struct{}{} + } + + // WHEN + _, err := sut.Provision(context.Background(), osbContext{}, fixProvisionRequest()) + assert.NoError(t, err) + + // THEN + select { + case <-asyncFinished: + case <-time.After(time.Second): + assert.Fail(t, "Async processing not finished") + } +} + +func TestProvisionErrorOnGettingServiceInstance(t *testing.T) { + // GIVEN + mockInstanceStorage := &automock.InstanceStorage{} + defer mockInstanceStorage.AssertExpectations(t) + mockStateGetter := &automock.InstanceStateGetter{} + defer mockStateGetter.AssertExpectations(t) + mockOperationStorage := &automock.OperationStorage{} + defer mockOperationStorage.AssertExpectations(t) + mockAccessChecker := &accessAutomock.ProvisionChecker{} + defer mockAccessChecker.AssertExpectations(t) + mockReFinder := &automock.ReFinder{} + defer mockReFinder.AssertExpectations(t) + mockServiceInstanceGetter := &automock.ServiceInstanceGetter{} + defer mockServiceInstanceGetter.AssertExpectations(t) + + clientset := fake.NewSimpleClientset() + + defaultWaitTime := time.Minute + + mockStateGetter.On("IsProvisioned", fixInstanceID()). + Return(false, nil).Once() + + mockStateGetter.On("IsProvisioningInProgress", fixInstanceID()). + Return(internal.OperationID(""), false, nil) + + mockOperationIDProvider := func() (internal.OperationID, error) { + return fixOperationID(), nil + } + + mockOperationStorage.On("Insert", fixNewCreateInstanceOperation()). + Return(nil) + + mockInstanceStorage.On("Insert", fixNewInstance()). + Return(nil) + + mockReFinder.On("FindOneByServiceID", internal.RemoteServiceID(fixServiceID())). + Return(fixRe(), nil). + Once() + + mockAccessChecker.On("CanProvision", fixInstanceID(), internal.RemoteServiceID(fixServiceID()), internal.Namespace(fixNs()), defaultWaitTime). + Return(access.CanProvisionOutput{Allowed: true}, nil) + + mockServiceInstanceGetter.On("GetByNamespaceAndExternalID", fixNs(), string(fixInstanceID())).Return(nil, errors.New("custom error")) + + mockInstanceStorage.On("UpdateState", fixInstanceID(), internal.InstanceStateFailed). + Return(nil). + Once() + + mockOperationStorage.On("UpdateStateDesc", fixInstanceID(), fixOperationID(), internal.OperationStateFailed, fixErrWhileGettingServiceInstance()). + Return(nil) + + sut := NewProvisioner(mockInstanceStorage, + mockStateGetter, + mockOperationStorage, + mockOperationStorage, + mockAccessChecker, + mockReFinder, + mockServiceInstanceGetter, + clientset.RemoteenvironmentV1alpha1(), + mockInstanceStorage, + mockOperationIDProvider, spy.NewLogDummy()) + + asyncFinished := make(chan struct{}, 0) + sut.asyncHook = func() { + asyncFinished <- struct{}{} + } + + // WHEN + _, err := sut.Provision(context.Background(), osbContext{}, fixProvisionRequest()) + assert.NoError(t, err) + + // THEN + select { + case <-asyncFinished: + case <-time.After(time.Second): + assert.Fail(t, "Async processing not finished") + } +} + +func TestProvisionErrorOnCheckingIfProvisioned(t *testing.T) { + // GIVEN + mockStateGetter := &automock.InstanceStateGetter{} + defer mockStateGetter.AssertExpectations(t) + mockStateGetter.On("IsProvisioned", fixInstanceID()).Return(false, fixError()) + + sut := NewProvisioner(nil, mockStateGetter, nil, nil, nil, nil, nil, nil, nil, nil, spy.NewLogDummy()) + // WHEN + _, err := sut.Provision(context.Background(), osbContext{}, fixProvisionRequest()) + + // THEN + assert.Error(t, err) +} + +func TestProvisionErrorOnCheckingIfProvisionInProgress(t *testing.T) { + // GIVEN + mockStateGetter := &automock.InstanceStateGetter{} + defer mockStateGetter.AssertExpectations(t) + mockStateGetter.On("IsProvisioned", fixInstanceID()).Return(false, nil) + mockStateGetter.On("IsProvisioningInProgress", fixInstanceID()).Return(internal.OperationID(""), false, fixError()) + + sut := NewProvisioner(nil, mockStateGetter, nil, nil, nil, nil, nil, nil, nil, nil, spy.NewLogDummy()) + // WHEN + _, err := sut.Provision(context.Background(), osbContext{}, fixProvisionRequest()) + + // THEN + assert.Error(t, err) +} + +func TestProvisionErrorOnIDGeneration(t *testing.T) { + // GIVEN + mockStateGetter := &automock.InstanceStateGetter{} + defer mockStateGetter.AssertExpectations(t) + + mockStateGetter.On("IsProvisioned", fixInstanceID()). + Return(false, nil).Once() + + mockStateGetter.On("IsProvisioningInProgress", fixInstanceID()). + Return(internal.OperationID(""), false, nil) + + mockOperationIDProvider := func() (internal.OperationID, error) { + return "", fixError() + } + sut := NewProvisioner(nil, mockStateGetter, nil, nil, nil, nil, nil, nil, nil, mockOperationIDProvider, spy.NewLogDummy()) + // WHEN + _, err := sut.Provision(context.Background(), osbContext{}, fixProvisionRequest()) + // THEN + assert.Error(t, err) +} + +func TestProvisionErrorOnInsertingOperation(t *testing.T) { + // GIVEN + mockStateGetter := &automock.InstanceStateGetter{} + defer mockStateGetter.AssertExpectations(t) + mockOperationStorage := &automock.OperationStorage{} + defer mockOperationStorage.AssertExpectations(t) + + mockStateGetter.On("IsProvisioned", fixInstanceID()). + Return(false, nil).Once() + + mockStateGetter.On("IsProvisioningInProgress", fixInstanceID()). + Return(internal.OperationID(""), false, nil) + + mockOperationIDProvider := func() (internal.OperationID, error) { + return fixOperationID(), nil + } + + mockOperationStorage.On("Insert", fixNewCreateInstanceOperation()). + Return(fixError()) + + sut := NewProvisioner(nil, + mockStateGetter, + mockOperationStorage, + mockOperationStorage, + nil, + nil, + nil, + nil, + nil, + mockOperationIDProvider, spy.NewLogDummy()) + + // WHEN + _, err := sut.Provision(context.Background(), osbContext{}, fixProvisionRequest()) + // THEN + assert.Error(t, err) +} + +func TestProvisionErrorOnInsertingInstance(t *testing.T) { + // GIVEN + mockInstanceStorage := &automock.InstanceStorage{} + defer mockInstanceStorage.AssertExpectations(t) + mockStateGetter := &automock.InstanceStateGetter{} + defer mockStateGetter.AssertExpectations(t) + mockOperationStorage := &automock.OperationStorage{} + defer mockOperationStorage.AssertExpectations(t) + mockReFinder := &automock.ReFinder{} + defer mockReFinder.AssertExpectations(t) + + mockStateGetter.On("IsProvisioned", fixInstanceID()). + Return(false, nil).Once() + + mockStateGetter.On("IsProvisioningInProgress", fixInstanceID()). + Return(internal.OperationID(""), false, nil) + + mockOperationIDProvider := func() (internal.OperationID, error) { + return fixOperationID(), nil + } + + mockOperationStorage.On("Insert", fixNewCreateInstanceOperation()). + Return(nil) + + mockInstanceStorage.On("Insert", fixNewInstance()).Return(fixError()) + + mockReFinder.On("FindOneByServiceID", internal.RemoteServiceID(fixServiceID())). + Return(fixRe(), nil). + Once() + + sut := NewProvisioner(mockInstanceStorage, + mockStateGetter, + mockOperationStorage, + mockOperationStorage, + nil, + mockReFinder, + nil, + nil, + nil, + mockOperationIDProvider, spy.NewLogDummy()) + + // WHEN + _, err := sut.Provision(context.Background(), osbContext{}, fixProvisionRequest()) + // THEN + assert.Error(t, err) + +} + +func failingReactor(action k8testing.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, errors.New("custom error") +} + +func fixErrWhileCreatingEA() *string { + err := fmt.Sprintf("provisioning failed while creating EventActivation on error: while creating EventActivation with name: %q in namespace: %q: custom error", fixServiceID(), fixNs()) + return &err +} + +func fixErrWhileGettingServiceInstance() *string { + err := fmt.Sprintf("provisioning failed while creating EventActivation on error: while getting service instance with external id: %q in namespace: %q: custom error", fixInstanceID(), fixNs()) + return &err +} diff --git a/components/remote-environment-broker/internal/broker/server.go b/components/remote-environment-broker/internal/broker/server.go new file mode 100644 index 000000000000..81477851c166 --- /dev/null +++ b/components/remote-environment-broker/internal/broker/server.go @@ -0,0 +1,462 @@ +package broker + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net" + "net/http" + "time" + + "github.com/gorilla/mux" + "github.com/meatballhat/negroni-logrus" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "github.com/urfave/negroni" + + osb "github.com/pmorie/go-open-service-broker-client/v2" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" +) + +type ( + catalogGetter interface { + GetCatalog(ctx context.Context, osbCtx osbContext) (*osb.CatalogResponse, error) + } + + provisioner interface { + Provision(ctx context.Context, osbCtx osbContext, req *osb.ProvisionRequest) (*osb.ProvisionResponse, error) + } + + deprovisioner interface { + Deprovision(ctx context.Context, osbCtx osbContext, req *osb.DeprovisionRequest) (*osb.DeprovisionResponse, error) + } + + binder interface { + Bind(ctx context.Context, osbCtx osbContext, req *osb.BindRequest) (*osb.BindResponse, error) + } + + lastOpGetter interface { + GetLastOperation(ctx context.Context, osbCtx osbContext, req *osb.LastOperationRequest) (*osb.LastOperationResponse, error) + } +) + +// Server implements HTTP server used to serve OSB API for remote environment broker. +type Server struct { + catalogGetter catalogGetter + provisioner provisioner + deprovisioner deprovisioner + binder binder + lastOpGetter lastOpGetter + logger *logrus.Entry + addr string +} + +// Addr returns address server is listening on. +// Its use is targeted for cases when address is not known, e.g. tests. +func (srv *Server) Addr() string { + if srv.addr == "" { + timer := time.NewTicker(time.Millisecond) + waitLoop: + for { + <-timer.C + + if srv.addr != "" { + break waitLoop + } + } + } + + return srv.addr +} + +// Run is starting HTTP server +func (srv *Server) Run(ctx context.Context, addr string) error { + listenAndServe := func(httpSrv *http.Server) error { + ln, err := net.Listen("tcp", addr) + if err != nil { + return err + } + lnTCP := ln.(*net.TCPListener) + + srv.addr = lnTCP.Addr().String() + + // TODO: add support for tcpKeepAliveListener + return httpSrv.Serve(ln) + } + + return srv.run(ctx, addr, listenAndServe) +} + +// RunTLS is starting TLS server +func RunTLS(ctx context.Context, addr string, cert string, key string) error { + return errors.New("TLS is not yet implemented") +} + +// TODO: rewrite to go-sdk implementation with app and services +func (srv *Server) run(ctx context.Context, addr string, listenAndServe func(srv *http.Server) error) error { + httpSrv := &http.Server{ + Addr: addr, + Handler: srv.createHandler(), + } + go func() { + <-ctx.Done() + c, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + if httpSrv.Shutdown(c) != nil { + httpSrv.Close() + } + }() + return listenAndServe(httpSrv) +} + +func (srv *Server) createHandler() http.Handler { + var rtr = mux.NewRouter() + + // TODO: middleware: validate osbCtx.APIVersion that matches 2.12 + // TODO: middleware: add support for osbCtx.OriginatingIdentity + + rtr.HandleFunc("/statusz", func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "OK") + }).Methods("GET") + + // sync operations + rtr.HandleFunc("/v2/catalog", srv.catalogAction).Methods("GET") + rtr.HandleFunc("/v2/service_instances/{instance_id}/last_operation", srv.getServiceInstanceLastOperationAction).Methods("GET") + rtr.HandleFunc("/v2/service_instances/{instance_id}/service_bindings/{binding_id}", srv.bindAction).Methods("PUT") + rtr.HandleFunc("/v2/service_instances/{instance_id}/service_bindings/{binding_id}", srv.unBindAction).Methods("DELETE") + + // async operations + rtr.Path("/v2/service_instances/{instance_id}").Methods(http.MethodPut).Handler( + negroni.New(&RequireAsyncMiddleware{}, negroni.WrapFunc(srv.provisionAction)), + ) + rtr.Path("/v2/service_instances/{instance_id}").Methods(http.MethodDelete).Handler( + negroni.New(&RequireAsyncMiddleware{}, negroni.WrapFunc(srv.deprovisionAction)), + ) + + logMiddleware := negronilogrus.NewMiddlewareFromLogger(srv.logger.Logger, "") + logMiddleware.After = func(in *logrus.Entry, rw negroni.ResponseWriter, latency time.Duration, s string) *logrus.Entry { + return in.WithFields(logrus.Fields{ + "status": rw.Status(), + "took": latency, + "size": rw.Size(), + }) + } + + n := negroni.New(negroni.NewRecovery(), logMiddleware) + n.Use(&OSBContextMiddleware{}) + n.UseHandler(rtr) + return n +} + +func (srv *Server) catalogAction(w http.ResponseWriter, r *http.Request) { + osbCtx, _ := osbContextFromContext(r.Context()) + resp, err := srv.catalogGetter.GetCatalog(r.Context(), osbCtx) + if err != nil { + srv.writeErrorResponse(w, http.StatusBadRequest, err.Error(), "") + return + } + + if srv.logger != nil { + srv.logger.WithFields(logrus.Fields{ + "action": "catalog", + "resp:services:count": len(resp.Services), + }).Info("action response") + } + + srv.writeResponse(w, http.StatusOK, resp) +} + +func (srv *Server) provisionAction(w http.ResponseWriter, r *http.Request) { + osbCtx, _ := osbContextFromContext(r.Context()) + + var inDTO ProvisionRequestDTO + + if err := httpBodyToDTO(r, &inDTO); err != nil { + srv.writeErrorResponse(w, http.StatusBadRequest, err.Error(), "") + } + + instanceID := mux.Vars(r)["instance_id"] + + sReq := osb.ProvisionRequest{ + AcceptsIncomplete: true, // see OSBContextMiddleware + InstanceID: string(instanceID), + ServiceID: string(inDTO.ServiceID), + PlanID: string(inDTO.PlanID), + OrganizationGUID: inDTO.OrganizationGUID, + SpaceGUID: inDTO.SpaceGUID, + Parameters: inDTO.Parameters, + Context: map[string]interface{}{ + "namespace": string(inDTO.Context.Namespace), + }, + } + + sResp, err := srv.provisioner.Provision(r.Context(), osbCtx, &sReq) + + switch { + case IsForbiddenError(err): + srv.writeErrorResponse(w, http.StatusForbidden, err.Error(), "") + return + case err != nil: + srv.writeErrorResponse(w, http.StatusBadRequest, err.Error(), "") + return + } + + logRespFields := logrus.Fields{ + "action": "provision", + "resp:async": sResp.Async, + } + logResp := func(fields logrus.Fields) { + if srv.logger != nil { + srv.logger.WithFields(fields).Info("action response") + } + } + + if !sResp.Async { + logResp(logRespFields) + srv.writeResponse(w, http.StatusOK, map[string]interface{}{}) + return + } + + opID := internal.OperationID(*sResp.OperationKey) + egDTO := ProvisionSuccessResponseDTO{ + Operation: &opID, + } + + logRespFields["resp:operation:id"] = opID + logResp(logRespFields) + + srv.writeResponse(w, http.StatusAccepted, egDTO) +} + +func (srv *Server) deprovisionAction(w http.ResponseWriter, r *http.Request) { + osbCtx, _ := osbContextFromContext(r.Context()) + + instanceID := mux.Vars(r)["instance_id"] + + q := r.URL.Query() + + svcIDRaw := q.Get("service_id") + planIDRaw := q.Get("plan_id") + sReq := osb.DeprovisionRequest{ + AcceptsIncomplete: true, // see OsbContextMiddleware + InstanceID: string(instanceID), + ServiceID: svcIDRaw, + PlanID: planIDRaw, + } + + sResp, err := srv.deprovisioner.Deprovision(r.Context(), osbCtx, &sReq) + switch { + case IsNotFoundError(err): + srv.writeResponse(w, http.StatusGone, map[string]interface{}{}) + return + case err != nil: + srv.writeErrorResponse(w, http.StatusBadRequest, err.Error(), "") + return + } + + logRespFields := logrus.Fields{ + "action": "deprovision", + "resp:async": sResp.Async, + } + logResp := func(fields logrus.Fields) { + if srv.logger != nil { + srv.logger.WithFields(fields).Info("action response") + } + } + + if !sResp.Async { + logResp(logRespFields) + srv.writeResponse(w, http.StatusGone, map[string]interface{}{}) + return + } + + opID := internal.OperationID(*sResp.OperationKey) + egDTO := ProvisionSuccessResponseDTO{ + Operation: &opID, + } + + logRespFields["resp:operation:id"] = opID + logResp(logRespFields) + + srv.writeResponse(w, http.StatusAccepted, egDTO) +} + +func (srv *Server) getServiceInstanceLastOperationAction(w http.ResponseWriter, r *http.Request) { + osbCtx, _ := osbContextFromContext(r.Context()) + + instanceID := mux.Vars(r)["instance_id"] + var operationID internal.OperationID + + q := r.URL.Query() + + sReq := osb.LastOperationRequest{ + InstanceID: string(instanceID), + } + if svcIDRaw := q.Get("service_id"); svcIDRaw != "" { + svcID := svcIDRaw + sReq.ServiceID = &svcID + } + if planIDRaw := q.Get("plan_id"); planIDRaw != "" { + planID := planIDRaw + sReq.PlanID = &planID + } + if opIDRaw := q.Get("operation"); opIDRaw != "" { + operationID = internal.OperationID(opIDRaw) + opKey := osb.OperationKey(opIDRaw) + sReq.OperationKey = &opKey + } + + sResp, err := srv.lastOpGetter.GetLastOperation(r.Context(), osbCtx, &sReq) + switch { + case IsNotFoundError(err): + srv.writeResponse(w, http.StatusGone, map[string]interface{}{}) + return + case err != nil: + srv.writeErrorResponse(w, http.StatusBadRequest, err.Error(), "") + return + } + + logRespFields := logrus.Fields{ + "action": "getLastOperation", + "instance:id": instanceID, + "operation:id": operationID, + "resp:operation:state": sResp.State, + "resp:operation:desc": nil, + } + + resp := LastOperationSuccessResponseDTO{ + State: internal.OperationState(sResp.State), + } + if sResp.Description != nil { + desc := string(*sResp.Description) + logRespFields["resp:operation:desc"] = desc + resp.Description = &desc + } + + if srv.logger != nil { + srv.logger.WithFields(logRespFields).Info("action response") + } + srv.writeResponse(w, http.StatusOK, resp) +} + +func (srv *Server) bindAction(w http.ResponseWriter, r *http.Request) { + osbCtx, _ := osbContextFromContext(r.Context()) + + instanceID := mux.Vars(r)["instance_id"] + + var params BindParametersDTO + err := httpBodyToDTO(r, ¶ms) + if err != nil { + srv.writeErrorResponse(w, http.StatusBadRequest, err.Error(), "cannot get bind parameters from request body") + } + + err = params.Validate() + if err != nil { + srv.writeErrorResponse(w, http.StatusBadRequest, err.Error(), "") + } + + q := r.URL.Query() + bindIDRaw := q.Get("binding_id") + + sReq := osb.BindRequest{ + InstanceID: instanceID, + ServiceID: params.ServiceID, + PlanID: params.PlanID, + BindingID: bindIDRaw, + } + sResp, err := srv.binder.Bind(r.Context(), osbCtx, &sReq) + if err != nil { + srv.writeErrorResponse(w, http.StatusBadRequest, err.Error(), "") + return + } + + if srv.logger != nil { + var keys []string + for k := range sResp.Credentials { + keys = append(keys, k) + } + logRespFields := logrus.Fields{ + "action": "bind", + "resp:async": false, + "resp:credentials:keys": keys, + } + srv.logger.WithFields(logRespFields).Info("action response") + } + + egDTO := BindSuccessResponseDTO{ + Credentials: sResp.Credentials, + } + srv.writeResponse(w, http.StatusCreated, egDTO) +} + +func (srv *Server) unBindAction(w http.ResponseWriter, r *http.Request) { + srv.writeResponse(w, http.StatusGone, map[string]interface{}{}) +} + +func (srv *Server) writeResponse(w http.ResponseWriter, code int, object interface{}) { + writeResponse(w, code, object) +} + +func writeResponse(w http.ResponseWriter, code int, object interface{}) { + data, err := json.Marshal(object) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + w.Write(data) +} + +func (srv *Server) writeErrorResponse(w http.ResponseWriter, code int, errorMsg, desc string) { + if srv.logger != nil { + srv.logger.Warnf("Server responds with error: [HTTP %d]: [%s] [%s]", code, errorMsg, desc) + } + writeErrorResponse(w, code, errorMsg, desc) +} + +// writeErrorResponse writes error response compatible with OpenServiceBroker API specification. +func writeErrorResponse(w http.ResponseWriter, code int, errorMsg, desc string) { + dto := struct { + // Error is a machine readable info on an error. + // As of 2.13 Open Broker API spec it's NOT passed to entity querying the catalog. + Error string `json:"error,optional"` + + // Desc is a meaningful error message explaining why the request failed. + // see: https://github.com/openservicebrokerapi/servicebroker/blob/v2.13/spec.md#broker-errors + Desc string `json:"description,optional"` + }{} + + if errorMsg != "" { + dto.Error = errorMsg + } + + if desc != "" { + dto.Desc = desc + } + writeResponse(w, code, &dto) +} + +type osbContext struct { + APIVersion string + OriginatingIdentity string +} + +func httpBodyToDTO(r *http.Request, object interface{}) error { + body, err := ioutil.ReadAll(r.Body) + if err != nil { + return err + } + defer r.Body.Close() + + err = json.Unmarshal(body, object) + if err != nil { + return err + } + + return nil +} diff --git a/components/remote-environment-broker/internal/broker/service_instance.go b/components/remote-environment-broker/internal/broker/service_instance.go new file mode 100644 index 000000000000..e84ab8e11ede --- /dev/null +++ b/components/remote-environment-broker/internal/broker/service_instance.go @@ -0,0 +1,57 @@ +package broker + +import ( + "fmt" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/pkg/errors" + "k8s.io/client-go/tools/cache" +) + +const namespaceExternalIDIndexName = "namespaceExtID" + +// ServiceInstanceFacade expose operations on ServiceInstance objects +type ServiceInstanceFacade struct { + informer cache.SharedIndexInformer +} + +// NewServiceInstanceFacade creates ServiceInstanceFacade +func NewServiceInstanceFacade(informer cache.SharedIndexInformer) *ServiceInstanceFacade { + informer.AddIndexers(cache.Indexers{ + namespaceExternalIDIndexName: func(obj interface{}) ([]string, error) { + si, ok := obj.(*v1beta1.ServiceInstance) + if !ok { + return nil, fmt.Errorf("cannot covert object [%+v] of type %T to *v1beta1.ServiceInstance", obj, obj) + } + + return []string{namespaceExtIDKey(si.Namespace, si.Spec.ExternalID)}, nil + }, + }) + return &ServiceInstanceFacade{ + informer: informer, + } +} + +// GetByNamespaceAndExternalID returns service instance +func (f *ServiceInstanceFacade) GetByNamespaceAndExternalID(namespace string, extID string) (*v1beta1.ServiceInstance, error) { + values, err := f.informer.GetIndexer().ByIndex(namespaceExternalIDIndexName, namespaceExtIDKey(namespace, extID)) + if err != nil { + return nil, errors.Wrapf(err, "while getting service instance [namespace: %q ExtID: %q]", namespace, extID) + } + if len(values) == 0 { + return nil, fmt.Errorf("service instance not found [namespace: %q ExtID: %q]", namespace, extID) + } + if len(values) > 1 { + return nil, fmt.Errorf("more than one service instance found in namespace: %q with ExtID: %q", namespace, extID) + } + + si, ok := values[0].(*v1beta1.ServiceInstance) + if !ok { + return nil, fmt.Errorf("cannot covert object [%+v] of type %T to *v1beta1.ServiceInstance", values[0], values[0]) + } + return si, nil +} + +func namespaceExtIDKey(namespace string, extID string) string { + return fmt.Sprintf("%s/%s", namespace, extID) +} diff --git a/components/remote-environment-broker/internal/broker/service_instance_test.go b/components/remote-environment-broker/internal/broker/service_instance_test.go new file mode 100644 index 000000000000..5254159624a4 --- /dev/null +++ b/components/remote-environment-broker/internal/broker/service_instance_test.go @@ -0,0 +1,72 @@ +package broker_test + +import ( + "testing" + "time" + + "github.com/kubernetes-incubator/service-catalog/pkg/client/clientset_generated/clientset" + "github.com/kubernetes-incubator/service-catalog/pkg/client/clientset_generated/clientset/fake" + "github.com/kubernetes-incubator/service-catalog/pkg/client/informers_generated/externalversions" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/broker" + "github.com/stretchr/testify/assert" + "k8s.io/client-go/tools/cache" +) + +func TestServiceInstanceFacadeGetServiceInstance(t *testing.T) { + // GIVEN + givenServiceInstance := broker.FixServiceInstance() + cs := fake.NewSimpleClientset(givenServiceInstance) + informer := createInformer(cs) + facade := broker.NewServiceInstanceFacade(informer) + waitForInformerStart(t, informer) + + // WHEN + obj, err := facade.GetByNamespaceAndExternalID(givenServiceInstance.Namespace, givenServiceInstance.Spec.ExternalID) + + //THEN + assert.NoError(t, err) + assert.Equal(t, givenServiceInstance, obj) +} + +func TestServiceInstanceFacadeGetServiceInstanceNotFound(t *testing.T) { + // GIVEN + givenServiceInstance := broker.FixServiceInstance() + cs := fake.NewSimpleClientset(givenServiceInstance) + informer := createInformer(cs) + facade := broker.NewServiceInstanceFacade(informer) + waitForInformerStart(t, informer) + + // WHEN + obj, err := facade.GetByNamespaceAndExternalID(givenServiceInstance.Namespace, "not-existing") + + //THEN + assert.Error(t, err) + assert.Nil(t, obj) +} + +func createInformer(cs clientset.Interface) cache.SharedIndexInformer { + informerFactory := externalversions.NewSharedInformerFactory(cs, 0) + informer := informerFactory.Servicecatalog().V1beta1().ServiceInstances().Informer() + + return informer +} + +func waitForInformerStart(t *testing.T, informer cache.SharedIndexInformer) { + stop := make(chan struct{}) + syncedDone := make(chan struct{}) + + go func() { + if !cache.WaitForCacheSync(stop, informer.HasSynced) { + t.Fatalf("timeout occurred when waiting to sync informer") + } + close(syncedDone) + }() + + go informer.Run(stop) + + select { + case <-time.After(time.Second): + close(stop) + case <-syncedDone: + } +} diff --git a/components/remote-environment-broker/internal/broker/state.go b/components/remote-environment-broker/internal/broker/state.go new file mode 100644 index 000000000000..8a09e6d9dcbd --- /dev/null +++ b/components/remote-environment-broker/internal/broker/state.go @@ -0,0 +1,110 @@ +package broker + +import ( + "github.com/pkg/errors" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" +) + +type instanceStateService struct { + operationCollectionGetter operationCollectionGetter +} + +func (svc *instanceStateService) IsProvisioned(iID internal.InstanceID) (bool, error) { + result := false + + ops, err := svc.operationCollectionGetter.GetAll(iID) + switch { + case err == nil: + case IsNotFoundError(err): + return false, nil + default: + return false, errors.Wrap(err, "while getting operations from storage") + } + +OpsLoop: + for _, op := range ops { + if op.Type == internal.OperationTypeCreate && op.State == internal.OperationStateSucceeded { + result = true + } + if op.Type == internal.OperationTypeRemove && op.State == internal.OperationStateSucceeded { + result = false + break OpsLoop + } + } + + return result, nil +} + +func (svc *instanceStateService) IsProvisioningInProgress(iID internal.InstanceID) (internal.OperationID, bool, error) { + resultInProgress := false + var resultOpID internal.OperationID + + ops, err := svc.operationCollectionGetter.GetAll(iID) + switch { + case err == nil: + case IsNotFoundError(err): + return resultOpID, false, nil + default: + return resultOpID, false, errors.Wrap(err, "while getting operations from storage") + } + +OpsLoop: + for _, op := range ops { + if op.Type == internal.OperationTypeCreate && op.State == internal.OperationStateInProgress { + resultInProgress = true + resultOpID = op.OperationID + break OpsLoop + } + } + + return resultOpID, resultInProgress, nil +} + +func (svc *instanceStateService) IsDeprovisioned(iID internal.InstanceID) (bool, error) { + result := false + + ops, err := svc.operationCollectionGetter.GetAll(iID) + switch { + case err == nil: + case IsNotFoundError(err): + return false, err + default: + return false, errors.Wrap(err, "while getting operations from storage") + } + +OpsLoop: + for _, op := range ops { + if op.Type == internal.OperationTypeRemove && op.State == internal.OperationStateSucceeded { + result = true + break OpsLoop + } + } + + return result, nil +} + +func (svc *instanceStateService) IsDeprovisioningInProgress(iID internal.InstanceID) (internal.OperationID, bool, error) { + resultInProgress := false + var resultOpID internal.OperationID + + ops, err := svc.operationCollectionGetter.GetAll(iID) + switch { + case err == nil: + case IsNotFoundError(err): + return resultOpID, false, nil + default: + return resultOpID, false, errors.Wrap(err, "while getting operations from storage") + } + +OpsLoop: + for _, op := range ops { + if op.Type == internal.OperationTypeRemove && op.State == internal.OperationStateInProgress { + resultInProgress = true + resultOpID = op.OperationID + break OpsLoop + } + } + + return resultOpID, resultInProgress, nil +} diff --git a/components/remote-environment-broker/internal/broker/state_export_test.go b/components/remote-environment-broker/internal/broker/state_export_test.go new file mode 100644 index 000000000000..d31a8e4de0eb --- /dev/null +++ b/components/remote-environment-broker/internal/broker/state_export_test.go @@ -0,0 +1,7 @@ +package broker + +func NewInstanceStateService(ocg operationCollectionGetter) *instanceStateService { + return &instanceStateService{ + operationCollectionGetter: ocg, + } +} diff --git a/components/remote-environment-broker/internal/broker/state_test.go b/components/remote-environment-broker/internal/broker/state_test.go new file mode 100644 index 000000000000..a52967b8cad8 --- /dev/null +++ b/components/remote-environment-broker/internal/broker/state_test.go @@ -0,0 +1,406 @@ +package broker_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/broker" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/broker/automock" + "github.com/pkg/errors" +) + +func newInstanceStateServiceTestSuite(t *testing.T) *instanceStateServiceTestSuite { + return &instanceStateServiceTestSuite{t: t} +} + +type instanceStateServiceTestSuite struct { + t *testing.T + Exp expAll +} + +func (ts *instanceStateServiceTestSuite) SetUp() { + ts.Exp.Populate() +} + +func TestInstanceStateServiceIsProvisioned(t *testing.T) { + for sym, tc := range map[string]struct { + genOps func(ts *instanceStateServiceTestSuite) []*internal.InstanceOperation + exp bool + }{ + "true/singleCreateSucceeded": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + return append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateSucceeded)) + }, + exp: true, + }, + "true/CreateSucceededThanRemoveInProgress": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateSucceeded)) + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateInProgress)) + return out + }, + exp: true, + }, + "false/singleCreateInProgress": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + return append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateInProgress)) + }, + exp: false, + }, + "false/CreateSucceededThanRemoveSucceeded": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateSucceeded)) + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateSucceeded)) + return out + }, + exp: false, + }, + } { + t.Run(fmt.Sprintf("Success/%s", sym), func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(tc.genOps(ts), nil).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + got, err := svc.IsProvisioned(ts.Exp.InstanceID) + + // THEN + assert.NoError(t, err) + assert.Equal(t, tc.exp, got) + }) + } + + t.Run("Success/false/InstanceNotFound", func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(nil, notFoundError{}).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + got, err := svc.IsProvisioned(ts.Exp.InstanceID) + + // THEN + assert.NoError(t, err) + assert.False(t, got) + }) + + t.Run("Failure/GenericStorageError", func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + fixErr := errors.New("fix-storage-error") + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(nil, fixErr).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + got, err := svc.IsProvisioned(ts.Exp.InstanceID) + + // THEN + assert.EqualError(t, err, fmt.Sprintf("while getting operations from storage: %s", fixErr.Error())) + assert.False(t, got) + }) +} + +func TestInstanceStateServiceIsDeprovisioned(t *testing.T) { + for sym, tc := range map[string]struct { + genOps func(ts *instanceStateServiceTestSuite) []*internal.InstanceOperation + exp bool + }{ + "true/singleRemoveSucceeded": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + return append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateSucceeded)) + }, + exp: true, + }, + "true/CreateSucceededThanRemoveInProgress": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateSucceeded)) + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateInProgress)) + return out + }, + exp: false, + }, + "false/singleRemoveInProgress": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + return append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateInProgress)) + }, + exp: false, + }, + "false/CreateSucceededThanRemoveSucceeded": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateSucceeded)) + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateSucceeded)) + return out + }, + exp: true, + }, + } { + t.Run(fmt.Sprintf("Success/%s", sym), func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(tc.genOps(ts), nil).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + got, err := svc.IsDeprovisioned(ts.Exp.InstanceID) + + // THEN + assert.NoError(t, err) + assert.Equal(t, tc.exp, got) + }) + } + + t.Run("Success/false/InstanceNotFound", func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(nil, notFoundError{}).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + got, err := svc.IsDeprovisioned(ts.Exp.InstanceID) + + // THEN + assert.True(t, broker.IsNotFoundError(err)) + assert.False(t, got) + }) + + t.Run("Failure/GenericStorageError", func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + fixErr := errors.New("fix-storage-error") + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(nil, fixErr).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + got, err := svc.IsDeprovisioned(ts.Exp.InstanceID) + + // THEN + assert.EqualError(t, err, fmt.Sprintf("while getting operations from storage: %s", fixErr.Error())) + assert.False(t, got) + }) +} + +func TestInstanceStateServiceIsDeprovisioningInProgress(t *testing.T) { + for sym, tc := range map[string]struct { + genOps func(ts *instanceStateServiceTestSuite) []*internal.InstanceOperation + exp bool + }{ + "false/singleRemoveSucceeded": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + return append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateSucceeded)) + }, + exp: false, + }, + "true/CreateSucceededThanRemoveInProgress": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateSucceeded)) + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateInProgress)) + return out + }, + exp: true, + }, + "false/NoOp": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { return out }, + exp: false, + }, + "false/CreateSucceededThanRemoveSucceeded": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateSucceeded)) + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateSucceeded)) + return out + }, + exp: false, + }, + } { + t.Run(fmt.Sprintf("Success/%s", sym), func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(tc.genOps(ts), nil).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + gotOpID, gotInProgress, err := svc.IsDeprovisioningInProgress(ts.Exp.InstanceID) + + // THEN + assert.NoError(t, err) + assert.Equal(t, tc.exp, gotInProgress) + if tc.exp { + assert.Equal(t, ts.Exp.OperationID, gotOpID) + } + }) + } + + t.Run("Success/false/InstanceNotFound", func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(nil, notFoundError{}).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + gotOpID, got, err := svc.IsDeprovisioningInProgress(ts.Exp.InstanceID) + + // THEN + assert.NoError(t, err) + assert.False(t, got) + assert.Zero(t, gotOpID) + }) + + t.Run("Failure/GenericStorageError", func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + fixErr := errors.New("fix-storage-error") + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(nil, fixErr).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + gotOpID, got, err := svc.IsDeprovisioningInProgress(ts.Exp.InstanceID) + + // THEN + assert.EqualError(t, err, fmt.Sprintf("while getting operations from storage: %s", fixErr.Error())) + assert.False(t, got) + assert.Zero(t, gotOpID) + }) +} + +func TestInstanceStateServiceIsProvisioningInProgress(t *testing.T) { + for sym, tc := range map[string]struct { + genOps func(ts *instanceStateServiceTestSuite) []*internal.InstanceOperation + expInProgress bool + }{ + "true/singleCreateInProgress": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + return append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateInProgress)) + }, + expInProgress: true, + }, + "false/singleCreateSucceeded": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + return append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateSucceeded)) + }, + expInProgress: false, + }, + "false/NoOp": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { return out }, + expInProgress: false, + }, + "false/CreateSucceededThanRemoveInProgress": { + genOps: func(ts *instanceStateServiceTestSuite) (out []*internal.InstanceOperation) { + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeCreate, internal.OperationStateSucceeded)) + out = append(out, ts.Exp.NewInstanceOperation(internal.OperationTypeRemove, internal.OperationStateInProgress)) + return out + }, + expInProgress: false, + }, + } { + t.Run(fmt.Sprintf("Success/%s", sym), func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(tc.genOps(ts), nil).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + gotOpID, gotInProgress, err := svc.IsProvisioningInProgress(ts.Exp.InstanceID) + + // THEN + assert.NoError(t, err) + assert.Equal(t, tc.expInProgress, gotInProgress) + if tc.expInProgress { + assert.Equal(t, ts.Exp.OperationID, gotOpID) + } + }) + } + + t.Run("Success/false/InstanceNotFound", func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(nil, notFoundError{}).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + gotOpID, got, err := svc.IsProvisioningInProgress(ts.Exp.InstanceID) + + // THEN + assert.NoError(t, err) + assert.False(t, got) + assert.Zero(t, gotOpID) + }) + + t.Run("Failure/GenericStorageError", func(t *testing.T) { + // GIVEN + ts := newInstanceStateServiceTestSuite(t) + ts.SetUp() + + ocgMock := &automock.OperationStorage{} + defer ocgMock.AssertExpectations(t) + fixErr := errors.New("fix-storage-error") + ocgMock.On("GetAll", ts.Exp.InstanceID).Return(nil, fixErr).Once() + + svc := broker.NewInstanceStateService(ocgMock) + + // WHEN + gotOpID, got, err := svc.IsProvisioningInProgress(ts.Exp.InstanceID) + + // THEN + assert.EqualError(t, err, fmt.Sprintf("while getting operations from storage: %s", fixErr.Error())) + assert.False(t, got) + assert.Zero(t, gotOpID) + }) +} diff --git a/components/remote-environment-broker/internal/config/config.go b/components/remote-environment-broker/internal/config/config.go new file mode 100644 index 000000000000..d37018c54d5e --- /dev/null +++ b/components/remote-environment-broker/internal/config/config.go @@ -0,0 +1,81 @@ +package config + +import ( + "fmt" + "io/ioutil" + "os" + "time" + + "github.com/asaskevich/govalidator" + "github.com/ghodss/yaml" + "github.com/imdario/mergo" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/storage" + "github.com/kyma-project/kyma/components/remote-environment-broker/platform/logger" + "github.com/mcuadros/go-defaults" + "github.com/pkg/errors" + "github.com/vrischmann/envconfig" +) + +// Config provide remote environment broker configuration +// Supported tags: +// - json: github.com/ghodss/yaml +// - envconfig: github.com/vrischmann/envconfig +// - default: github.com/mcuadros/go-defaults +// - valid github.com/asaskevich/govalidator +// Example of valid tag: `valid:"alphanum,required"` +// Combining many tags: tags have to be separated by WHITESPACE: `json:"port" default:"8080" valid:"required"` +type Config struct { + Logger logger.Config + Port int `default:"8080"` + Storage []storage.Config `valid:"required"` + BrokerName string `valid:"required"` + BrokerRelistDurationWindow time.Duration `valid:"required"` +} + +// Load method has following strategy: +// 1. Check env variable 'APP_CONFIG_FILE_NAME', if exists load configuration from specified file +// 2. Read configuration from environment variables (will override configuration from file) +// 3. Apply defaults +// 4. Validate +func Load(verbose bool) (*Config, error) { + outCfg := Config{} + + cfgFile := os.Getenv("APP_CONFIG_FILE_NAME") + if cfgFile != "" { + b, err := ioutil.ReadFile(cfgFile) + if err != nil { + return nil, errors.Wrapf(err, "while opening config file [%s]", cfgFile) + } + fileConfig := Config{} + if err := yaml.Unmarshal(b, &fileConfig); err != nil { + return nil, errors.Wrap(err, "while unmarshalling config from file") + } + outCfg = fileConfig + // fmt.Printf used, because logger will be created after reading configuration + if verbose { + fmt.Printf("Config after applying values from file: %+v\n", outCfg) + } + } + + envConf := Config{} + if err := envconfig.InitWithOptions(&envConf, envconfig.Options{Prefix: "APP", AllOptional: true, AllowUnexported: true}); err != nil { + return nil, errors.Wrap(err, "while reading configuration from environment variables") + } + + if err := mergo.MergeWithOverwrite(&outCfg, &envConf); err != nil { + return nil, errors.Wrap(err, "while merging config from environment variables") + } + if verbose { + fmt.Printf("Config after applying values from environment variables: %+v\n", outCfg) + } + + defaults.SetDefaults(&outCfg) + + if verbose { + fmt.Printf("Config after applying defaults: %+v\n", outCfg) + } + if _, err := govalidator.ValidateStruct(outCfg); err != nil { + return nil, errors.Wrap(err, "while validating configuration object") + } + return &outCfg, nil +} diff --git a/components/remote-environment-broker/internal/doc.go b/components/remote-environment-broker/internal/doc.go new file mode 100644 index 000000000000..5bf0569ce8cb --- /dev/null +++ b/components/remote-environment-broker/internal/doc.go @@ -0,0 +1 @@ +package internal diff --git a/components/remote-environment-broker/internal/labeler/automock/ns_patcher.go b/components/remote-environment-broker/internal/labeler/automock/ns_patcher.go new file mode 100644 index 000000000000..c0e887e06ab8 --- /dev/null +++ b/components/remote-environment-broker/internal/labeler/automock/ns_patcher.go @@ -0,0 +1,41 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import types "k8s.io/apimachinery/pkg/types" +import v1 "k8s.io/api/core/v1" + +// NsPatcher is an autogenerated mock type for the NsPatcher type +type NsPatcher struct { + mock.Mock +} + +// Patch provides a mock function with given fields: name, pt, data, subresources +func (_m *NsPatcher) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (*v1.Namespace, error) { + _va := make([]interface{}, len(subresources)) + for _i := range subresources { + _va[_i] = subresources[_i] + } + var _ca []interface{} + _ca = append(_ca, name, pt, data) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *v1.Namespace + if rf, ok := ret.Get(0).(func(string, types.PatchType, []byte, ...string) *v1.Namespace); ok { + r0 = rf(name, pt, data, subresources...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1.Namespace) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, types.PatchType, []byte, ...string) error); ok { + r1 = rf(name, pt, data, subresources...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/remote-environment-broker/internal/labeler/automock/re_getter.go b/components/remote-environment-broker/internal/labeler/automock/re_getter.go new file mode 100644 index 000000000000..e65780ccf944 --- /dev/null +++ b/components/remote-environment-broker/internal/labeler/automock/re_getter.go @@ -0,0 +1,34 @@ +// Code generated by mockery v1.0.0 +package automock + +import internal "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + +import mock "github.com/stretchr/testify/mock" + +// ReGetter is an autogenerated mock type for the ReGetter type +type ReGetter struct { + mock.Mock +} + +// Get provides a mock function with given fields: _a0 +func (_m *ReGetter) Get(_a0 internal.RemoteEnvironmentName) (*internal.RemoteEnvironment, error) { + ret := _m.Called(_a0) + + var r0 *internal.RemoteEnvironment + if rf, ok := ret.Get(0).(func(internal.RemoteEnvironmentName) *internal.RemoteEnvironment); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*internal.RemoteEnvironment) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(internal.RemoteEnvironmentName) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/remote-environment-broker/internal/labeler/controller.go b/components/remote-environment-broker/internal/labeler/controller.go new file mode 100644 index 000000000000..0ad2c224de47 --- /dev/null +++ b/components/remote-environment-broker/internal/labeler/controller.go @@ -0,0 +1,302 @@ +package labeler + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/storage" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/strategicpatch" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" +) + +const ( + // maxEnvironmentMappingProcessRetries is the number of times a environment mapping CR will be retried before it is dropped out of the queue. + // With the current rate-limiter in use (5ms*2^(maxRetries-1)) the following numbers represent the times + // a deployment is going to be requeued: + // + // 5ms, 10ms, 20ms, 40ms, 80ms + maxEnvironmentMappingProcessRetries = 15 +) + +//go:generate mockery -name=reGetter -output=automock -outpkg=automock -case=underscore +type reGetter interface { + Get(internal.RemoteEnvironmentName) (*internal.RemoteEnvironment, error) +} + +//go:generate mockery -name=nsPatcher -output=automock -outpkg=automock -case=underscore +type nsPatcher interface { + Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *corev1.Namespace, err error) +} + +// Controller populates local storage with all EnvironmentMapping custom resources created in k8s cluster. +type Controller struct { + log logrus.FieldLogger + queue workqueue.RateLimitingInterface + emInformer cache.SharedIndexInformer + nsInformer cache.SharedIndexInformer + nsPatcher nsPatcher + reGetter reGetter +} + +// New creates new environment mapping controller +func New(emInformer cache.SharedIndexInformer, nsInformer cache.SharedIndexInformer, nsPatcher nsPatcher, reGetter reGetter, log logrus.FieldLogger) *Controller { + c := &Controller{ + log: log.WithField("service", "labeler:controller"), + emInformer: emInformer, + nsInformer: nsInformer, + queue: workqueue.NewRateLimitingQueue(workqueue.DefaultControllerRateLimiter()), + nsPatcher: nsPatcher, + reGetter: reGetter, + } + + // EventHandler reacts every time when we add, update or delete EnvironmentMapping + emInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: c.addEM, + UpdateFunc: c.updateEM, + DeleteFunc: c.deleteEM, + }) + return c +} + +func (c *Controller) addEM(obj interface{}) { + key, err := cache.MetaNamespaceKeyFunc(obj) + if err != nil { + c.log.Errorf("while handling adding event: while adding new environment mapping custom resource to queue: couldn't get key: %v", err) + return + } + c.queue.Add(key) +} + +func (c *Controller) updateEM(old, cur interface{}) { + key, err := cache.MetaNamespaceKeyFunc(cur) + if err != nil { + c.log.Errorf("while handling update event: while adding new environment mapping custom resource to queue: couldn't get key: %v", err) + return + } + c.queue.Add(key) +} + +func (c *Controller) deleteEM(obj interface{}) { + key, err := cache.DeletionHandlingMetaNamespaceKeyFunc(obj) + if err != nil { + c.log.Errorf("while handling deletion event: while adding new environment mapping custom resource to queue: couldn't get key: %v", err) + return + } + c.queue.Add(key) +} + +// Run starts the controller +func (c *Controller) Run(stopCh <-chan struct{}) { + go c.shutdownQueueOnStop(stopCh) + + c.log.Info("Starting Environment Mappings controller") + defer c.log.Infof("Shutting down Environment Mappings controller") + + if !cache.WaitForCacheSync(stopCh, c.emInformer.HasSynced) { + c.log.Error("Timeout occurred on waiting for EM informer caches to sync. Shutdown the controller.") + return + } + if !cache.WaitForCacheSync(stopCh, c.nsInformer.HasSynced) { + c.log.Error("Timeout occurred on waiting for NS informer caches to sync. Shutdown the controller.") + return + } + + c.log.Info("EM controller synced and ready") + + wait.Until(c.runWorker, time.Second, stopCh) +} + +func (c *Controller) shutdownQueueOnStop(stopCh <-chan struct{}) { + <-stopCh + c.queue.ShutDown() +} + +func (c *Controller) runWorker() { + for c.processNextItem() { + // continue looping + } +} + +func (c *Controller) processNextItem() bool { + key, shutdown := c.queue.Get() + if shutdown { + return false + } + + defer c.queue.Done(key) + + err := c.processItem(key.(string)) + switch { + case err == nil: + c.queue.Forget(key) + + case isTemporaryError(err) && c.queue.NumRequeues(key) < maxEnvironmentMappingProcessRetries: + c.log.Errorf("Error processing %q (will retry): %v", key, err) + c.queue.AddRateLimited(key) + + default: // err != nil and err != temporary and too many retries + c.log.Errorf("Error processing %q (giving up): %v", key, err) + c.queue.Forget(key) + } + + return true +} + +func (c *Controller) processItem(key string) error { + // TODO: In prometheus-operator they use exists to check if we should delete resources, see: + // https://github.com/coreos/prometheus-operator/blob/master/pkg/alertmanager/operator.go#L364 + // but in k8s they use Lister to check if event should be delete, see: + // https://github.com/kubernetes/kubernetes/blob/master/pkg/controller/service/service_controller.go#L725 + // We need to check the guarantees of such solutions and choose the best one. + _, exists, err := c.emInformer.GetIndexer().GetByKey(key) + if err != nil { + return errors.Wrapf(err, "while getting object with key %q from the store", key) + } + + var name, namespace string + namespace, name, err = cache.SplitMetaNamespaceKey(key) + if err != nil { + return errors.Wrapf(err, "while getting name and namespace from key %q", key) + } + + nsObj, nsExist, nsErr := c.nsInformer.GetIndexer().GetByKey(namespace) + if nsErr != nil || !nsExist { + return errors.Wrapf(err, "cannot get the namespace: %q", namespace) + } + + reNs, ok := nsObj.(*corev1.Namespace) + if !ok { + return errors.New("cannot cast received object to corev1.Namespace type") + } + + if !exists { + if err = c.deleteNsAccLabel(reNs); err != nil { + return errors.Wrapf(err, "cannot delete AccessLabel from the namespace: %q", namespace) + } + return nil + } + var label string + label, err = c.getReAccLabel(name) + if err != nil { + return errors.Wrapf(err, "cannot get AccessLabel from RE: %q", name) + } + err = c.applyNsAccLabel(reNs, label) + if err != nil { + return errors.Wrapf(err, "cannot apply AccessLabel to the namespace: %q", namespace) + } + + return nil +} + +func (c *Controller) deleteNsAccLabel(ns *corev1.Namespace) error { + nsCopy := ns.DeepCopy() + c.log.Infof("Deleting AccessLabel: %q, from the namespace - %q", nsCopy.Labels["accessLabel"], nsCopy.Name) + + delete(nsCopy.Labels, "accessLabel") + + err := c.patchNs(ns, nsCopy) + if err != nil { + return fmt.Errorf("failed to delete AccessLabel from the namespace: %q, %v", nsCopy.Name, err) + } + + return nil +} + +func (c *Controller) applyNsAccLabel(ns *corev1.Namespace, label string) error { + nsCopy := ns.DeepCopy() + if nsCopy.Labels == nil { + nsCopy.Labels = make(map[string]string) + } + nsCopy.Labels["accessLabel"] = label + + c.log.Infof("Applying AccessLabel: %q to namespace - %q", label, nsCopy.Name) + + err := c.patchNs(ns, nsCopy) + if err != nil { + return fmt.Errorf("failed to apply AccessLabel: %q to the namespace: %q, %v", label, nsCopy.Name, err) + } + + return nil +} + +func (c *Controller) patchNs(nsOrig, nsMod *corev1.Namespace) error { + oldData, err := json.Marshal(nsOrig) + if err != nil { + return errors.Wrapf(err, "while marshalling original namespace") + } + newData, err2 := json.Marshal(nsMod) + if err2 != nil { + return errors.Wrapf(err, "while marshalling modified namespace") + } + + patch, err3 := strategicpatch.CreateTwoWayMergePatch(oldData, newData, corev1.Namespace{}) + if err3 != nil { + return errors.Wrapf(err, "while creating patch") + } + + if _, err := c.nsPatcher.Patch(nsMod.Name, types.StrategicMergePatchType, patch); err != nil { + return fmt.Errorf("failed to patch namespace: %q: %v", nsMod.Name, err) + } + return nil +} + +func (c *Controller) getReAccLabel(name string) (string, error) { + // get RE from storage + re, err := c.reGetter.Get(internal.RemoteEnvironmentName(name)) + if err != nil { + switch { + // We consider IsNotFoundError as Temporary error because EM can reference to existing but not already stored RE. + // In this case we want from Controller to retry processing this EM. + case storage.IsNotFoundError(err): + return "", errors.Wrapf(&tmpError{err}, "while getting remote environment with name: %q", name) + default: + return "", errors.Wrapf(err, "while getting remote environment with name: %q", name) + } + } + if re.AccessLabel == "" { + return "", fmt.Errorf("RE %q access label is empty", name) + } + + return re.AccessLabel, nil +} + +func (c *Controller) closeChanOnCtxCancellation(ctx context.Context, ch chan<- struct{}) { + for { + select { + case <-ctx.Done(): + close(ch) + return + } + } +} + +// and Temporary() method return true. Otherwise false will be returned. +func isTemporaryError(err error) bool { + type temporary interface { + Temporary() bool + } + + te, ok := errors.Cause(err).(temporary) + return ok && te.Temporary() +} + +type tmpError struct { + err error +} + +func (t *tmpError) Error() string { + return t.err.Error() +} + +func (t *tmpError) Temporary() bool { + return true +} diff --git a/components/remote-environment-broker/internal/labeler/controller_export_test.go b/components/remote-environment-broker/internal/labeler/controller_export_test.go new file mode 100644 index 000000000000..979792e0322e --- /dev/null +++ b/components/remote-environment-broker/internal/labeler/controller_export_test.go @@ -0,0 +1,15 @@ +package labeler + +import "k8s.io/api/core/v1" + +func (c *Controller) ProcessItem(key string) error { + return c.processItem(key) +} + +func (c *Controller) DeleteAccessLabelFromNamespace(ns *v1.Namespace) error { + return c.deleteNsAccLabel(ns) +} + +func (c *Controller) GetAccessLabelFromRE(name string) (string, error) { + return c.getReAccLabel(name) +} diff --git a/components/remote-environment-broker/internal/labeler/controller_test.go b/components/remote-environment-broker/internal/labeler/controller_test.go new file mode 100644 index 000000000000..7065ef233c6c --- /dev/null +++ b/components/remote-environment-broker/internal/labeler/controller_test.go @@ -0,0 +1,228 @@ +package labeler_test + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/labeler" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/labeler/automock" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/fake" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/informers/externalversions" + "github.com/kyma-project/kyma/components/remote-environment-broker/platform/logger/spy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/informers/core/v1" + fake2 "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/tools/cache" +) + +const ( + fixNSName = "production" + fixREName = "ec-prod" +) + +func TestControllerRunSuccess(t *testing.T) { + + // given + fixEM := fixEnvironmentMappingCR(fixREName, fixNSName) + fixRE := fixRemoteEnvironment(fixREName) + fixNS := fixNamespace(fixNSName) + + expectations := &sync.WaitGroup{} + expectations.Add(2) + fulfillExpectation := func(mock.Arguments) { + expectations.Done() + } + + expectedPatchNS := fmt.Sprintf(`{"metadata":{"labels":{"accessLabel":"%s"}}}`, fixRE.AccessLabel) + + emInformer := fakeEMInformer(fixEM) + nsInformer := fakeNSInformer(fixNS) + + nsClientMock := &automock.NsPatcher{} + defer nsClientMock.AssertExpectations(t) + nsClientMock.On("Patch", fixNSName, types.StrategicMergePatchType, []byte(expectedPatchNS)). + Return(&corev1.Namespace{}, nil). + Run(fulfillExpectation). + Once() + + reGetterMock := &automock.ReGetter{} + defer reGetterMock.AssertExpectations(t) + reGetterMock.On("Get", internal.RemoteEnvironmentName(fixREName)). + Return(&fixRE, nil). + Run(fulfillExpectation). + Once() + + svc := labeler.New(emInformer, nsInformer, nsClientMock, reGetterMock, spy.NewLogDummy()) + + awaitInformerStartAtMost(t, time.Second, emInformer) + awaitInformerStartAtMost(t, time.Second, nsInformer) + + ctx, close := context.WithCancel(context.Background()) + // when + go svc.Run(ctx.Done()) + + // then + awaitForSyncGroupAtMost(t, expectations, time.Second) + + // clean-up - release controller + close() +} + +func TestControllerRunSuccessLabelRemove(t *testing.T) { + // given + fixEM := fixEnvironmentMappingCR(fixREName, fixNSName) + fixNS := fixNamespaceWithAccessLabel(fixNSName) + fixExpectedNS := fixNamespace(fixNSName) + + emInformer := fakeEMInformer(fixEM) + nsClientMock := &automock.NsPatcher{} + defer nsClientMock.AssertExpectations(t) + + deletedLabelNS := `{"metadata":{"labels":null}}` + nsClientMock.On("Patch", fixNSName, types.StrategicMergePatchType, []byte(deletedLabelNS)). + Return(&fixExpectedNS, nil). + Once() + + svc := labeler.New(emInformer, nil, nsClientMock, nil, spy.NewLogDummy()) + + awaitInformerStartAtMost(t, time.Second, emInformer) + + // when + err := svc.DeleteAccessLabelFromNamespace(&fixNS) + + // then + assert.NoError(t, err) +} + +func TestControllerRunFailure(t *testing.T) { + // given + fixEM := fixEnvironmentMappingCR(fixREName, fixNSName) + fixNS := fixNamespace(fixNSName) + fixErr := errors.New("fix get err") + fixPatchErr := errors.New("fix patch err") + + emInformer := fakeEMInformer(fixEM) + + expectations := &sync.WaitGroup{} + expectations.Add(2) + fulfillExpectation := func(mock.Arguments) { + expectations.Done() + } + + nsClientMock := &automock.NsPatcher{} + defer nsClientMock.AssertExpectations(t) + nsClientMock.On("Patch", fixNSName, types.StrategicMergePatchType, []byte("{}")). + Return(nil, fixPatchErr). + Run(fulfillExpectation). + Once() + + reGetter := &automock.ReGetter{} + defer reGetter.AssertExpectations(t) + reGetter.On("Get", internal.RemoteEnvironmentName(fixREName)). + Return(nil, fixErr). + Run(fulfillExpectation). + Once() + + svc := labeler.New(emInformer, nil, nsClientMock, reGetter, spy.NewLogDummy()) + + awaitInformerStartAtMost(t, time.Second, emInformer) + + // when + err2 := svc.DeleteAccessLabelFromNamespace(&fixNS) + _, err3 := svc.GetAccessLabelFromRE(fixREName) + + // then + awaitForSyncGroupAtMost(t, expectations, time.Second) + assert.EqualError(t, err2, fmt.Sprintf("failed to delete AccessLabel from the namespace: %q, failed to patch namespace: %q: %v", fixNSName, fixNSName, fixPatchErr.Error())) + assert.EqualError(t, err3, fmt.Sprintf("while getting remote environment with name: %q: %v", fixREName, fixErr.Error())) +} + +func awaitForSyncGroupAtMost(t *testing.T, wg *sync.WaitGroup, timeout time.Duration) { + c := make(chan struct{}) + go func() { + defer close(c) + wg.Wait() + }() + + select { + case <-c: + case <-time.After(timeout): + t.Fatalf("timeout occurred when waiting for sync group") + } +} + +func awaitInformerStartAtMost(t *testing.T, timeout time.Duration, informer cache.SharedIndexInformer) { + stop := make(chan struct{}) + syncedDone := make(chan struct{}) + + go func() { + if !cache.WaitForCacheSync(stop, informer.HasSynced) { + t.Fatalf("timeout occurred when waiting to sync informer") + } + close(syncedDone) + }() + + go informer.Run(stop) + + select { + case <-time.After(timeout): + close(stop) + case <-syncedDone: + } +} + +func fixEnvironmentMappingCR(name, ns string) v1alpha1.EnvironmentMapping { + return v1alpha1.EnvironmentMapping{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns, + }, + } +} + +func fixRemoteEnvironment(fixREName string) internal.RemoteEnvironment { + return internal.RemoteEnvironment{ + Name: internal.RemoteEnvironmentName(fixREName), + AccessLabel: "fix-access-1", + } +} + +func fixNamespace(fixNSName string) corev1.Namespace { + return corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: fixNSName, + }, + } +} +func fixNamespaceWithAccessLabel(fixNSName string) corev1.Namespace { + return corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: fixNSName, + Labels: map[string]string{ + "accessLabel": "fix-access-1", + }, + }, + } +} +func fakeEMInformer(fixEM v1alpha1.EnvironmentMapping) cache.SharedIndexInformer { + client := fake.NewSimpleClientset(&fixEM) + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + remoteEnvironmentSharedInformers := informerFactory.Remoteenvironment().V1alpha1() + emInformer := remoteEnvironmentSharedInformers.EnvironmentMappings().Informer() + return emInformer +} + +func fakeNSInformer(fixNS corev1.Namespace) cache.SharedIndexInformer { + client := fake2.NewSimpleClientset(&fixNS) + return v1.NewNamespaceInformer(client, 0, cache.Indexers{}) +} diff --git a/components/remote-environment-broker/internal/model.go b/components/remote-environment-broker/internal/model.go new file mode 100644 index 000000000000..54b4dec22660 --- /dev/null +++ b/components/remote-environment-broker/internal/model.go @@ -0,0 +1,153 @@ +package internal + +import ( + "time" +) + +// RemoteEnvironmentName is a Remote Environment name +type RemoteEnvironmentName string + +// RemoteServiceID is an ID of Service defined in RemoteEnvironment +type RemoteServiceID string + +// RemoteEnvironment represents Remote Environment as defined by OSB API. +type RemoteEnvironment struct { + Name RemoteEnvironmentName + Description string + Source Source + Services []Service + AccessLabel string +} + +// Source defines attributes, which identifies remote environments. +type Source struct { + Environment string + Type string + Namespace string +} + +// Service represents service defined in the remote environment which is mapped to service class in the service catalog. +type Service struct { + ID RemoteServiceID + DisplayName string + LongDescription string + ProviderDisplayName string + + Tags []string + + //TODO(entry-simplification): this is an accepted simplification until + // explicit support of many APIEntry and EventEntry + APIEntry *APIEntry + EventProvider bool +} + +// Entry is a generic type for all type of entries. +type Entry struct { + Type string +} + +// APIEntry represents API of the remote environment. +type APIEntry struct { + Entry + GatewayURL string + AccessLabel string +} + +// InstanceID is a service instance identifier. +type InstanceID string + +// IsZero checks if InstanceID equals zero. +func (id InstanceID) IsZero() bool { return id == InstanceID("") } + +// OperationID is used as binding operation identifier. +type OperationID string + +// IsZero checks if OperationID equals zero +func (id OperationID) IsZero() bool { return id == OperationID("") } + +// InstanceOperation represents single operation. +type InstanceOperation struct { + InstanceID InstanceID + OperationID OperationID + Type OperationType + State OperationState + StateDescription *string + + // ParamsHash is an immutable hash for operation parameters + // used to match requests. + ParamsHash string + + // CreatedAt points to creation time of the operation. + // Field should be treated as immutable and is responsibility of storage implementation. + // It should be set by storage Insert method. + CreatedAt time.Time +} + +// ServiceID is an ID of the Service exposed via Service Catalog. +type ServiceID string + +// ServicePlanID is an ID of the Plan of Service exposed via Service Catalog. +type ServicePlanID string + +// Namespace is the name of namespace in k8s +type Namespace string + +// Instance contains info about Service exposed via Service Catalog. +type Instance struct { + ID InstanceID + ServiceID ServiceID + ServicePlanID ServicePlanID + Namespace Namespace + State InstanceState + ParamsHash string +} + +// InstanceCredentials are created when we bind a service instance. +type InstanceCredentials map[string]string + +// InstanceBindData contains data about service instance and it's credentials. +type InstanceBindData struct { + InstanceID InstanceID + Credentials InstanceCredentials +} + +// OperationState defines the possible states of an asynchronous request to a broker. +type OperationState string + +// String returns state of the operation. +func (os OperationState) String() string { + return string(os) +} + +const ( + // OperationStateInProgress means that operation is in progress + OperationStateInProgress OperationState = "in progress" + // OperationStateSucceeded means that request succeeded + OperationStateSucceeded OperationState = "succeeded" + // OperationStateFailed means that request failed + OperationStateFailed OperationState = "failed" +) + +// OperationType defines the possible types of an asynchronous operation to a broker. +type OperationType string + +const ( + // OperationTypeCreate means creating OperationType + OperationTypeCreate OperationType = "create" + // OperationTypeRemove means removing OperationType + OperationTypeRemove OperationType = "remove" + // OperationTypeUndefined means undefined OperationType + OperationTypeUndefined OperationType = "" +) + +// InstanceState defines the possible states of the Instance in the storage. +type InstanceState string + +const ( + // InstanceStatePending is when provision is in progress + InstanceStatePending InstanceState = "pending" + // InstanceStateFailed is when provision was failed + InstanceStateFailed InstanceState = "failed" + // InstanceStateSucceeded is when provision was succeeded + InstanceStateSucceeded InstanceState = "succeeded" +) diff --git a/components/remote-environment-broker/internal/storage/config_test.go b/components/remote-environment-broker/internal/storage/config_test.go new file mode 100644 index 000000000000..17f3032e11b4 --- /dev/null +++ b/components/remote-environment-broker/internal/storage/config_test.go @@ -0,0 +1,26 @@ +package storage_test + +import ( + "io/ioutil" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/storage" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/storage/testdata" +) + +func TestConfigParse(t *testing.T) { + // GIVEN: + in, err := ioutil.ReadFile("testdata/ConfigAllMemory.input.yaml") + require.NoError(t, err) + + exp := testdata.GoldenConfigMemorySingleAll() + + // WHEN: + got, err := storage.ConfigParse(in) + + // THEN: + assert.EqualValues(t, exp, *got) +} diff --git a/components/remote-environment-broker/internal/storage/driver/memory/driver.go b/components/remote-environment-broker/internal/storage/driver/memory/driver.go new file mode 100644 index 000000000000..ca6dcb667c96 --- /dev/null +++ b/components/remote-environment-broker/internal/storage/driver/memory/driver.go @@ -0,0 +1,6 @@ +package memory + +// Config provide config for storage +type Config struct { + MaxKeys int64 `json:"max-keys"` +} diff --git a/components/remote-environment-broker/internal/storage/driver/memory/entity_instance.go b/components/remote-environment-broker/internal/storage/driver/memory/entity_instance.go new file mode 100644 index 000000000000..ae3f96483eb4 --- /dev/null +++ b/components/remote-environment-broker/internal/storage/driver/memory/entity_instance.go @@ -0,0 +1,110 @@ +package memory + +import ( + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/pkg/errors" +) + +// NewInstance creates new Instances storage +func NewInstance() *Instance { + return &Instance{ + storage: make(map[internal.InstanceID]*internal.Instance), + } +} + +// Instance implements in-memory storage for Instance entities. +type Instance struct { + threadSafeStorage + storage map[internal.InstanceID]*internal.Instance +} + +// Insert inserts object to storage. +func (s *Instance) Insert(i *internal.Instance) error { + defer unlock(s.lockW()) + + if i == nil { + return errors.New("entity may not be nil") + } + + if i.ID.IsZero() { + return errors.New("instance id must be set") + } + + if _, found := s.storage[i.ID]; found { + return alreadyExistsError{} + } + + s.storage[i.ID] = i + + return nil +} + +// Get returns object from storage. +func (s *Instance) Get(id internal.InstanceID) (*internal.Instance, error) { + defer unlock(s.lockR()) + + i, found := s.storage[id] + if !found { + return nil, notFoundError{} + } + + return i, nil +} + +func (s *Instance) get(iID internal.InstanceID) (*internal.Instance, error) { + if iID.IsZero() { + return nil, errors.New("instance id must be set") + } + + if _, found := s.storage[iID]; !found { + return nil, notFoundError{} + } + + i, found := s.storage[iID] + if !found { + return nil, notFoundError{} + } + + return i, nil +} + +// Remove removing object from storage. +func (s *Instance) Remove(id internal.InstanceID) error { + defer unlock(s.lockW()) + + _, found := s.storage[id] + if !found { + return notFoundError{} + } + + delete(s.storage, id) + + return nil +} + +// FindOne returns from storage first object which passes the match. +func (s *Instance) FindOne(m func(i *internal.Instance) bool) (*internal.Instance, error) { + defer unlock(s.lockW()) + + for iID, i := range s.storage { + if m(i) { + return s.storage[iID], nil + } + } + + return nil, nil +} + +// UpdateState modifies state on object in storage. +func (s *Instance) UpdateState(iID internal.InstanceID, state internal.InstanceState) error { + defer unlock(s.lockW()) + + i, err := s.get(iID) + if err != nil { + return err + } + + i.State = state + + return nil +} diff --git a/components/remote-environment-broker/internal/storage/driver/memory/entity_operation.go b/components/remote-environment-broker/internal/storage/driver/memory/entity_operation.go new file mode 100644 index 000000000000..facaf5a38dec --- /dev/null +++ b/components/remote-environment-broker/internal/storage/driver/memory/entity_operation.go @@ -0,0 +1,155 @@ +package memory + +import ( + "time" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/pkg/errors" + + pTime "github.com/kyma-project/kyma/components/remote-environment-broker/platform/time" +) + +// NewInstanceOperation returns new instance of InstanceOperation storage. +func NewInstanceOperation() *InstanceOperation { + return &InstanceOperation{ + storage: make(map[internal.InstanceID]map[internal.OperationID]*internal.InstanceOperation), + } +} + +// InstanceOperation implements in-memory storage InstanceOperation. +type InstanceOperation struct { + threadSafeStorage + storage map[internal.InstanceID]map[internal.OperationID]*internal.InstanceOperation + nowProvider pTime.NowProvider +} + +// WithTimeProvider allows for passing custom time provider. +// Used mostly in testing. +func (s *InstanceOperation) WithTimeProvider(nowProvider func() time.Time) *InstanceOperation { + s.nowProvider = nowProvider + return s +} + +// Insert inserts object into storage. +func (s *InstanceOperation) Insert(io *internal.InstanceOperation) error { + defer unlock(s.lockW()) + + if io == nil { + return errors.New("entity may not be nil") + } + + if io.InstanceID.IsZero() || io.OperationID.IsZero() { + return errors.New("both instance and operation id must be set") + } + + if _, found := s.storage[io.InstanceID]; !found { + s.storage[io.InstanceID] = make(map[internal.OperationID]*internal.InstanceOperation) + } + + if _, found := s.storage[io.InstanceID][io.OperationID]; found { + return alreadyExistsError{} + } + + for oID := range s.storage[io.InstanceID] { + if s.storage[io.InstanceID][oID].State == internal.OperationStateInProgress { + return activeOperationInProgressError{} + } + } + + io.CreatedAt = s.nowProvider.Now() + + s.storage[io.InstanceID][io.OperationID] = io + + return nil +} + +// Get returns object from storage. +func (s *InstanceOperation) Get(iID internal.InstanceID, opID internal.OperationID) (*internal.InstanceOperation, error) { + defer unlock(s.lockR()) + + return s.get(iID, opID) +} + +func (s *InstanceOperation) get(iID internal.InstanceID, opID internal.OperationID) (*internal.InstanceOperation, error) { + if iID.IsZero() || opID.IsZero() { + return nil, errors.New("both instance and operation id must be set") + } + + if _, found := s.storage[iID]; !found { + return nil, notFoundError{} + } + + io, found := s.storage[iID][opID] + if !found { + return nil, notFoundError{} + } + + return io, nil +} + +// GetAll returns all objects from storage. +func (s *InstanceOperation) GetAll(iID internal.InstanceID) ([]*internal.InstanceOperation, error) { + defer unlock(s.lockR()) + + out := []*internal.InstanceOperation{} + + opsForInstance, found := s.storage[iID] + if !found { + return nil, notFoundError{} + } + + for i := range opsForInstance { + out = append(out, opsForInstance[i]) + } + + return out, nil +} + +// UpdateState modifies state on object in storage. +func (s *InstanceOperation) UpdateState(iID internal.InstanceID, opID internal.OperationID, state internal.OperationState) error { + defer unlock(s.lockW()) + + op, err := s.get(iID, opID) + if err != nil { + return err + } + + op.State = state + op.StateDescription = nil + + //s.logStateChange(iID, opID, state, nil) + return nil +} + +// UpdateStateDesc updates both state and description for single operation. +// If desc is nil than description will be removed. +func (s *InstanceOperation) UpdateStateDesc(iID internal.InstanceID, opID internal.OperationID, state internal.OperationState, desc *string) error { + defer unlock(s.lockW()) + + op, err := s.get(iID, opID) + if err != nil { + return err + } + + op.State = state + op.StateDescription = desc + + //s.logStateChange(iID, opID, state, desc) + return nil +} + +// Remove removes object from storage. +func (s *InstanceOperation) Remove(iID internal.InstanceID, opID internal.OperationID) error { + defer unlock(s.lockW()) + + if _, err := s.get(iID, opID); err != nil { + return err + } + + delete(s.storage[iID], opID) + if len(s.storage[iID]) == 0 { + delete(s.storage, iID) + } + + return nil +} diff --git a/components/remote-environment-broker/internal/storage/driver/memory/entity_remoteenvironment.go b/components/remote-environment-broker/internal/storage/driver/memory/entity_remoteenvironment.go new file mode 100644 index 000000000000..497a19b8695e --- /dev/null +++ b/components/remote-environment-broker/internal/storage/driver/memory/entity_remoteenvironment.go @@ -0,0 +1,129 @@ +package memory + +import ( + "fmt" + + "github.com/pkg/errors" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" +) + +const remoteEnvironmentKeyPattern = "re:%s" + +type remoteEnvironmentName string + +// NewRemoteEnvironment creates new storage for RemoteEnvironments +func NewRemoteEnvironment() *RemoteEnvironment { + return &RemoteEnvironment{ + storage: make(map[remoteEnvironmentName]*internal.RemoteEnvironment), + } +} + +// RemoteEnvironment entity +type RemoteEnvironment struct { + threadSafeStorage + storage map[remoteEnvironmentName]*internal.RemoteEnvironment +} + +// Upsert persists RemoteEnvironment in memory. +// +// If RemoteEnvironment already exists in storage than full replace is performed. +// +// True is returned if RemoteEnvironment already existed in storage and was replaced. +func (s *RemoteEnvironment) Upsert(re *internal.RemoteEnvironment) (bool, error) { + defer unlock(s.lockW()) + + if re == nil { + return false, errors.New("entity may not be nil") + } + + nk, err := s.keyFromRE(re) + if err != nil { + return false, err + } + + _, existedPreviously := s.storage[nk] + + s.storage[nk] = re + + return existedPreviously, nil +} + +// Get returns from memory RemoteEnvironment with given name +func (s *RemoteEnvironment) Get(name internal.RemoteEnvironmentName) (*internal.RemoteEnvironment, error) { + defer unlock(s.lockR()) + + nk, err := s.key(name) + if err != nil { + return nil, err + } + + re, found := s.storage[nk] + if !found { + return nil, notFoundError{} + } + + return re, nil +} + +// FindAll returns from memory all RemoteEnvironment +func (s *RemoteEnvironment) FindAll() ([]*internal.RemoteEnvironment, error) { + defer unlock(s.lockR()) + + dmList := make([]*internal.RemoteEnvironment, 0, len(s.storage)) + for _, item := range s.storage { + dmList = append(dmList, item) + } + + return dmList, nil +} + +// FindOneByServiceID returns RemoteEnvironment which contains Service with given ID +func (s *RemoteEnvironment) FindOneByServiceID(id internal.RemoteServiceID) (*internal.RemoteEnvironment, error) { + all, err := s.FindAll() + if err != nil { + return nil, errors.Wrap(err, "while reading all remote environments") + } + for _, re := range all { + for _, srv := range re.Services { + if id == srv.ID { + return re, nil + } + } + } + return nil, nil +} + +// Remove removes from memory RemoteEnvironment with given name +func (s *RemoteEnvironment) Remove(name internal.RemoteEnvironmentName) error { + defer unlock(s.lockW()) + + nk, err := s.key(name) + if err != nil { + return err + } + + if _, found := s.storage[nk]; !found { + return notFoundError{} + } + + delete(s.storage, nk) + + return nil +} + +func (s *RemoteEnvironment) keyFromRE(re *internal.RemoteEnvironment) (remoteEnvironmentName, error) { + if re == nil { + return "", errors.New("entity may not be nil") + } + + return s.key(re.Name) +} + +func (*RemoteEnvironment) key(name internal.RemoteEnvironmentName) (remoteEnvironmentName, error) { + if name == "" { + return "", errors.New("name must be set") + } + + return remoteEnvironmentName(fmt.Sprintf(remoteEnvironmentKeyPattern, name)), nil +} diff --git a/components/remote-environment-broker/internal/storage/driver/memory/error.go b/components/remote-environment-broker/internal/storage/driver/memory/error.go new file mode 100644 index 000000000000..c3cca58db468 --- /dev/null +++ b/components/remote-environment-broker/internal/storage/driver/memory/error.go @@ -0,0 +1,18 @@ +package memory + +type notFoundError struct{} + +func (notFoundError) Error() string { return "element not found" } +func (notFoundError) NotFound() bool { return true } + +type alreadyExistsError struct{} + +func (alreadyExistsError) Error() string { return "element already exists" } +func (alreadyExistsError) AlreadyExists() bool { return true } + +type activeOperationInProgressError struct{} + +func (activeOperationInProgressError) Error() string { + return "there is an active operation in progres for instance" +} +func (activeOperationInProgressError) ActiveOperationInProgress() bool { return true } diff --git a/components/remote-environment-broker/internal/storage/driver/memory/sync.go b/components/remote-environment-broker/internal/storage/driver/memory/sync.go new file mode 100644 index 000000000000..a8b4b3ec90f2 --- /dev/null +++ b/components/remote-environment-broker/internal/storage/driver/memory/sync.go @@ -0,0 +1,29 @@ +/* +Code in this file is based on code from Kubernetes Helm. +Original code was licensed under the Apache License, Version 2.0 with copyright assigned to "2016 The Kubernetes Authors All rights reserved". +*/ + +package memory + +import "sync" + +type threadSafeStorage struct { + sync.RWMutex +} + +// lockW locks storage for writing +func (s *threadSafeStorage) lockW() func() { + s.Lock() + return func() { s.Unlock() } +} + +// lockR locks storage for reading +func (s *threadSafeStorage) lockR() func() { + s.RLock() + return func() { s.RUnlock() } +} + +// unlock calls fn which reverses a lockR or lockW. e.g: +// ```defer unlock(s.lockR())```, locks mem for reading at the +// call point of defer and unlocks upon exiting the block. +func unlock(fn func()) { fn() } diff --git a/components/remote-environment-broker/internal/storage/ext.go b/components/remote-environment-broker/internal/storage/ext.go new file mode 100644 index 000000000000..76f28c02a883 --- /dev/null +++ b/components/remote-environment-broker/internal/storage/ext.go @@ -0,0 +1,51 @@ +package storage + +import ( + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" +) + +// RemoteEnvironment is an interface that describe storage layer operations for Charts +type RemoteEnvironment interface { + Upsert(re *internal.RemoteEnvironment) (bool, error) + Get(name internal.RemoteEnvironmentName) (*internal.RemoteEnvironment, error) + FindAll() ([]*internal.RemoteEnvironment, error) + FindOneByServiceID(id internal.RemoteServiceID) (*internal.RemoteEnvironment, error) + Remove(name internal.RemoteEnvironmentName) error +} + +// Instance is an interface that describe storage layer operations for Instances +type Instance interface { + Insert(i *internal.Instance) error + Remove(id internal.InstanceID) error + Get(id internal.InstanceID) (*internal.Instance, error) + FindOne(func(i *internal.Instance) bool) (*internal.Instance, error) + UpdateState(iID internal.InstanceID, state internal.InstanceState) error +} + +// InstanceOperation is an interface that describe storage layer operations for InstanceOperations +type InstanceOperation interface { + // Insert is inserting object into storage. + // Object is modified by setting CreatedAt. + Insert(*internal.InstanceOperation) error + Get(internal.InstanceID, internal.OperationID) (*internal.InstanceOperation, error) + GetAll(internal.InstanceID) ([]*internal.InstanceOperation, error) + UpdateState(internal.InstanceID, internal.OperationID, internal.OperationState) error + UpdateStateDesc(internal.InstanceID, internal.OperationID, internal.OperationState, *string) error + Remove(internal.InstanceID, internal.OperationID) error +} + +// IsNotFoundError checks if given error is NotFound error +func IsNotFoundError(err error) bool { + nfe, ok := err.(interface { + NotFound() bool + }) + return ok && nfe.NotFound() +} + +// IsAlreadyExistsError checks if given error is AlreadyExist error +func IsAlreadyExistsError(err error) bool { + aee, ok := err.(interface { + AlreadyExists() bool + }) + return ok && aee.AlreadyExists() +} diff --git a/components/remote-environment-broker/internal/storage/factory_test.go b/components/remote-environment-broker/internal/storage/factory_test.go new file mode 100644 index 000000000000..91628036aef3 --- /dev/null +++ b/components/remote-environment-broker/internal/storage/factory_test.go @@ -0,0 +1,34 @@ +package storage_test + +import ( + "testing" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/storage" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/storage/driver/memory" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/storage/testdata" + "github.com/stretchr/testify/assert" +) + +func TestNewFactory(t *testing.T) { + for s, tc := range map[string]struct { + cfgGen func() storage.ConfigList + expRemoteEnvironment interface{} + }{ + "MemorySingleAll": {testdata.GoldenConfigMemorySingleAll, &memory.RemoteEnvironment{}}, + "MemorySingleSeparate": {testdata.GoldenConfigMemorySingleSeparate, &memory.RemoteEnvironment{}}, + "MemoryMultipleSeparate": {testdata.GoldenConfigMemoryMultipleSeparate, &memory.RemoteEnvironment{}}, + } { + t.Run(s, func(t *testing.T) { + // GIVEN: + cfg := tc.cfgGen() + + // WHEN: + got, err := storage.NewFactory(&cfg) + + // THEN: + assert.NoError(t, err) + + assert.IsType(t, tc.expRemoteEnvironment, got.RemoteEnvironment()) + }) + } +} diff --git a/components/remote-environment-broker/internal/storage/populator/automock/instance_inserter.go b/components/remote-environment-broker/internal/storage/populator/automock/instance_inserter.go new file mode 100644 index 000000000000..052deeb78aba --- /dev/null +++ b/components/remote-environment-broker/internal/storage/populator/automock/instance_inserter.go @@ -0,0 +1,24 @@ +// Code generated by mockery v1.0.0 +package automock + +import internal "github.com/kyma-project/kyma/components/remote-environment-broker/internal" +import mock "github.com/stretchr/testify/mock" + +// InstanceInserter is an autogenerated mock type for the InstanceInserter type +type InstanceInserter struct { + mock.Mock +} + +// Insert provides a mock function with given fields: i +func (_m *InstanceInserter) Insert(i *internal.Instance) error { + ret := _m.Called(i) + + var r0 error + if rf, ok := ret.Get(0).(func(*internal.Instance) error); ok { + r0 = rf(i) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/components/remote-environment-broker/internal/storage/populator/instance.go b/components/remote-environment-broker/internal/storage/populator/instance.go new file mode 100644 index 000000000000..746d7e16bd42 --- /dev/null +++ b/components/remote-environment-broker/internal/storage/populator/instance.go @@ -0,0 +1,143 @@ +package populator + +import ( + "context" + "time" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kubernetes-incubator/service-catalog/pkg/client/clientset_generated/clientset" + scv1beta "github.com/kubernetes-incubator/service-catalog/pkg/client/informers_generated/externalversions/servicecatalog/v1beta1" + listersv1beta "github.com/kubernetes-incubator/service-catalog/pkg/client/listers_generated/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/pkg/errors" + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +const informerResyncPeriod = 30 * time.Minute + +// Instances provide method for populating Instance storage +type Instances struct { + inserter instanceInserter + scClientSet clientset.Interface + brokerName string +} + +// NewInstances is a constructor of Instances populator +func NewInstances(scClientSet clientset.Interface, inserter instanceInserter, brokerName string) *Instances { + return &Instances{ + scClientSet: scClientSet, + inserter: inserter, + brokerName: brokerName, + } +} + +// Do perform instances population +func (p *Instances) Do(ctx context.Context) error { + siInformer := scv1beta.NewServiceInstanceInformer(p.scClientSet, v1.NamespaceAll, informerResyncPeriod, nil) + scInformer := scv1beta.NewClusterServiceClassInformer(p.scClientSet, informerResyncPeriod, nil) + + go siInformer.Run(ctx.Done()) + go scInformer.Run(ctx.Done()) + + if !cache.WaitForCacheSync(ctx.Done(), siInformer.HasSynced) { + return errors.New("cannot synchronize service instance cache") + } + + if !cache.WaitForCacheSync(ctx.Done(), scInformer.HasSynced) { + return errors.New("cannot synchronize service class cache") + } + + scLister := listersv1beta.NewClusterServiceClassLister(scInformer.GetIndexer()) + serviceClasses, err := scLister.List(labels.Everything()) + if err != nil { + return errors.Wrap(err, "while listing service classes") + } + + rebClassNames := make(map[string]struct{}) + for _, sc := range serviceClasses { + if sc.Spec.ClusterServiceBrokerName == p.brokerName { + rebClassNames[sc.Name] = struct{}{} + } + } + + siLister := listersv1beta.NewServiceInstanceLister(siInformer.GetIndexer()) + serviceInstances, err := siLister.List(labels.Everything()) + if err != nil { + return errors.Wrap(err, "while listing service instances") + } + + for _, si := range serviceInstances { + if _, ex := rebClassNames[si.Spec.ClusterServiceClassRef.Name]; ex { + if err := p.inserter.Insert(p.mapServiceInstance(si)); err != nil { + return errors.Wrap(err, "while inserting service instance") + } + } + } + return nil +} + +func (p *Instances) mapServiceInstance(in *v1beta1.ServiceInstance) *internal.Instance { + var state internal.InstanceState + + if p.isServiceInstanceReady(in) { + state = internal.InstanceStateSucceeded + } else { + state = internal.InstanceStateFailed + } + + return &internal.Instance{ + ID: internal.InstanceID(in.Spec.ExternalID), + Namespace: internal.Namespace(in.Namespace), + ParamsHash: "TODO", + ServicePlanID: internal.ServicePlanID(in.Spec.ClusterServicePlanRef.Name), + ServiceID: internal.ServiceID(in.Spec.ClusterServiceClassRef.Name), + State: state, + } +} + +//go:generate mockery -name=instanceInserter -output=automock -outpkg=automock -case=underscore +type instanceInserter interface { + Insert(i *internal.Instance) error +} + +func (p *Instances) isServiceInstanceReady(instance *v1beta1.ServiceInstance) bool { + for _, cond := range instance.Status.Conditions { + if cond.Type == v1beta1.ServiceInstanceConditionReady { + return cond.Status == v1beta1.ConditionTrue + } + } + return false +} + +/* +Objects taking part, example: + +- apiVersion: servicecatalog.k8s.io/v1beta1 + kind: ClusterServiceClass + metadata: + name: 48ab05bf-9aa4-4cb7-8999-0d3587265ac3 + spec: + clusterServiceBrokerName: core-remote-environment-broker + + +--- +- apiVersion: servicecatalog.k8s.io/v1beta1 + kind: ServiceInstance + metadata: + name: reb-instance-1 + namespace: default + spec: + clusterServiceClassExternalName: orders + clusterServiceClassRef: + name: 48ab05bf-9aa4-4cb7-8999-0d3587265ac3 + clusterServicePlanExternalName: default + clusterServicePlanRef: + name: 48ab05bf-9aa4-4cb7-8999-0d3587265ac3-plan + externalID: b180ef2f-1215-4439-a24c-850caf78d74b + +--- +PUT /v2/service_instances/b180ef2f-1215-4439-a24c-850caf78d74b/ + +*/ diff --git a/components/remote-environment-broker/internal/storage/populator/instance_test.go b/components/remote-environment-broker/internal/storage/populator/instance_test.go new file mode 100644 index 000000000000..c9198a2dd130 --- /dev/null +++ b/components/remote-environment-broker/internal/storage/populator/instance_test.go @@ -0,0 +1,203 @@ +package populator_test + +import ( + "context" + "fmt" + "testing" + + scv1beta1 "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kubernetes-incubator/service-catalog/pkg/client/clientset_generated/clientset/fake" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/storage/populator" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/storage/populator/automock" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPopulateOnlyREBInstances(t *testing.T) { + // GIVEN + mockClientSet := fake.NewSimpleClientset( + fixRedisServiceClass(), + fixRebServiceClass(), + fixRedisServiceInstance(), + fixRebServiceInstanceInDefaultNs(), + fixRebServiceInstnaceInProdNs()) + + mockInserter := &automock.InstanceInserter{} + defer mockInserter.AssertExpectations(t) + + insertExpectations := newSiInsertExpectations(expectedServiceInstanceInProdNs(), expectedServiceInstanceInDefaultNs()) + defer insertExpectations.AssertExpectations(t) + + mockInserter.On("Insert", mock.MatchedBy(insertExpectations.OnInsertArgsChecker)). + Run(func(args mock.Arguments) { + actualInstance := args.Get(0).(*internal.Instance) + insertExpectations.ReportInsertingInstance(actualInstance) + }). + Return(nil).Twice() + + sut := populator.NewInstances(mockClientSet, mockInserter, fixREBrokerName()) + // WHEN + actualErr := sut.Do(context.Background()) + // THEN + assert.NoError(t, actualErr) +} + +func TestPopulateErrorOnInsert(t *testing.T) { + // GIVEN + mockClientSet := fake.NewSimpleClientset(fixRebServiceClass(), fixRebServiceInstanceInDefaultNs()) + mockInserter := &automock.InstanceInserter{} + defer mockInserter.AssertExpectations(t) + + mockInserter.On("Insert", mock.Anything).Return(errors.New("some error")) + sut := populator.NewInstances(mockClientSet, mockInserter, fixREBrokerName()) + // WHEN + actualErr := sut.Do(context.Background()) + // THEN + assert.Error(t, actualErr) + fmt.Println(actualErr) + +} + +func fixRedisServiceClass() *scv1beta1.ClusterServiceClass { + return &scv1beta1.ClusterServiceClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "service-class-redis", + }, + Spec: scv1beta1.ClusterServiceClassSpec{ + ClusterServiceBrokerName: fixHelmBrokerName(), + }, + } +} + +func fixRebServiceClass() *scv1beta1.ClusterServiceClass { + return &scv1beta1.ClusterServiceClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "service-class-reb", + }, + Spec: scv1beta1.ClusterServiceClassSpec{ + ClusterServiceBrokerName: fixREBrokerName(), + }, + } +} + +func fixRedisServiceInstance() *scv1beta1.ServiceInstance { + return &scv1beta1.ServiceInstance{ + Spec: scv1beta1.ServiceInstanceSpec{ + ClusterServiceClassRef: &scv1beta1.ClusterObjectReference{ + Name: "service-class-redis", + }, + ExternalID: "redis-external-id", + ClusterServicePlanRef: &scv1beta1.ClusterObjectReference{ + Name: "micro", + }, + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "redis-instance", + }, + } +} + +func fixRebServiceInstanceInDefaultNs() *scv1beta1.ServiceInstance { + return &scv1beta1.ServiceInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "orders-instance-1", + Namespace: metav1.NamespaceDefault, + }, + Spec: scv1beta1.ServiceInstanceSpec{ + ClusterServiceClassRef: &scv1beta1.ClusterObjectReference{ + Name: "service-class-reb", + }, + ExternalID: "orders-external-id-1", + ClusterServicePlanRef: &scv1beta1.ClusterObjectReference{ + Name: "default", + }, + }, + } +} + +func expectedServiceInstanceInDefaultNs() *internal.Instance { + return &internal.Instance{ + ID: internal.InstanceID("orders-external-id-1"), + Namespace: internal.Namespace(metav1.NamespaceDefault), + ParamsHash: "TODO", + ServicePlanID: internal.ServicePlanID("default"), + ServiceID: internal.ServiceID("service-class-reb"), + State: internal.InstanceStateFailed, + } +} + +func fixRebServiceInstnaceInProdNs() *scv1beta1.ServiceInstance { + return &scv1beta1.ServiceInstance{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "prod", + Name: "orders-instance-2", + }, + Spec: scv1beta1.ServiceInstanceSpec{ + ClusterServiceClassRef: &scv1beta1.ClusterObjectReference{ + Name: "service-class-reb", + }, + ExternalID: "orders-external-id-2", + ClusterServicePlanRef: &scv1beta1.ClusterObjectReference{ + Name: "default", + }, + }, + Status: scv1beta1.ServiceInstanceStatus{ + Conditions: []scv1beta1.ServiceInstanceCondition{ + { + Type: scv1beta1.ServiceInstanceConditionReady, + Status: scv1beta1.ConditionTrue, + }, + }, + }, + } +} + +func expectedServiceInstanceInProdNs() *internal.Instance { + return &internal.Instance{ + ID: internal.InstanceID("orders-external-id-2"), + Namespace: internal.Namespace("prod"), + ParamsHash: "TODO", + ServicePlanID: internal.ServicePlanID("default"), + ServiceID: internal.ServiceID("service-class-reb"), + State: internal.InstanceStateSucceeded, + } +} + +func fixREBrokerName() string { + return "remote-environment-broker" +} + +func fixHelmBrokerName() string { + return "helm-broker" +} + +type siInsertExpectations struct { + insertCount map[internal.Instance]int +} + +func newSiInsertExpectations(expectedInstances ...*internal.Instance) siInsertExpectations { + exp := siInsertExpectations{insertCount: make(map[internal.Instance]int)} + for _, inst := range expectedInstances { + exp.insertCount[*inst] = 0 + } + return exp +} + +func (e *siInsertExpectations) OnInsertArgsChecker(actual *internal.Instance) bool { + _, ok := e.insertCount[*actual] + return ok +} + +func (e *siInsertExpectations) ReportInsertingInstance(actual *internal.Instance) { + e.insertCount[*actual] = e.insertCount[*actual] + 1 +} + +func (e *siInsertExpectations) AssertExpectations(t *testing.T) { + for k, v := range e.insertCount { + assert.Equal(t, 1, v, "Incorrect number of inserts for [%+v]", k) + } +} diff --git a/components/remote-environment-broker/internal/storage/storage.go b/components/remote-environment-broker/internal/storage/storage.go new file mode 100644 index 000000000000..55fc07a431b7 --- /dev/null +++ b/components/remote-environment-broker/internal/storage/storage.go @@ -0,0 +1,129 @@ +package storage + +import ( + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/storage/driver/memory" + "github.com/pkg/errors" + "gopkg.in/yaml.v2" +) + +// Factory provides access to concrete storage. +// Multiple calls should to specific storage return the same storage instance. +type Factory interface { + RemoteEnvironment() RemoteEnvironment + Instance() Instance + InstanceOperation() InstanceOperation +} + +// DriverType defines type of data storage +type DriverType string + +const ( + // DriverMemory is a driver to local in-memory store + DriverMemory DriverType = "memory" +) + +// EntityName defines name of the entity in database +type EntityName string + +const ( + // EntityAll represents name of all entities + EntityAll EntityName = "all" + // EntityRemoteEnvironment represents name of remote environment entities + EntityRemoteEnvironment EntityName = "remoteenvironment" + // EntityInstance represents name of services instances entities + EntityInstance EntityName = "instance" + // EntityInstanceOperation represents name of instances operations entities + EntityInstanceOperation EntityName = "instanceOperation" +) + +// ProviderConfig provides configuration to the database provider +type ProviderConfig struct{} + +// ProviderConfigMap contains map of provided configurations for given entities +type ProviderConfigMap map[EntityName]ProviderConfig + +// Config contains database configuration. +type Config struct { + Driver DriverType `json:"driver" valid:"required"` + Provide ProviderConfigMap `json:"provide" valid:"required"` + Memory memory.Config `json:"memory"` +} + +// ConfigList is a list of configurations +type ConfigList []Config + +// ConfigParse is parsing yaml file to the ConfigList +func ConfigParse(inByte []byte) (*ConfigList, error) { + var cl ConfigList + + if err := yaml.Unmarshal(inByte, &cl); err != nil { + return nil, errors.Wrap(err, "while unmarshalling yaml") + } + + return &cl, nil +} + +// NewFactory is a factory for entities based on given ConfigList +func NewFactory(cl *ConfigList) (Factory, error) { + fact := concreteFactory{} + + for _, cfg := range *cl { + + var ( + remoteEnvironmentFactory func() (RemoteEnvironment, error) + instanceFactory func() (Instance, error) + instanceOperationFactory func() (InstanceOperation, error) + ) + + switch cfg.Driver { + case DriverMemory: + remoteEnvironmentFactory = func() (RemoteEnvironment, error) { + return memory.NewRemoteEnvironment(), nil + } + instanceFactory = func() (Instance, error) { + return memory.NewInstance(), nil + } + instanceOperationFactory = func() (InstanceOperation, error) { + return memory.NewInstanceOperation(), nil + } + default: + return nil, errors.New("unknown driver type") + } + + for em := range cfg.Provide { + switch em { + case EntityRemoteEnvironment: + fact.re, _ = remoteEnvironmentFactory() + case EntityInstance: + fact.instance, _ = instanceFactory() + case EntityInstanceOperation: + fact.op, _ = instanceOperationFactory() + case EntityAll: + fact.re, _ = remoteEnvironmentFactory() + fact.instance, _ = instanceFactory() + fact.op, _ = instanceOperationFactory() + default: + } + } + } + + return &fact, nil +} + +type concreteFactory struct { + re RemoteEnvironment + instance Instance + op InstanceOperation +} + +func (f *concreteFactory) RemoteEnvironment() RemoteEnvironment { + return f.re +} + +func (f *concreteFactory) Instance() Instance { + return f.instance +} + +func (f *concreteFactory) InstanceOperation() InstanceOperation { + return f.op +} diff --git a/components/remote-environment-broker/internal/storage/testdata/Config.golden.go b/components/remote-environment-broker/internal/storage/testdata/Config.golden.go new file mode 100644 index 000000000000..a49b3eb8566e --- /dev/null +++ b/components/remote-environment-broker/internal/storage/testdata/Config.golden.go @@ -0,0 +1,31 @@ +package testdata + +import "github.com/kyma-project/kyma/components/remote-environment-broker/internal/storage" + +func GoldenConfigMemorySingleAll() storage.ConfigList { + return storage.ConfigList{ + { + Driver: storage.DriverMemory, + Provide: storage.ProviderConfigMap{ + storage.EntityAll: storage.ProviderConfig{}, + }, + }, + } +} + +func GoldenConfigMemorySingleSeparate() storage.ConfigList { + return storage.ConfigList{ + { + Driver: storage.DriverMemory, + Provide: storage.ProviderConfigMap{ + storage.EntityRemoteEnvironment: storage.ProviderConfig{}, + }, + }, + } +} + +func GoldenConfigMemoryMultipleSeparate() storage.ConfigList { + return storage.ConfigList{ + {Driver: storage.DriverMemory, Provide: storage.ProviderConfigMap{storage.EntityRemoteEnvironment: storage.ProviderConfig{}}}, + } +} diff --git a/components/remote-environment-broker/internal/storage/testdata/ConfigAllMemory.input.yaml b/components/remote-environment-broker/internal/storage/testdata/ConfigAllMemory.input.yaml new file mode 100644 index 000000000000..ac0ad4ef6d23 --- /dev/null +++ b/components/remote-environment-broker/internal/storage/testdata/ConfigAllMemory.input.yaml @@ -0,0 +1,3 @@ +- driver: memory + provide: + all: ~ diff --git a/components/remote-environment-broker/internal/storage/testing/common_test.go b/components/remote-environment-broker/internal/storage/testing/common_test.go new file mode 100644 index 000000000000..bc0e22e8f386 --- /dev/null +++ b/components/remote-environment-broker/internal/storage/testing/common_test.go @@ -0,0 +1,41 @@ +package testing + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/storage" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/storage/driver/memory" +) + +var allDrivers = map[storage.DriverType]func() storage.ConfigList{ + storage.DriverMemory: func() storage.ConfigList { + return storage.ConfigList{storage.Config{ + Driver: storage.DriverMemory, + Provide: storage.ProviderConfigMap{storage.EntityAll: storage.ProviderConfig{}}, + Memory: memory.Config{ + // Ignored for now + MaxKeys: 666, + }, + }} + }, +} + +func tRunDrivers(t *testing.T, tName string, f func(*testing.T, storage.Factory)) bool { + result := true + for dt, clGen := range allDrivers { + cl := clGen() + + fT := func(t *testing.T) { + sf, err := storage.NewFactory(&cl) + require.NoError(t, err) + + f(t, sf) + } + result = t.Run(fmt.Sprintf("%s/%s", dt, tName), fT) && result + } + + return result +} diff --git a/components/remote-environment-broker/internal/storage/testing/doc.go b/components/remote-environment-broker/internal/storage/testing/doc.go new file mode 100644 index 000000000000..73f8d8ea41ab --- /dev/null +++ b/components/remote-environment-broker/internal/storage/testing/doc.go @@ -0,0 +1,4 @@ +// Package testing provides test functions for storage. +// Those functions should not be driver (backend) specific but should test storage API +// regardless of actual implementation. +package testing diff --git a/components/remote-environment-broker/internal/storage/testing/instance_test.go b/components/remote-environment-broker/internal/storage/testing/instance_test.go new file mode 100644 index 000000000000..ab3c46130ce3 --- /dev/null +++ b/components/remote-environment-broker/internal/storage/testing/instance_test.go @@ -0,0 +1,248 @@ +package testing + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/storage" +) + +func TestInstanceGet(t *testing.T) { + tRunDrivers(t, "Found", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceTestSuite(t, sf) + ts.PopulateStorage() + exp := ts.MustGetFixture("A1") + + // WHEN: + got, err := ts.s.Get(exp.ID) + + // THEN: + assert.NoError(t, err) + ts.AssertInstanceEqual(exp, got) + }) + + tRunDrivers(t, "Failure/NotFound", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceTestSuite(t, sf) + ts.PopulateStorage() + + // WHEN: + got, err := ts.s.Get(internal.InstanceID("non-existing-iID")) + + // THEN: + ts.AssertNotFoundError(err) + assert.Nil(t, got) + }) +} + +func TestInstanceFindOne(t *testing.T) { + tRunDrivers(t, "Success/Found/MatchNamespaceAndClass", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceTestSuite(t, sf) + ts.PopulateStorage() + exp := ts.MustGetFixture("B3") + + // WHEN: + got, err := ts.s.FindOne(func(i *internal.Instance) bool { + if i.Namespace != exp.Namespace { + return false + } + if i.ServiceID != exp.ServiceID { + return false + } + return true + }) + + // THEN: + assert.NoError(t, err) + ts.AssertInstanceEqual(exp, got) + }) + + tRunDrivers(t, "Success/NotFound/MatchNamespaceButNotClass", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceTestSuite(t, sf) + ts.PopulateStorage() + exp := ts.MustGetFixture("B3") + + // WHEN: + got, err := ts.s.FindOne(func(i *internal.Instance) bool { + if i.Namespace != exp.Namespace { + return false + } + if i.ServiceID != internal.ServiceID("not-existing-service-id") { + return false + } + return true + }) + + // THEN: + assert.NoError(t, err) + assert.Nil(t, got) + }) +} + +func TestInstanceInsert(t *testing.T) { + tRunDrivers(t, "Success/New", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceTestSuite(t, sf) + fix := ts.MustGetFixture("A1") + + // WHEN: + err := ts.s.Insert(fix) + + // THEN: + assert.NoError(t, err) + }) + + tRunDrivers(t, "Failure/AlreadyExist", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceTestSuite(t, sf) + fix := ts.MustGetFixture("A1") + ts.s.Insert(fix) + + // WHEN: + fixNew := ts.MustCopyFixture(fix) + err := ts.s.Insert(fixNew) + + // THEN: + ts.AssertAlreadyExistsError(err) + }) + + tRunDrivers(t, "Failure/EmptyID", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceTestSuite(t, sf) + fix := ts.MustGetFixture("A1") + fix.ID = internal.InstanceID("") + + // WHEN: + err := ts.s.Insert(fix) + + // THEN: + assert.EqualError(t, err, "instance id must be set") + }) +} + +func TestInstanceRemove(t *testing.T) { + tRunDrivers(t, "Success", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceTestSuite(t, sf) + ts.PopulateStorage() + exp := ts.MustGetFixture("A1") + + // WHEN: + err := ts.s.Remove(exp.ID) + + // THEN: + assert.NoError(t, err) + ts.AssertInstanceDoesNotExist(exp) + }) + + tRunDrivers(t, "Failure/NotFound", func(t *testing.T, sf storage.Factory) { + // GIVEN: + ts := newInstanceTestSuite(t, sf) + exp := ts.MustGetFixture("A1") + + // WHEN: + err := ts.s.Remove(exp.ID) + + // THEN: + ts.AssertNotFoundError(err) + }) +} + +func newInstanceTestSuite(t *testing.T, sf storage.Factory) *instanceTestSuite { + ts := instanceTestSuite{ + t: t, + s: sf.Instance(), + fixtures: make(map[internal.InstanceID]*internal.Instance), + fixturesSymToKeyMap: make(map[string]internal.InstanceID), + } + + ts.generateFixtures() + + return &ts +} + +type instanceTestSuite struct { + t *testing.T + s storage.Instance + fixtures map[internal.InstanceID]*internal.Instance + fixturesSymToKeyMap map[string]internal.InstanceID +} + +func (ts *instanceTestSuite) generateFixtures() { + for fs, ft := range map[string]struct{ ns, id, sID, spID, rName, pHash string }{ + "A1": {"ns-a", "id-01", "sID-01", "spID-01", "rName-01-01", "pHash-01"}, + "A2": {"ns-a", "id-02", "sID-01", "spID-01", "rName-01-02", "pHash-02"}, + "A3": {"ns-a", "id-03", "sID-03", "spID-03", "rName-03", "pHash-03"}, + "B3": {"ns-b", "id-04", "sID-04", "spID-04", "rName-04", "pHash-04"}, + } { + i := &internal.Instance{ + Namespace: internal.Namespace(ft.ns), + ID: internal.InstanceID(ft.id), + ServiceID: internal.ServiceID(ft.sID), + ServicePlanID: internal.ServicePlanID(ft.spID), + ParamsHash: ft.pHash, + } + + ts.fixtures[i.ID] = i + ts.fixturesSymToKeyMap[fs] = i.ID + } +} + +func (ts *instanceTestSuite) PopulateStorage() { + for _, b := range ts.fixtures { + ts.s.Insert(ts.MustCopyFixture(b)) + } +} + +func (ts *instanceTestSuite) MustGetFixture(sym string) *internal.Instance { + k, found := ts.fixturesSymToKeyMap[sym] + if !found { + panic(fmt.Sprintf("fixture symbol not found, sym: %s", sym)) + } + + b, found := ts.fixtures[k] + if !found { + panic(fmt.Sprintf("fixture not found, sym: %s, nameVersion: %s", sym, k)) + } + + return b +} + +// CopyFixture is copying fixture +// BEWARE: not all fields are copied, only those currently used in this test suite scope +func (ts *instanceTestSuite) MustCopyFixture(in *internal.Instance) *internal.Instance { + return &internal.Instance{ + Namespace: in.Namespace, + ID: in.ID, + ServiceID: in.ServiceID, + ServicePlanID: in.ServicePlanID, + ParamsHash: in.ParamsHash, + } +} + +func (ts *instanceTestSuite) AssertInstanceEqual(exp, got *internal.Instance) bool { + ts.t.Helper() + return assert.EqualValues(ts.t, exp, got) +} + +func (ts *instanceTestSuite) AssertNotFoundError(err error) bool { + ts.t.Helper() + return assert.True(ts.t, storage.IsNotFoundError(err), "NotFound error expected") +} + +func (ts *instanceTestSuite) AssertAlreadyExistsError(err error) bool { + ts.t.Helper() + return assert.True(ts.t, storage.IsAlreadyExistsError(err), "AlreadyExists error expected") +} + +func (ts *instanceTestSuite) AssertInstanceDoesNotExist(i *internal.Instance) bool { + ts.t.Helper() + _, err := ts.s.Get(i.ID) + return assert.True(ts.t, storage.IsNotFoundError(err), "NotFound error expected") +} diff --git a/components/remote-environment-broker/internal/storage/testing/remoteenvironment_test.go b/components/remote-environment-broker/internal/storage/testing/remoteenvironment_test.go new file mode 100644 index 000000000000..6b2e9f960415 --- /dev/null +++ b/components/remote-environment-broker/internal/storage/testing/remoteenvironment_test.go @@ -0,0 +1,263 @@ +package testing + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/storage" +) + +func TestRemoteEnvironmentGet(t *testing.T) { + tRunDrivers(t, "Found", func(t *testing.T, sf storage.Factory) { + // given + ts := newRETestSuite(t, sf) + ts.PopulateStorage() + exp := ts.MustGetFixture("A1") + + // when + got, err := ts.reStorage.Get(internal.RemoteEnvironmentName(exp.Name)) + + // then + assert.NoError(t, err) + assert.EqualValues(t, exp, got) + }) + + tRunDrivers(t, "NotFound", func(t *testing.T, sf storage.Factory) { + // given + ts := newRETestSuite(t, sf) + exp := ts.MustGetFixture("A1") + + // when + got, err := ts.reStorage.Get(internal.RemoteEnvironmentName(exp.Name)) + + // then + ts.AssertNotFoundError(err) + assert.Nil(t, got) + }) +} + +func TestRemoteEnvironmentFindAll(t *testing.T) { + tRunDrivers(t, "Found", func(t *testing.T, sf storage.Factory) { + // given + ts := newRETestSuite(t, sf) + ts.PopulateStorage() + + // when + got, err := ts.reStorage.FindAll() + + // then + assert.NoError(t, err) + ts.AssertContainsAllFixtures(got) + }) +} + +func TestRemoteEnvironmentFindOneByServiceID(t *testing.T) { + tRunDrivers(t, "Found", func(t *testing.T, sf storage.Factory) { + // given + ts := newRETestSuite(t, sf) + ts.PopulateStorage() + exp := ts.MustGetFixture("A1") + + // when + got, err := ts.reStorage.FindOneByServiceID(exp.Services[0].ID) + + // then + assert.NoError(t, err) + assert.EqualValues(t, exp, got) + }) + tRunDrivers(t, "NotFound", func(t *testing.T, sf storage.Factory) { + // given + ts := newRETestSuite(t, sf) + ts.PopulateStorage() + + // when + got, err := ts.reStorage.FindOneByServiceID(internal.RemoteServiceID("apud")) + + // then + assert.NoError(t, err) + assert.Nil(t, got) + }) +} + +func TestRemoteEnvironmentUpsert(t *testing.T) { + tRunDrivers(t, "Success/New", func(t *testing.T, sf storage.Factory) { + // given + ts := newRETestSuite(t, sf) + fix := ts.MustGetFixture("A1") + + // when + replace, err := ts.reStorage.Upsert(fix) + + // then + assert.NoError(t, err) + assert.False(t, replace) + }) + + tRunDrivers(t, "Success/Replace", func(t *testing.T, sf storage.Factory) { + // given + expDesc := "updated description" + ts := newRETestSuite(t, sf) + fix := ts.MustGetFixture("A1") + ts.reStorage.Upsert(fix) + + // when + fixNew := ts.MustCopyFixture(fix) + fixNew.Description = expDesc + replace, err := ts.reStorage.Upsert(fixNew) + + // then + assert.NoError(t, err) + assert.True(t, replace) + + got, err := ts.reStorage.Get(internal.RemoteEnvironmentName(fixNew.Name)) + assert.NoError(t, err) + assert.EqualValues(t, fixNew, got) + }) + + tRunDrivers(t, "Failure/EmptyName", func(t *testing.T, sf storage.Factory) { + // given + ts := newRETestSuite(t, sf) + fix := ts.MustGetFixture("A1") + fix.Name = "" + + // when + _, err := ts.reStorage.Upsert(fix) + + // then + assert.EqualError(t, err, "name must be set") + }) +} + +func TestRemoteEnvironmentRemove(t *testing.T) { + tRunDrivers(t, "Found", func(t *testing.T, sf storage.Factory) { + // given + ts := newRETestSuite(t, sf) + ts.PopulateStorage() + exp := ts.MustGetFixture("A1") + + // when + err := ts.reStorage.Remove(internal.RemoteEnvironmentName(exp.Name)) + + // then + assert.NoError(t, err) + ts.AssertChartDoesNotExist(exp) + }) + + tRunDrivers(t, "NotFound", func(t *testing.T, sf storage.Factory) { + // given + ts := newRETestSuite(t, sf) + exp := ts.MustGetFixture("A1") + + // when + err := ts.reStorage.Remove(internal.RemoteEnvironmentName(exp.Name)) + + // then + ts.AssertNotFoundError(err) + }) +} + +func newRETestSuite(t *testing.T, sf storage.Factory) *reTestSuite { + ts := reTestSuite{ + t: t, + reStorage: sf.RemoteEnvironment(), + fixtures: make(map[string]*internal.RemoteEnvironment), + } + + ts.generateFixtures() + + return &ts +} + +type reTestSuite struct { + t *testing.T + reStorage storage.RemoteEnvironment + fixtures map[string]*internal.RemoteEnvironment +} + +func (ts *reTestSuite) generateFixtures() { + for fs, ft := range map[string]struct{ name, id, desc string }{ + "A1": {"name-A1", "service-id-A1", "desc-A1"}, + "A2": {"name-A2", "service-id-A2", "desc-A2"}, + "B1": {"name-B1", "service-id-B1", "desc-B1"}, + "B2": {"name-B2", "service-id-B2", "desc-B2"}, + } { + re := &internal.RemoteEnvironment{ + Name: internal.RemoteEnvironmentName(ft.name), + Description: ft.desc, + Source: internal.Source{ + Environment: "env", + Namespace: "ns", + Type: "type", + }, + Services: []internal.Service{ + { + ID: internal.RemoteServiceID(ft.id), + DisplayName: "displayName", + APIEntry: &internal.APIEntry{ + AccessLabel: "access-label", + GatewayURL: "http://gateway.io", + Entry: internal.Entry{ + Type: "API", + }, + }, + Tags: []string{"tag1", "tag2"}, + }, + }, + } + + ts.fixtures[fs] = re + } +} + +func (ts *reTestSuite) PopulateStorage() { + for _, fix := range ts.fixtures { + ts.reStorage.Upsert(fix) + } +} + +func (ts *reTestSuite) MustGetFixture(name string) *internal.RemoteEnvironment { + fix, found := ts.fixtures[name] + if !found { + panic(fmt.Sprintf("fixture with name %q not found", name)) + } + + return ts.MustCopyFixture(fix) +} + +func (ts *reTestSuite) MustCopyFixture(in *internal.RemoteEnvironment) *internal.RemoteEnvironment { + m, err := json.Marshal(in) + if err != nil { + panic(fmt.Sprintf("input remote environemnt marchaling failed, err: %s", err)) + } + + var out internal.RemoteEnvironment + if err := json.Unmarshal(m, &out); err != nil { + panic(fmt.Sprintf("input remote environemnt unmarchaling failed, err: %s", err)) + } + + return &out +} +func (ts *reTestSuite) AssertNotFoundError(err error) bool { + ts.t.Helper() + return assert.True(ts.t, storage.IsNotFoundError(err), "NotFound error expected") +} + +func (ts *reTestSuite) AssertChartDoesNotExist(exp *internal.RemoteEnvironment) bool { + ts.t.Helper() + _, err := ts.reStorage.Get(internal.RemoteEnvironmentName(exp.Name)) + return assert.True(ts.t, storage.IsNotFoundError(err), "NotFound error expected") +} + +func (ts *reTestSuite) AssertContainsAllFixtures(got []*internal.RemoteEnvironment) { + ts.t.Helper() + + assert.Len(ts.t, got, len(ts.fixtures)) + + for _, fix := range ts.fixtures { + assert.Contains(ts.t, got, fix) + } +} diff --git a/components/remote-environment-broker/internal/syncer/automock/extended.go b/components/remote-environment-broker/internal/syncer/automock/extended.go new file mode 100644 index 000000000000..9b1b6b9c8094 --- /dev/null +++ b/components/remote-environment-broker/internal/syncer/automock/extended.go @@ -0,0 +1,23 @@ +package automock + +import ( + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/stretchr/testify/mock" +) + +func (_m *RemoteEnvironmentCRMapper) ExpectOnToModel(dto *v1alpha1.RemoteEnvironment, dm *internal.RemoteEnvironment) *mock.Call { + return _m.On("ToModel", dto).Return(dm) +} + +func (_m *RemoteEnvironmentCRValidator) ExpectOnValidate(dto *v1alpha1.RemoteEnvironment) *mock.Call { + return _m.On("Validate", dto).Return(nil) +} + +func (_m *RemoteEnvironmentUpserter) ExpectOnUpsert(dm *internal.RemoteEnvironment) *mock.Call { + return _m.On("Upsert", dm).Return(false, nil) +} + +func (_m *SCRelistRequester) ExpectOnRequestRelist() *mock.Call { + return _m.On("RequestRelist") +} diff --git a/components/remote-environment-broker/internal/syncer/automock/remote_environment_cr_mapper.go b/components/remote-environment-broker/internal/syncer/automock/remote_environment_cr_mapper.go new file mode 100644 index 000000000000..2aa8dffc00a0 --- /dev/null +++ b/components/remote-environment-broker/internal/syncer/automock/remote_environment_cr_mapper.go @@ -0,0 +1,28 @@ +// Code generated by mockery v1.0.0 +package automock + +import internal "github.com/kyma-project/kyma/components/remote-environment-broker/internal" +import mock "github.com/stretchr/testify/mock" + +import v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + +// RemoteEnvironmentCRMapper is an autogenerated mock type for the RemoteEnvironmentCRMapper type +type RemoteEnvironmentCRMapper struct { + mock.Mock +} + +// ToModel provides a mock function with given fields: dto +func (_m *RemoteEnvironmentCRMapper) ToModel(dto *v1alpha1.RemoteEnvironment) *internal.RemoteEnvironment { + ret := _m.Called(dto) + + var r0 *internal.RemoteEnvironment + if rf, ok := ret.Get(0).(func(*v1alpha1.RemoteEnvironment) *internal.RemoteEnvironment); ok { + r0 = rf(dto) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*internal.RemoteEnvironment) + } + } + + return r0 +} diff --git a/components/remote-environment-broker/internal/syncer/automock/remote_environment_cr_validator.go b/components/remote-environment-broker/internal/syncer/automock/remote_environment_cr_validator.go new file mode 100644 index 000000000000..b5fb0f087e4f --- /dev/null +++ b/components/remote-environment-broker/internal/syncer/automock/remote_environment_cr_validator.go @@ -0,0 +1,25 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" + +import v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + +// RemoteEnvironmentCRValidator is an autogenerated mock type for the RemoteEnvironmentCRValidator type +type RemoteEnvironmentCRValidator struct { + mock.Mock +} + +// Validate provides a mock function with given fields: dto +func (_m *RemoteEnvironmentCRValidator) Validate(dto *v1alpha1.RemoteEnvironment) error { + ret := _m.Called(dto) + + var r0 error + if rf, ok := ret.Get(0).(func(*v1alpha1.RemoteEnvironment) error); ok { + r0 = rf(dto) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/components/remote-environment-broker/internal/syncer/automock/remote_environment_remover.go b/components/remote-environment-broker/internal/syncer/automock/remote_environment_remover.go new file mode 100644 index 000000000000..155a8b68c7e6 --- /dev/null +++ b/components/remote-environment-broker/internal/syncer/automock/remote_environment_remover.go @@ -0,0 +1,24 @@ +// Code generated by mockery v1.0.0 +package automock + +import internal "github.com/kyma-project/kyma/components/remote-environment-broker/internal" +import mock "github.com/stretchr/testify/mock" + +// RemoteEnvironmentRemover is an autogenerated mock type for the RemoteEnvironmentRemover type +type RemoteEnvironmentRemover struct { + mock.Mock +} + +// Remove provides a mock function with given fields: name +func (_m *RemoteEnvironmentRemover) Remove(name internal.RemoteEnvironmentName) error { + ret := _m.Called(name) + + var r0 error + if rf, ok := ret.Get(0).(func(internal.RemoteEnvironmentName) error); ok { + r0 = rf(name) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/components/remote-environment-broker/internal/syncer/automock/remote_environment_upserter.go b/components/remote-environment-broker/internal/syncer/automock/remote_environment_upserter.go new file mode 100644 index 000000000000..44fd13a302fa --- /dev/null +++ b/components/remote-environment-broker/internal/syncer/automock/remote_environment_upserter.go @@ -0,0 +1,31 @@ +// Code generated by mockery v1.0.0 +package automock + +import internal "github.com/kyma-project/kyma/components/remote-environment-broker/internal" +import mock "github.com/stretchr/testify/mock" + +// RemoteEnvironmentUpserter is an autogenerated mock type for the RemoteEnvironmentUpserter type +type RemoteEnvironmentUpserter struct { + mock.Mock +} + +// Upsert provides a mock function with given fields: re +func (_m *RemoteEnvironmentUpserter) Upsert(re *internal.RemoteEnvironment) (bool, error) { + ret := _m.Called(re) + + var r0 bool + if rf, ok := ret.Get(0).(func(*internal.RemoteEnvironment) bool); ok { + r0 = rf(re) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func(*internal.RemoteEnvironment) error); ok { + r1 = rf(re) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/remote-environment-broker/internal/syncer/automock/sc_relist_requester.go b/components/remote-environment-broker/internal/syncer/automock/sc_relist_requester.go new file mode 100644 index 000000000000..bba5b55c1f38 --- /dev/null +++ b/components/remote-environment-broker/internal/syncer/automock/sc_relist_requester.go @@ -0,0 +1,14 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" + +// SCRelistRequester is an autogenerated mock type for the SCRelistRequester type +type SCRelistRequester struct { + mock.Mock +} + +// RequestRelist provides a mock function with given fields: +func (_m *SCRelistRequester) RequestRelist() { + _m.Called() +} diff --git a/components/remote-environment-broker/internal/syncer/automock/service_catalog_syncer.go b/components/remote-environment-broker/internal/syncer/automock/service_catalog_syncer.go new file mode 100644 index 000000000000..5fc46ac7b69a --- /dev/null +++ b/components/remote-environment-broker/internal/syncer/automock/service_catalog_syncer.go @@ -0,0 +1,23 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" + +// ServiceCatalogSyncer is an autogenerated mock type for the ServiceCatalogSyncer type +type ServiceCatalogSyncer struct { + mock.Mock +} + +// Sync provides a mock function with given fields: name, retries +func (_m *ServiceCatalogSyncer) Sync(name string, retries int) error { + ret := _m.Called(name, retries) + + var r0 error + if rf, ok := ret.Get(0).(func(string, int) error); ok { + r0 = rf(name, retries) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/components/remote-environment-broker/internal/syncer/controller.go b/components/remote-environment-broker/internal/syncer/controller.go new file mode 100644 index 000000000000..a1f90de9f439 --- /dev/null +++ b/components/remote-environment-broker/internal/syncer/controller.go @@ -0,0 +1,240 @@ +package syncer + +import ( + "context" + "time" + + re_type_v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + informers "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/v1alpha1" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" +) + +const ( + // maxRemoteEnvironmentProcessRetries is the number of times a remote environment CR will be retried before it is dropped out of the queue. + // With the current rate-limiter in use (5ms*2^(maxRetries-1)) the following numbers represent the times + // a deployment is going to be requeued: + // + // 5ms, 10ms, 20ms, 40ms, 80ms + maxRemoteEnvironmentProcessRetries = 5 +) + +//go:generate mockery -name=remoteEnvironmentUpserter -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=remoteEnvironmentRemover -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=remoteEnvironmentCRValidator -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=remoteEnvironmentCRMapper -output=automock -outpkg=automock -case=underscore +//go:generate mockery -name=scRelistRequester -output=automock -outpkg=automock -case=underscore + +type ( + remoteEnvironmentUpserter interface { + Upsert(re *internal.RemoteEnvironment) (bool, error) + } + + remoteEnvironmentRemover interface { + Remove(name internal.RemoteEnvironmentName) error + } + + remoteEnvironmentCRValidator interface { + Validate(dto *re_type_v1alpha1.RemoteEnvironment) error + } + + remoteEnvironmentCRMapper interface { + ToModel(dto *re_type_v1alpha1.RemoteEnvironment) *internal.RemoteEnvironment + } + + scRelistRequester interface { + RequestRelist() + } +) + +// Controller populates local storage with all RemoteEnvironment custom resources created in k8s cluster. +type Controller struct { + log logrus.FieldLogger + queue workqueue.RateLimitingInterface + informer informers.RemoteEnvironmentInformer + reUpserter remoteEnvironmentUpserter + reRemover remoteEnvironmentRemover + reCRValidator remoteEnvironmentCRValidator + reCRMapper remoteEnvironmentCRMapper + scRelistRequester scRelistRequester +} + +// New creates new remote environment controller +func New(remoteEnvironmentInformer informers.RemoteEnvironmentInformer, reUpserter remoteEnvironmentUpserter, reRemover remoteEnvironmentRemover, scRelistRequester scRelistRequester, log logrus.FieldLogger) *Controller { + c := &Controller{ + informer: remoteEnvironmentInformer, + reUpserter: reUpserter, + reRemover: reRemover, + scRelistRequester: scRelistRequester, + log: log.WithField("service", "syncer:controller"), + + queue: workqueue.NewRateLimitingQueue(workqueue.DefaultControllerRateLimiter()), + + reCRValidator: &reCRValidator{}, + reCRMapper: &reCRMapper{}, + } + + remoteEnvironmentInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: c.addRE, + DeleteFunc: c.deleteRE, + UpdateFunc: c.updateRE, + }) + + return c +} + +func (c *Controller) addRE(obj interface{}) { + key, err := cache.MetaNamespaceKeyFunc(obj) + if err != nil { + c.log.Errorf("while handling adding event: while adding new remote environment custom resource to queue: couldn't get key: %v", err) + return + } + + c.queue.Add(key) +} + +func (c *Controller) deleteRE(obj interface{}) { + key, err := cache.DeletionHandlingMetaNamespaceKeyFunc(obj) + if err != nil { + c.log.Errorf("while handling deletion event: while adding new remote environment custom resource to queue: couldn't get key: %v", err) + return + } + + c.queue.Add(key) +} + +func (c *Controller) updateRE(old, cur interface{}) { + key, err := cache.MetaNamespaceKeyFunc(cur) + if err != nil { + c.log.Errorf("while handling update event: while adding new remote environment custom resource to queue: couldn't get key: %v", err) + return + } + + c.queue.Add(key) +} + +// Run starts the controller +func (c *Controller) Run(stopCh <-chan struct{}) { + go c.shutdownQueueOnStop(stopCh) + + c.log.Info("Starting remote environment CR sync-controller") + defer c.log.Infof("Shutting down remote environment CR sync-controller") + + if !cache.WaitForCacheSync(stopCh, c.informer.Informer().HasSynced) { + c.log.Error("Timeout occurred on waiting for caches to sync. Shutdown the controller.") + return + } + + c.log.Info("RE controller synced and ready") + + wait.Until(c.runWorker, time.Second, stopCh) +} + +func (c *Controller) shutdownQueueOnStop(stopCh <-chan struct{}) { + <-stopCh + c.queue.ShutDown() +} + +func (c *Controller) runWorker() { + for c.processNextItem() { + // continue looping + } +} + +func (c *Controller) processNextItem() bool { + key, shutdown := c.queue.Get() + if shutdown { + return false + } + + defer c.queue.Done(key) + + strKey := key.(string) + err := c.processItem(strKey) + switch { + case err == nil: + c.queue.Forget(key) + + c.scRelistRequester.RequestRelist() + c.log.Infof("Relist requested after successful processing of the %q", strKey) + + case isTemporaryError(err) && c.queue.NumRequeues(key) < maxRemoteEnvironmentProcessRetries: + c.log.Errorf("Error processing %q (will retry): %v", key, err) + c.queue.AddRateLimited(key) + + default: // err != nil and err != temporary and too many retries + c.log.Errorf("Error processing %q (giving up): %v", key, err) + c.queue.Forget(key) + } + + return true +} + +func (c *Controller) processItem(key string) error { + // TODO: In prometheus-operator they use exists to check if we should delete resources, see: + // https://github.com/coreos/prometheus-operator/blob/master/pkg/alertmanager/operator.go#L364 + // but in k8s they use Lister to check if event should be delete, see: + // https://github.com/kubernetes/kubernetes/blob/master/pkg/controller/service/service_controller.go#L725 + // We need to check the guarantees of such solutions and choose the best one. + obj, exists, err := c.informer.Informer().GetIndexer().GetByKey(key) + if err != nil { + return errors.Wrapf(err, "while getting object with key %q from store", key) + } + + if !exists { + err := c.reRemover.Remove(internal.RemoteEnvironmentName(key)) + if err != nil { + return errors.Wrapf(err, "while removing remote environment with name %q from storage", key) + } + c.log.Infof("Remote Environment %q was removed from storage", key) + return nil + } + + reObj, ok := obj.(*re_type_v1alpha1.RemoteEnvironment) + if !ok { + return errors.New("cannot cast received object to v1alpha1.RemoteEnvironment type") + } + + if err := c.reCRValidator.Validate(reObj); err != nil { + return errors.Wrapf(err, "while validating remote environment %q", key) + } + + dm := c.reCRMapper.ToModel(reObj) + replaced, err := c.reUpserter.Upsert(dm) + if err != nil { + return errors.Wrapf(err, "while upserting remote environment with name %q into storage", key) + } + + c.log.Infof("Remote Environment %q was added into storage (replaced: %v)", key, replaced) + return nil +} + +func (c *Controller) closeChanOnCtxCancellation(ctx context.Context, ch chan<- struct{}) { + for { + select { + case <-ctx.Done(): + close(ch) + return + } + } +} + +// isTemporaryError returns true if error implements following interface: +// type temporary interface { +// Temporary() bool +// } +// +// and Temporary() method return true. Otherwise false will be returned. +func isTemporaryError(err error) bool { + type temporary interface { + Temporary() bool + } + + te, ok := err.(temporary) + return ok && te.Temporary() +} diff --git a/components/remote-environment-broker/internal/syncer/controller_export_test.go b/components/remote-environment-broker/internal/syncer/controller_export_test.go new file mode 100644 index 000000000000..287ac828d116 --- /dev/null +++ b/components/remote-environment-broker/internal/syncer/controller_export_test.go @@ -0,0 +1,11 @@ +package syncer + +func (c *Controller) WithCRValidator(validator remoteEnvironmentCRValidator) *Controller { + c.reCRValidator = validator + return c +} + +func (c *Controller) WithCRMapper(mapper remoteEnvironmentCRMapper) *Controller { + c.reCRMapper = mapper + return c +} diff --git a/components/remote-environment-broker/internal/syncer/controller_test.go b/components/remote-environment-broker/internal/syncer/controller_test.go new file mode 100644 index 000000000000..5cab3212969d --- /dev/null +++ b/components/remote-environment-broker/internal/syncer/controller_test.go @@ -0,0 +1,101 @@ +package syncer_test + +import ( + "context" + "io/ioutil" + "sync" + "testing" + "time" + + "github.com/ghodss/yaml" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/syncer" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/syncer/automock" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/fake" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/informers/externalversions" + "github.com/kyma-project/kyma/components/remote-environment-broker/platform/logger/spy" + "github.com/stretchr/testify/mock" +) + +func TestControllerRunSuccess(t *testing.T) { + // given + reCR := mustLoadCRFix("testdata/re-CR-valid.input.yaml") + reDM := internal.RemoteEnvironment{ + Name: "mapped", + } + + client := fake.NewSimpleClientset(&reCR) + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + serviceCatalogSharedInformers := informerFactory.Remoteenvironment().V1alpha1() + reInformer := serviceCatalogSharedInformers.RemoteEnvironments() + + expectations := &sync.WaitGroup{} + expectations.Add(4) + fulfillExpectation := func(mock.Arguments) { + expectations.Done() + } + + validatorMock := &automock.RemoteEnvironmentCRValidator{} + defer validatorMock.AssertExpectations(t) + validatorMock.ExpectOnValidate(&reCR).Run(fulfillExpectation).Once() + + mapperMock := &automock.RemoteEnvironmentCRMapper{} + defer mapperMock.AssertExpectations(t) + mapperMock.ExpectOnToModel(&reCR, &reDM).Run(fulfillExpectation).Once() + + upserterMock := &automock.RemoteEnvironmentUpserter{} + defer upserterMock.AssertExpectations(t) + upserterMock.ExpectOnUpsert(&reDM).Run(fulfillExpectation).Once() + + relistRequesterMock := &automock.SCRelistRequester{} + defer relistRequesterMock.AssertExpectations(t) + relistRequesterMock.ExpectOnRequestRelist().Run(fulfillExpectation).Once() + + syncJob := syncer.New(reInformer, upserterMock, nil, relistRequesterMock, spy.NewLogDummy()). + WithCRValidator(validatorMock). + WithCRMapper(mapperMock) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + stopCh := make(chan struct{}) + defer close(stopCh) + informerFactory.Start(stopCh) + + // when + go syncJob.Run(ctx.Done()) + + // then + awaitForSyncGroupAtMost(t, expectations, 2*time.Second) +} + +func awaitForSyncGroupAtMost(t *testing.T, wg *sync.WaitGroup, timeout time.Duration) { + c := make(chan struct{}) + go func() { + defer close(c) + wg.Wait() + }() + + select { + case <-c: + case <-time.After(timeout): + t.Fatalf("timeout occurred when waiting for sync group") + } +} + +func mustLoadCRFix(path string) v1alpha1.RemoteEnvironment { + in, err := ioutil.ReadFile(path) + if err != nil { + panic(err) + } + + var remoteEnvironment v1alpha1.RemoteEnvironment + err = yaml.Unmarshal(in, &remoteEnvironment) + if err != nil { + panic(err) + } + + return remoteEnvironment +} diff --git a/components/remote-environment-broker/internal/syncer/mapper.go b/components/remote-environment-broker/internal/syncer/mapper.go new file mode 100644 index 000000000000..98c8915a56b9 --- /dev/null +++ b/components/remote-environment-broker/internal/syncer/mapper.go @@ -0,0 +1,76 @@ +package syncer + +import ( + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" +) + +type reCRMapper struct{} + +const ( + api = "API" + events = "Events" +) + +// ToModel produces RemoteEnvironment domain model from RemoteEnvironment custom resource +func (re *reCRMapper) ToModel(dto *v1alpha1.RemoteEnvironment) *internal.RemoteEnvironment { + var reServices []internal.Service + + for _, svc := range dto.Spec.Services { + dmSvc := internal.Service{ + ID: internal.RemoteServiceID(svc.ID), + DisplayName: svc.DisplayName, + LongDescription: svc.LongDescription, + ProviderDisplayName: svc.ProviderDisplayName, + Tags: svc.Tags, + APIEntry: re.extractAPIEntryAsModel(svc.Entries), + EventProvider: re.extractEventEntryAsModel(svc.Entries), + } + + reServices = append(reServices, dmSvc) + } + + dm := &internal.RemoteEnvironment{ + Name: internal.RemoteEnvironmentName(dto.Name), + Description: dto.Spec.Description, + Source: internal.Source{ + Type: dto.Spec.Source.Type, + Namespace: dto.Spec.Source.Namespace, + Environment: dto.Spec.Source.Environment, + }, + Services: reServices, + AccessLabel: dto.Spec.AccessLabel, + } + + return dm +} + +func (*reCRMapper) extractAPIEntryAsModel(entries []v1alpha1.Entry) *internal.APIEntry { + for _, entry := range entries { + switch entry.Type { + case api: + // TODO(entry-simplification): this is an accepted simplification until + // explicit support of many APIEntry and EventEntry. + // For now we are know that only one entry of type API is allowed, + // so we are returning immediately + return &internal.APIEntry{ + Entry: internal.Entry{Type: entry.Type}, + AccessLabel: entry.AccessLabel, + GatewayURL: entry.GatewayUrl, + } + + } + } + return nil +} +func (*reCRMapper) extractEventEntryAsModel(entries []v1alpha1.Entry) bool { + for _, entry := range entries { + switch entry.Type { + case events: + // TODO(entry-simplification): this is an accepted simplification until + // explicit support of many APIEntry and EventEntry. + return true + } + } + return false +} diff --git a/components/remote-environment-broker/internal/syncer/mapper_test.go b/components/remote-environment-broker/internal/syncer/mapper_test.go new file mode 100644 index 000000000000..092a24a9a4fd --- /dev/null +++ b/components/remote-environment-broker/internal/syncer/mapper_test.go @@ -0,0 +1,154 @@ +package syncer + +import ( + "testing" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestReCRMapperToModel(t *testing.T) { + // given + fix := v1alpha1.RemoteEnvironment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "re", + }, + Spec: v1alpha1.RemoteEnvironmentSpec{ + Source: v1alpha1.Source{ + Environment: "production", + Type: "commerce", + Namespace: "com.hakuna.matata", + }, + Description: "EC description", + Services: []v1alpha1.Service{ + { + ID: "123", + DisplayName: "name", + Tags: []string{"tag1", "tag2"}, + LongDescription: "desc", + ProviderDisplayName: "name", + Entries: []v1alpha1.Entry{ + { + Type: "API", + GatewayUrl: "url", + AccessLabel: "label", + }, + { + Type: "Events", + }, + }, + }, + }, + }, + } + + mapper := &reCRMapper{} + + // when + dm := mapper.ToModel(&fix) + + // then + assert.Equal(t, dm.Description, fix.Spec.Description) + assert.Equal(t, dm.Name, internal.RemoteEnvironmentName(fix.Name)) + + assert.Equal(t, dm.Source.Type, fix.Spec.Source.Type) + assert.Equal(t, dm.Source.Environment, fix.Spec.Source.Environment) + assert.Equal(t, dm.Source.Namespace, fix.Spec.Source.Namespace) + + require.Len(t, dm.Services, 1) + assert.Equal(t, string(dm.Services[0].ID), fix.Spec.Services[0].ID) + assert.Equal(t, dm.Services[0].Tags, fix.Spec.Services[0].Tags) + assert.Equal(t, dm.Services[0].DisplayName, fix.Spec.Services[0].DisplayName) + assert.Equal(t, dm.Services[0].LongDescription, fix.Spec.Services[0].LongDescription) + assert.Equal(t, dm.Services[0].ProviderDisplayName, fix.Spec.Services[0].ProviderDisplayName) + + assert.Equal(t, dm.Services[0].APIEntry.Type, fix.Spec.Services[0].Entries[0].Type) + assert.Equal(t, dm.Services[0].APIEntry.AccessLabel, fix.Spec.Services[0].Entries[0].AccessLabel) + assert.Equal(t, dm.Services[0].APIEntry.GatewayURL, fix.Spec.Services[0].Entries[0].GatewayUrl) +} + +func TestEventProviderTrue(t *testing.T) { + fixEventRE := fixEventsBasedRE() + fixAPIEventsRE := fixAPIAndEventsRE() + + mapper := &reCRMapper{} + + // when + dmEvent := mapper.ToModel(fixEventRE) + dmAPIEvent := mapper.ToModel(fixAPIEventsRE) + + // then + assert.Equal(t, true, dmEvent.Services[0].EventProvider) + assert.Equal(t, true, dmAPIEvent.Services[0].EventProvider) +} + +func TestEventProviderFalse(t *testing.T) { + mapper := &reCRMapper{} + + // when + dmAPI := mapper.ToModel(fixAPIBasedRE()) + + // then + assert.Equal(t, false, dmAPI.Services[0].EventProvider) +} + +func fixEventsBasedRE() *v1alpha1.RemoteEnvironment { + return &v1alpha1.RemoteEnvironment{ + Spec: v1alpha1.RemoteEnvironmentSpec{ + Services: []v1alpha1.Service{ + { + ID: "123", + Entries: []v1alpha1.Entry{ + { + Type: "Events", + }, + }, + }, + }, + }, + } +} + +func fixAPIBasedRE() *v1alpha1.RemoteEnvironment { + return &v1alpha1.RemoteEnvironment{ + Spec: v1alpha1.RemoteEnvironmentSpec{ + Services: []v1alpha1.Service{ + { + ID: "123", + Entries: []v1alpha1.Entry{ + { + Type: "API", + GatewayUrl: "url", + AccessLabel: "label", + }, + }, + }, + }, + }, + } +} + +func fixAPIAndEventsRE() *v1alpha1.RemoteEnvironment { + return &v1alpha1.RemoteEnvironment{ + Spec: v1alpha1.RemoteEnvironmentSpec{ + Services: []v1alpha1.Service{ + { + ID: "123", + Entries: []v1alpha1.Entry{ + { + Type: "API", + GatewayUrl: "url", + AccessLabel: "label", + }, + { + Type: "Events", + }, + }, + }, + }, + }, + } +} diff --git a/components/remote-environment-broker/internal/syncer/sc_relist_requester.go b/components/remote-environment-broker/internal/syncer/sc_relist_requester.go new file mode 100644 index 000000000000..2de064cc6055 --- /dev/null +++ b/components/remote-environment-broker/internal/syncer/sc_relist_requester.go @@ -0,0 +1,85 @@ +package syncer + +import ( + "time" + + "github.com/sirupsen/logrus" +) + +const maxSyncRetries = 5 + +//go:generate mockery -name=serviceCatalogSyncer -output=automock -outpkg=automock -case=underscore + +type serviceCatalogSyncer interface { + Sync(name string, retries int) error +} + +// RelistRequester informs the Service Catalog that given Service Broker should be relisted. +// Due to performance reason, many relist requests which happen during the period defined in `reListDurationWindow` +// result in a single Service Catalog relist trigger. +type RelistRequester struct { + serviceCatalogSyncer serviceCatalogSyncer + brokerName string + reListDurationWindow time.Duration + log logrus.FieldLogger + + timeAfterProvider TimeAfterProvider + + relistRequested chan struct{} +} + +// NewRelistRequester returns new instance of RelistRequester +func NewRelistRequester(serviceCatalogSyncer serviceCatalogSyncer, brokerName string, reListDuration time.Duration, log logrus.FieldLogger) *RelistRequester { + return &RelistRequester{ + serviceCatalogSyncer: serviceCatalogSyncer, + brokerName: brokerName, + reListDurationWindow: reListDuration, + log: log.WithField("service", "syncer:sc-relist-requester"), + + relistRequested: make(chan struct{}, 1), + } +} + +// RequestRelist informs the Service Catalog that Broker should be relisted. +func (r *RelistRequester) RequestRelist() { + select { + case r.relistRequested <- struct{}{}: + default: // relist already requested, drop next request + } +} + +// Run runs worker which executing relist operation +func (r *RelistRequester) Run(stopCh <-chan struct{}) { + r.log.Infof("Starting Broker relist worker with relist duration window %v", r.reListDurationWindow) + + for { + select { + case <-r.timeAfterProvider.After(r.reListDurationWindow): + case <-stopCh: + r.log.Infof("Shutting down Broker relist worker") + return + } + + select { + case <-r.relistRequested: + if err := r.serviceCatalogSyncer.Sync(r.brokerName, maxSyncRetries); err != nil { + r.log.Errorf("Error occurred when synchronizing broker %q [maxRetires=%d]: %v", r.brokerName, maxSyncRetries, err) + } + r.log.Infof("Relist request for Broker %q fulfilled", r.brokerName) + default: + } + } +} + +// TimeAfterProvider is a provider for time.After. +// If not initialised defaults to time.After implementation for stdlib. +// It's intended to facilitate testing without time dependency. +type TimeAfterProvider func(d time.Duration) <-chan time.Time + +// After calls attached After implementation. +func (p TimeAfterProvider) After(d time.Duration) <-chan time.Time { + if p == nil { + return time.After(d) + } + return p(d) +} diff --git a/components/remote-environment-broker/internal/syncer/sc_relist_requester_export_test.go b/components/remote-environment-broker/internal/syncer/sc_relist_requester_export_test.go new file mode 100644 index 000000000000..75a2a49806d7 --- /dev/null +++ b/components/remote-environment-broker/internal/syncer/sc_relist_requester_export_test.go @@ -0,0 +1,6 @@ +package syncer + +func (r *RelistRequester) WithTimeAfter(fn TimeAfterProvider) *RelistRequester { + r.timeAfterProvider = fn + return r +} diff --git a/components/remote-environment-broker/internal/syncer/sc_relist_requester_test.go b/components/remote-environment-broker/internal/syncer/sc_relist_requester_test.go new file mode 100644 index 000000000000..e78be2eb0695 --- /dev/null +++ b/components/remote-environment-broker/internal/syncer/sc_relist_requester_test.go @@ -0,0 +1,104 @@ +package syncer_test + +import ( + "context" + "testing" + "time" + + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/syncer" + "github.com/kyma-project/kyma/components/remote-environment-broker/internal/syncer/automock" + "github.com/kyma-project/kyma/components/remote-environment-broker/platform/logger/spy" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestRelistRequesterRequestRelistSuccessSingleTrigger(t *testing.T) { + // given + fixBrokerName := "fix-broker-name" + fixRelistDuration := time.Microsecond + + logSink := newLogSinkForErrors() + + syncCalled := make(chan struct{}) + fulfillExpectation := func(mock.Arguments) { + close(syncCalled) + } + + scSyncerMock := &automock.ServiceCatalogSyncer{} + defer scSyncerMock.AssertExpectations(t) + scSyncerMock.On("Sync", fixBrokerName, mock.AnythingOfType("int")). + Run(fulfillExpectation). + Return(nil). + Once() + + relister := syncer.NewRelistRequester(scSyncerMock, fixBrokerName, fixRelistDuration, logSink.Logger) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + go relister.Run(ctx.Done()) + + // when + relister.RequestRelist() + + // then + awaitForChanAtMost(t, syncCalled, time.Second) + assert.Empty(t, logSink.DumpAll()) +} + +func TestRelistRequesterRequestRelistSuccessMultipleTrigger(t *testing.T) { + // given + fixBrokerName := "fix-broker-name" + fixRelistDuration := time.Second + + syncCalled := make(chan struct{}) + fulfillExpectation := func(mock.Arguments) { + close(syncCalled) + } + + scSyncerMock := &automock.ServiceCatalogSyncer{} + defer scSyncerMock.AssertExpectations(t) + scSyncerMock.On("Sync", fixBrokerName, mock.AnythingOfType("int")). + Run(fulfillExpectation). + Return(nil) + + afterChan := make(chan time.Time, 1) + afterTimeMock := func(d time.Duration) <-chan time.Time { + return afterChan + } + + relister := syncer.NewRelistRequester(scSyncerMock, fixBrokerName, fixRelistDuration, spy.NewLogDummy()). + WithTimeAfter(afterTimeMock) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + go relister.Run(ctx.Done()) + + // when + for i := 0; i < 10; i++ { // trigger request multiple times, no one should be blocking + relister.RequestRelist() + } + + afterChan <- time.Now() // simulate that time has expired + + // then + awaitForChanAtMost(t, syncCalled, time.Second) + scSyncerMock.AssertNumberOfCalls(t, "Sync", 1) + assert.Empty(t, afterChan, "timeAfter was not called when it should be") +} + +func newLogSinkForErrors() *spy.LogSink { + logSink := spy.NewLogSink() + logSink.Logger.Logger.Level = logrus.ErrorLevel + return logSink +} + +func awaitForChanAtMost(t *testing.T, ch <-chan struct{}, timeout time.Duration) { + select { + case <-ch: + case <-time.After(timeout): + t.Fatalf("timeout occurred when waiting for channel") + } +} diff --git a/components/remote-environment-broker/internal/syncer/testdata/re-CR-valid.input.yaml b/components/remote-environment-broker/internal/syncer/testdata/re-CR-valid.input.yaml new file mode 100644 index 000000000000..4587dae7b770 --- /dev/null +++ b/components/remote-environment-broker/internal/syncer/testdata/re-CR-valid.input.yaml @@ -0,0 +1,37 @@ +apiVersion: remoteenvironment.kyma.cx/v1alpha1 +kind: RemoteEnvironment +metadata: + name: ec-prod +spec: + source: + environment: "production" + type: "commerce" + namespace: "com.hakuna.matata" + + description: "EC description" + + services: + - id: "ac031e8c-9aa4-4cb7-8999-0d358726ffaa" + + displayName: "Promotions" + longDescription: "Promotions APIs" + providerDisplayName: "HakunaMatata" + + tags: + - occ + - promotions + + entries: + - type: API + gatewayUrl: "http://promotions-gateway.production.svc.cluster.local/" + accessLabel: "access-label-1" + - type: Events + + - id: "48ab05bf-9aa4-4cb7-8999-0d3587265ac3" + displayName: "Orders" + longDescription: "Orders API" + providerDisplayName: "HakunaMatata" + entries: + - type: API + gatewayUrl: "http://orders-gateway.production.svc.cluster.local/" + accessLabel: "access-label-2" diff --git a/components/remote-environment-broker/internal/syncer/validator.go b/components/remote-environment-broker/internal/syncer/validator.go new file mode 100644 index 000000000000..9aff081fb86a --- /dev/null +++ b/components/remote-environment-broker/internal/syncer/validator.go @@ -0,0 +1,60 @@ +package syncer + +import ( + "errors" + "fmt" + "strings" + + re_type_v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" +) + +type reCRValidator struct{} + +const ( + apiEntryType = "API" + eventEntryType = "Event" +) + +// Validate validates RemoteEnvironment custom resource. +func (*reCRValidator) Validate(dto *re_type_v1alpha1.RemoteEnvironment) error { + messages := []string{} + + for _, svc := range dto.Spec.Services { + if len(svc.Entries) == 0 { + messages = append(messages, fmt.Sprintf("Service with id %q is invalid. Entries list cannot be empty", svc.ID)) + continue + } + + APIEntryCnt := 0 + EventEntryCnt := 0 + for _, entry := range svc.Entries { + switch entry.Type { + case apiEntryType: + APIEntryCnt++ + + if entry.GatewayUrl == "" { + messages = append(messages, "GatewayUrl field is required for API type") + } + if entry.AccessLabel == "" { + messages = append(messages, "AccessLabel field is required for API type") + } + case eventEntryType: + EventEntryCnt++ + } + } + + if APIEntryCnt > 1 { + messages = append(messages, fmt.Sprintf("Service with id %q is invalid. Only one element with type API is allowed but found %d", svc.ID, APIEntryCnt)) + } + + if EventEntryCnt > 1 { + messages = append(messages, fmt.Sprintf("Service with id %q is invalid. Only one element with type Event is allowed but found %d", svc.ID, EventEntryCnt)) + } + } + + if len(messages) > 0 { + return errors.New(strings.Join(messages, ", ")) + } + + return nil +} diff --git a/components/remote-environment-broker/internal/syncer/validator_test.go b/components/remote-environment-broker/internal/syncer/validator_test.go new file mode 100644 index 000000000000..6695eeb46820 --- /dev/null +++ b/components/remote-environment-broker/internal/syncer/validator_test.go @@ -0,0 +1,118 @@ +package syncer + +import ( + "io/ioutil" + "testing" + + "github.com/ghodss/yaml" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/stretchr/testify/assert" +) + +func TestReCRValidatorValidateSuccess(t *testing.T) { + // given + remoteEnvironment := mustLoadCRFix("testdata/re-CR-valid.input.yaml") + validator := &reCRValidator{} + + // when + err := validator.Validate(&remoteEnvironment) + + // then + assert.NoError(t, err) +} + +func TestReCRValidatorValidateFailure(t *testing.T) { + tests := map[string]struct { + fixModifier func(*v1alpha1.RemoteEnvironment) + expErrMsg []string + }{ + "empty entries list": { + fixModifier: func(re *v1alpha1.RemoteEnvironment) { + re.Spec.Services[0].Entries = []v1alpha1.Entry{} + }, + expErrMsg: []string{"Service with id \"ac031e8c-9aa4-4cb7-8999-0d358726ffaa\" is invalid. Entries list cannot be empty"}, + }, + "missing GatewayUrl field": { + fixModifier: func(re *v1alpha1.RemoteEnvironment) { + for i := range re.Spec.Services[0].Entries { + re.Spec.Services[0].Entries[i].GatewayUrl = "" + } + }, + expErrMsg: []string{"GatewayUrl field is required for API type"}, + }, + "missing AccessLabel field": { + fixModifier: func(re *v1alpha1.RemoteEnvironment) { + for i := range re.Spec.Services[0].Entries { + re.Spec.Services[0].Entries[i].AccessLabel = "" + } + }, + expErrMsg: []string{"AccessLabel field is required for API type"}, + }, + "multiple API entries in one service": { + fixModifier: func(re *v1alpha1.RemoteEnvironment) { + re.Spec.Services[0].Entries = []v1alpha1.Entry{ + {Type: "API", GatewayUrl: "test.svc.1", AccessLabel: "access.1"}, + {Type: "API", GatewayUrl: "test.svc.2", AccessLabel: "access.2"}, + } + }, + expErrMsg: []string{"Service with id \"ac031e8c-9aa4-4cb7-8999-0d358726ffaa\" is invalid. Only one element with type API is allowed but found 2"}, + }, + "multiple Event entries in one service": { + fixModifier: func(re *v1alpha1.RemoteEnvironment) { + re.Spec.Services[0].Entries = []v1alpha1.Entry{ + {Type: "Event"}, + {Type: "Event"}, + } + }, + expErrMsg: []string{"Service with id \"ac031e8c-9aa4-4cb7-8999-0d358726ffaa\" is invalid. Only one element with type Event is allowed but found 2"}, + }, + "multiple validation errors": { + fixModifier: func(re *v1alpha1.RemoteEnvironment) { + for i := range re.Spec.Services[0].Entries { + re.Spec.Services[0].Entries[i].AccessLabel = "" + } + for i := range re.Spec.Services[0].Entries { + re.Spec.Services[0].Entries[i].GatewayUrl = "" + } + }, + expErrMsg: []string{"GatewayUrl field is required for API type", "AccessLabel field is required for API type"}, + }, + } + for tn, tc := range tests { + t.Run(tn, func(t *testing.T) { + // given + validator := &reCRValidator{} + fixCR := mustModifyValidCR(tc.fixModifier) + + // when + err := validator.Validate(fixCR) + + // then + for _, msg := range tc.expErrMsg { + assert.Contains(t, err.Error(), msg) + } + }) + } +} + +func mustModifyValidCR(modifer func(re *v1alpha1.RemoteEnvironment)) *v1alpha1.RemoteEnvironment { + fix := mustLoadCRFix("testdata/re-CR-valid.input.yaml") + modifer(&fix) + + return &fix +} + +func mustLoadCRFix(path string) v1alpha1.RemoteEnvironment { + in, err := ioutil.ReadFile(path) + if err != nil { + panic(err) + } + + var remoteEnvironment v1alpha1.RemoteEnvironment + err = yaml.Unmarshal(in, &remoteEnvironment) + if err != nil { + panic(err) + } + + return remoteEnvironment +} diff --git a/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1/doc.go b/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1/doc.go new file mode 100644 index 000000000000..c053ae7d204f --- /dev/null +++ b/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1/doc.go @@ -0,0 +1,4 @@ +// +k8s:deepcopy-gen=package,register + +// +groupName=remoteenvironment.kyma.cx +package v1alpha1 diff --git a/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1/register.go b/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1/register.go new file mode 100644 index 000000000000..f62fbd5f1327 --- /dev/null +++ b/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1/register.go @@ -0,0 +1,43 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// SchemeGroupVersion is group version used to register these objects +var SchemeGroupVersion = schema.GroupVersion{Group: "remoteenvironment.kyma.cx", Version: "v1alpha1"} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} + +var ( + // localSchemeBuilder and AddToScheme will stay in k8s.io/kubernetes. + SchemeBuilder runtime.SchemeBuilder + localSchemeBuilder = &SchemeBuilder + AddToScheme = localSchemeBuilder.AddToScheme +) + +func init() { + // We only register manually written functions here. The registration of the + // generated functions takes place in the generated files. The separation + // makes the code compile even when the generated files are missing. + localSchemeBuilder.Register(addKnownTypes) +} + +// Adds the list of known types to api.Scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &RemoteEnvironment{}, + &RemoteEnvironmentList{}, + &EnvironmentMapping{}, + &EnvironmentMappingList{}, + &EventActivation{}, + &EventActivationList{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1/types.go b/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1/types.go new file mode 100644 index 000000000000..7133664c76a7 --- /dev/null +++ b/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1/types.go @@ -0,0 +1,169 @@ +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +genclient +// +genclient:noStatus +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +type EnvironmentMapping struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` +} + +func (rem *EnvironmentMapping) GetObjectKind() schema.ObjectKind { + return &EnvironmentMapping{} +} + +// +genclient +// +genclient:nonNamespaced +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +type RemoteEnvironment struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` + Spec RemoteEnvironmentSpec `json:"spec"` + Status REStatus `json:"status,omitempty"` +} + +type REStatus struct { + Conditions []ReCondition `json:"conditions"` +} + +type ReCondition struct { + // Type of the condition, currently ('Ready'). + Type ReConditionType `json:"type"` + + // Status of the condition, one of ('True', 'False', 'Unknown'). + Status ConditionStatus `json:"status"` + + // LastTransitionTime is the timestamp corresponding to the last status + // change of this condition. + LastTransitionTime metav1.Time `json:"lastTransitionTime"` + + // Reason is a brief machine readable explanation for the condition's last + // transition. + Reason string `json:"reason"` + + // Message is a human readable description of the details of the last + // transition, complementing reason. + Message string `json:"message"` +} + +// ReConditionType represents an Issuer condition value. +type ReConditionType string + +const ( + // IssuerConditionReady represents the fact that a given Issuer condition + // is in ready state. + Stage1Done ReConditionType = "Stage_1" + Stage2Done ReConditionType = "Stage_2" + Stage3Done ReConditionType = "Stage_3" +) + +// ConditionStatus represents a condition's status. +type ConditionStatus string + +// These are valid condition statuses. "ConditionTrue" means a resource is in +// the condition; "ConditionFalse" means a resource is not in the condition; +// "ConditionUnknown" means kubernetes can't decide if a resource is in the +// condition or not. In the future, we could add other intermediate +// conditions, e.g. ConditionDegraded. +const ( + // ConditionTrue represents the fact that a given condition is true + ConditionTrue ConditionStatus = "True" + + // ConditionFalse represents the fact that a given condition is false + ConditionFalse ConditionStatus = "False" + + // ConditionUnknown represents the fact that a given condition is unknown + ConditionUnknown ConditionStatus = "Unknown" +) + +func (pw *RemoteEnvironment) GetObjectKind() schema.ObjectKind { + return &RemoteEnvironment{} +} + +// RemoteEnvironmentSpec defines spec section of the RemoteEnvironment custom resource +type RemoteEnvironmentSpec struct { + Description string `json:"description"` + Source Source `json:"source"` + Services []Service `json:"services"` + // AccessLabel is not required, 'omitempty' is needed because of regexp validation + AccessLabel string `json:"accessLabel,omitempty"` +} + +// Source defines attributes, which identifies remote environments. +type Source struct { + Environment string `json:"environment"` + Type string `json:"type"` + Namespace string `json:"namespace"` +} + +// Entry defines, what is enabled by activating the service. +type Entry struct { + Type string `json:"type"` + GatewayUrl string `json:"gatewayUrl"` + // AccessLabel is not required for Events, 'omitempty' is needed because of regexp validation + AccessLabel string `json:"accessLabel,omitempty"` + TargetUrl string `json:"targetUrl"` + OauthUrl string `json:"oauthUrl"` + CredentialsSecretName string `json:"credentialsSecretName"` +} + +// Service represents part of the remote environment, which is mapped 1 to 1 to service class in the service-catalog +type Service struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + LongDescription string `json:"longDescription"` + ProviderDisplayName string `json:"providerDisplayName"` + Tags []string `json:"tags,omitempty"` + Entries []Entry `json:"entries"` +} + +// +genclient +// +genclient:noStatus +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +type EventActivation struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` + + Spec EventActivationSpec `json:"spec"` +} + +type EventActivationSpec struct { + DisplayName string `json:"displayName"` + Source Source `json:"source"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +type RemoteEnvironmentList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + Items []RemoteEnvironment `json:"items"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +type EnvironmentMappingList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + Items []EnvironmentMapping `json:"items"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +type EventActivationList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + Items []EventActivation `json:"items"` +} diff --git a/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1/zz_generated.deepcopy.go b/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 000000000000..03c97c46ddda --- /dev/null +++ b/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,328 @@ +// +build !ignore_autogenerated + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + 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 *Entry) DeepCopyInto(out *Entry) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Entry. +func (in *Entry) DeepCopy() *Entry { + if in == nil { + return nil + } + out := new(Entry) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EnvironmentMapping) DeepCopyInto(out *EnvironmentMapping) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EnvironmentMapping. +func (in *EnvironmentMapping) DeepCopy() *EnvironmentMapping { + if in == nil { + return nil + } + out := new(EnvironmentMapping) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EnvironmentMapping) 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 *EnvironmentMappingList) DeepCopyInto(out *EnvironmentMappingList) { + *out = *in + out.TypeMeta = in.TypeMeta + out.ListMeta = in.ListMeta + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]EnvironmentMapping, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EnvironmentMappingList. +func (in *EnvironmentMappingList) DeepCopy() *EnvironmentMappingList { + if in == nil { + return nil + } + out := new(EnvironmentMappingList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EnvironmentMappingList) 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 *EventActivation) DeepCopyInto(out *EventActivation) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventActivation. +func (in *EventActivation) DeepCopy() *EventActivation { + if in == nil { + return nil + } + out := new(EventActivation) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EventActivation) 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 *EventActivationList) DeepCopyInto(out *EventActivationList) { + *out = *in + out.TypeMeta = in.TypeMeta + out.ListMeta = in.ListMeta + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]EventActivation, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventActivationList. +func (in *EventActivationList) DeepCopy() *EventActivationList { + if in == nil { + return nil + } + out := new(EventActivationList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EventActivationList) 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 *EventActivationSpec) DeepCopyInto(out *EventActivationSpec) { + *out = *in + out.Source = in.Source + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventActivationSpec. +func (in *EventActivationSpec) DeepCopy() *EventActivationSpec { + if in == nil { + return nil + } + out := new(EventActivationSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *REStatus) DeepCopyInto(out *REStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]ReCondition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new REStatus. +func (in *REStatus) DeepCopy() *REStatus { + if in == nil { + return nil + } + out := new(REStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ReCondition) DeepCopyInto(out *ReCondition) { + *out = *in + in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReCondition. +func (in *ReCondition) DeepCopy() *ReCondition { + if in == nil { + return nil + } + out := new(ReCondition) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RemoteEnvironment) DeepCopyInto(out *RemoteEnvironment) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RemoteEnvironment. +func (in *RemoteEnvironment) DeepCopy() *RemoteEnvironment { + if in == nil { + return nil + } + out := new(RemoteEnvironment) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RemoteEnvironment) 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 *RemoteEnvironmentList) DeepCopyInto(out *RemoteEnvironmentList) { + *out = *in + out.TypeMeta = in.TypeMeta + out.ListMeta = in.ListMeta + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]RemoteEnvironment, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RemoteEnvironmentList. +func (in *RemoteEnvironmentList) DeepCopy() *RemoteEnvironmentList { + if in == nil { + return nil + } + out := new(RemoteEnvironmentList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RemoteEnvironmentList) 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 *RemoteEnvironmentSpec) DeepCopyInto(out *RemoteEnvironmentSpec) { + *out = *in + out.Source = in.Source + if in.Services != nil { + in, out := &in.Services, &out.Services + *out = make([]Service, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RemoteEnvironmentSpec. +func (in *RemoteEnvironmentSpec) DeepCopy() *RemoteEnvironmentSpec { + if in == nil { + return nil + } + out := new(RemoteEnvironmentSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Service) DeepCopyInto(out *Service) { + *out = *in + if in.Tags != nil { + in, out := &in.Tags, &out.Tags + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Entries != nil { + in, out := &in.Entries, &out.Entries + *out = make([]Entry, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Service. +func (in *Service) DeepCopy() *Service { + if in == nil { + return nil + } + out := new(Service) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Source) DeepCopyInto(out *Source) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Source. +func (in *Source) DeepCopy() *Source { + if in == nil { + return nil + } + out := new(Source) + in.DeepCopyInto(out) + return out +} diff --git a/components/remote-environment-broker/pkg/client/clientset/versioned/clientset.go b/components/remote-environment-broker/pkg/client/clientset/versioned/clientset.go new file mode 100644 index 000000000000..9bd93c81de5c --- /dev/null +++ b/components/remote-environment-broker/pkg/client/clientset/versioned/clientset.go @@ -0,0 +1,84 @@ +// Code generated by client-gen. DO NOT EDIT. + +package versioned + +import ( + glog "github.com/golang/glog" + remoteenvironmentv1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1" + discovery "k8s.io/client-go/discovery" + rest "k8s.io/client-go/rest" + flowcontrol "k8s.io/client-go/util/flowcontrol" +) + +type Interface interface { + Discovery() discovery.DiscoveryInterface + RemoteenvironmentV1alpha1() remoteenvironmentv1alpha1.RemoteenvironmentV1alpha1Interface + // Deprecated: please explicitly pick a version if possible. + Remoteenvironment() remoteenvironmentv1alpha1.RemoteenvironmentV1alpha1Interface +} + +// Clientset contains the clients for groups. Each group has exactly one +// version included in a Clientset. +type Clientset struct { + *discovery.DiscoveryClient + remoteenvironmentV1alpha1 *remoteenvironmentv1alpha1.RemoteenvironmentV1alpha1Client +} + +// RemoteenvironmentV1alpha1 retrieves the RemoteenvironmentV1alpha1Client +func (c *Clientset) RemoteenvironmentV1alpha1() remoteenvironmentv1alpha1.RemoteenvironmentV1alpha1Interface { + return c.remoteenvironmentV1alpha1 +} + +// Deprecated: Remoteenvironment retrieves the default version of RemoteenvironmentClient. +// Please explicitly pick a version. +func (c *Clientset) Remoteenvironment() remoteenvironmentv1alpha1.RemoteenvironmentV1alpha1Interface { + return c.remoteenvironmentV1alpha1 +} + +// Discovery retrieves the DiscoveryClient +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + if c == nil { + return nil + } + return c.DiscoveryClient +} + +// NewForConfig creates a new Clientset for the given config. +func NewForConfig(c *rest.Config) (*Clientset, error) { + configShallowCopy := *c + if configShallowCopy.RateLimiter == nil && configShallowCopy.QPS > 0 { + configShallowCopy.RateLimiter = flowcontrol.NewTokenBucketRateLimiter(configShallowCopy.QPS, configShallowCopy.Burst) + } + var cs Clientset + var err error + cs.remoteenvironmentV1alpha1, err = remoteenvironmentv1alpha1.NewForConfig(&configShallowCopy) + if err != nil { + return nil, err + } + + cs.DiscoveryClient, err = discovery.NewDiscoveryClientForConfig(&configShallowCopy) + if err != nil { + glog.Errorf("failed to create the DiscoveryClient: %v", err) + return nil, err + } + return &cs, nil +} + +// NewForConfigOrDie creates a new Clientset for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *Clientset { + var cs Clientset + cs.remoteenvironmentV1alpha1 = remoteenvironmentv1alpha1.NewForConfigOrDie(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClientForConfigOrDie(c) + return &cs +} + +// New creates a new Clientset for the given RESTClient. +func New(c rest.Interface) *Clientset { + var cs Clientset + cs.remoteenvironmentV1alpha1 = remoteenvironmentv1alpha1.New(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClient(c) + return &cs +} diff --git a/components/remote-environment-broker/pkg/client/clientset/versioned/doc.go b/components/remote-environment-broker/pkg/client/clientset/versioned/doc.go new file mode 100644 index 000000000000..0e0c2a8900e2 --- /dev/null +++ b/components/remote-environment-broker/pkg/client/clientset/versioned/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated clientset. +package versioned diff --git a/components/remote-environment-broker/pkg/client/clientset/versioned/fake/clientset_generated.go b/components/remote-environment-broker/pkg/client/clientset/versioned/fake/clientset_generated.go new file mode 100644 index 000000000000..73010b736d18 --- /dev/null +++ b/components/remote-environment-broker/pkg/client/clientset/versioned/fake/clientset_generated.go @@ -0,0 +1,65 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + clientset "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned" + remoteenvironmentv1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1" + fakeremoteenvironmentv1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/discovery" + fakediscovery "k8s.io/client-go/discovery/fake" + "k8s.io/client-go/testing" +) + +// NewSimpleClientset returns a clientset that will respond with the provided objects. +// It's backed by a very simple object tracker that processes creates, updates and deletions as-is, +// without applying any validations and/or defaults. It shouldn't be considered a replacement +// for a real clientset and is mostly useful in simple unit tests. +func NewSimpleClientset(objects ...runtime.Object) *Clientset { + o := testing.NewObjectTracker(scheme, codecs.UniversalDecoder()) + for _, obj := range objects { + if err := o.Add(obj); err != nil { + panic(err) + } + } + + fakePtr := testing.Fake{} + fakePtr.AddReactor("*", "*", testing.ObjectReaction(o)) + fakePtr.AddWatchReactor("*", func(action testing.Action) (handled bool, ret watch.Interface, err error) { + gvr := action.GetResource() + ns := action.GetNamespace() + watch, err := o.Watch(gvr, ns) + if err != nil { + return false, nil, err + } + return true, watch, nil + }) + + return &Clientset{fakePtr, &fakediscovery.FakeDiscovery{Fake: &fakePtr}} +} + +// Clientset implements clientset.Interface. Meant to be embedded into a +// struct to get a default implementation. This makes faking out just the method +// you want to test easier. +type Clientset struct { + testing.Fake + discovery *fakediscovery.FakeDiscovery +} + +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + return c.discovery +} + +var _ clientset.Interface = &Clientset{} + +// RemoteenvironmentV1alpha1 retrieves the RemoteenvironmentV1alpha1Client +func (c *Clientset) RemoteenvironmentV1alpha1() remoteenvironmentv1alpha1.RemoteenvironmentV1alpha1Interface { + return &fakeremoteenvironmentv1alpha1.FakeRemoteenvironmentV1alpha1{Fake: &c.Fake} +} + +// Remoteenvironment retrieves the RemoteenvironmentV1alpha1Client +func (c *Clientset) Remoteenvironment() remoteenvironmentv1alpha1.RemoteenvironmentV1alpha1Interface { + return &fakeremoteenvironmentv1alpha1.FakeRemoteenvironmentV1alpha1{Fake: &c.Fake} +} diff --git a/components/remote-environment-broker/pkg/client/clientset/versioned/fake/doc.go b/components/remote-environment-broker/pkg/client/clientset/versioned/fake/doc.go new file mode 100644 index 000000000000..3630ed1cd17d --- /dev/null +++ b/components/remote-environment-broker/pkg/client/clientset/versioned/fake/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated fake clientset. +package fake diff --git a/components/remote-environment-broker/pkg/client/clientset/versioned/fake/register.go b/components/remote-environment-broker/pkg/client/clientset/versioned/fake/register.go new file mode 100644 index 000000000000..e202f93947de --- /dev/null +++ b/components/remote-environment-broker/pkg/client/clientset/versioned/fake/register.go @@ -0,0 +1,38 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + remoteenvironmentv1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var scheme = runtime.NewScheme() +var codecs = serializer.NewCodecFactory(scheme) +var parameterCodec = runtime.NewParameterCodec(scheme) + +func init() { + v1.AddToGroupVersion(scheme, schema.GroupVersion{Version: "v1"}) + AddToScheme(scheme) +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +func AddToScheme(scheme *runtime.Scheme) { + remoteenvironmentv1alpha1.AddToScheme(scheme) +} diff --git a/components/remote-environment-broker/pkg/client/clientset/versioned/scheme/doc.go b/components/remote-environment-broker/pkg/client/clientset/versioned/scheme/doc.go new file mode 100644 index 000000000000..14db57a58f8d --- /dev/null +++ b/components/remote-environment-broker/pkg/client/clientset/versioned/scheme/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package contains the scheme of the automatically generated clientset. +package scheme diff --git a/components/remote-environment-broker/pkg/client/clientset/versioned/scheme/register.go b/components/remote-environment-broker/pkg/client/clientset/versioned/scheme/register.go new file mode 100644 index 000000000000..9c67f22df65d --- /dev/null +++ b/components/remote-environment-broker/pkg/client/clientset/versioned/scheme/register.go @@ -0,0 +1,38 @@ +// Code generated by client-gen. DO NOT EDIT. + +package scheme + +import ( + remoteenvironmentv1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var Scheme = runtime.NewScheme() +var Codecs = serializer.NewCodecFactory(Scheme) +var ParameterCodec = runtime.NewParameterCodec(Scheme) + +func init() { + v1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"}) + AddToScheme(Scheme) +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +func AddToScheme(scheme *runtime.Scheme) { + remoteenvironmentv1alpha1.AddToScheme(scheme) +} diff --git a/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/doc.go b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/doc.go new file mode 100644 index 000000000000..93a7ca4e0e2b --- /dev/null +++ b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated typed clients. +package v1alpha1 diff --git a/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/environmentmapping.go b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/environmentmapping.go new file mode 100644 index 000000000000..9e90250f64c1 --- /dev/null +++ b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/environmentmapping.go @@ -0,0 +1,141 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + scheme "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// EnvironmentMappingsGetter has a method to return a EnvironmentMappingInterface. +// A group's client should implement this interface. +type EnvironmentMappingsGetter interface { + EnvironmentMappings(namespace string) EnvironmentMappingInterface +} + +// EnvironmentMappingInterface has methods to work with EnvironmentMapping resources. +type EnvironmentMappingInterface interface { + Create(*v1alpha1.EnvironmentMapping) (*v1alpha1.EnvironmentMapping, error) + Update(*v1alpha1.EnvironmentMapping) (*v1alpha1.EnvironmentMapping, error) + Delete(name string, options *v1.DeleteOptions) error + DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error + Get(name string, options v1.GetOptions) (*v1alpha1.EnvironmentMapping, error) + List(opts v1.ListOptions) (*v1alpha1.EnvironmentMappingList, error) + Watch(opts v1.ListOptions) (watch.Interface, error) + Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.EnvironmentMapping, err error) + EnvironmentMappingExpansion +} + +// environmentMappings implements EnvironmentMappingInterface +type environmentMappings struct { + client rest.Interface + ns string +} + +// newEnvironmentMappings returns a EnvironmentMappings +func newEnvironmentMappings(c *RemoteenvironmentV1alpha1Client, namespace string) *environmentMappings { + return &environmentMappings{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the environmentMapping, and returns the corresponding environmentMapping object, and an error if there is any. +func (c *environmentMappings) Get(name string, options v1.GetOptions) (result *v1alpha1.EnvironmentMapping, err error) { + result = &v1alpha1.EnvironmentMapping{} + err = c.client.Get(). + Namespace(c.ns). + Resource("environmentmappings"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of EnvironmentMappings that match those selectors. +func (c *environmentMappings) List(opts v1.ListOptions) (result *v1alpha1.EnvironmentMappingList, err error) { + result = &v1alpha1.EnvironmentMappingList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("environmentmappings"). + VersionedParams(&opts, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested environmentMappings. +func (c *environmentMappings) Watch(opts v1.ListOptions) (watch.Interface, error) { + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("environmentmappings"). + VersionedParams(&opts, scheme.ParameterCodec). + Watch() +} + +// Create takes the representation of a environmentMapping and creates it. Returns the server's representation of the environmentMapping, and an error, if there is any. +func (c *environmentMappings) Create(environmentMapping *v1alpha1.EnvironmentMapping) (result *v1alpha1.EnvironmentMapping, err error) { + result = &v1alpha1.EnvironmentMapping{} + err = c.client.Post(). + Namespace(c.ns). + Resource("environmentmappings"). + Body(environmentMapping). + Do(). + Into(result) + return +} + +// Update takes the representation of a environmentMapping and updates it. Returns the server's representation of the environmentMapping, and an error, if there is any. +func (c *environmentMappings) Update(environmentMapping *v1alpha1.EnvironmentMapping) (result *v1alpha1.EnvironmentMapping, err error) { + result = &v1alpha1.EnvironmentMapping{} + err = c.client.Put(). + Namespace(c.ns). + Resource("environmentmappings"). + Name(environmentMapping.Name). + Body(environmentMapping). + Do(). + Into(result) + return +} + +// Delete takes name of the environmentMapping and deletes it. Returns an error if one occurs. +func (c *environmentMappings) Delete(name string, options *v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("environmentmappings"). + Name(name). + Body(options). + Do(). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *environmentMappings) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("environmentmappings"). + VersionedParams(&listOptions, scheme.ParameterCodec). + Body(options). + Do(). + Error() +} + +// Patch applies the patch and returns the patched environmentMapping. +func (c *environmentMappings) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.EnvironmentMapping, err error) { + result = &v1alpha1.EnvironmentMapping{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("environmentmappings"). + SubResource(subresources...). + Name(name). + Body(data). + Do(). + Into(result) + return +} diff --git a/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/eventactivation.go b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/eventactivation.go new file mode 100644 index 000000000000..bc705b1eaa11 --- /dev/null +++ b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/eventactivation.go @@ -0,0 +1,141 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + scheme "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// EventActivationsGetter has a method to return a EventActivationInterface. +// A group's client should implement this interface. +type EventActivationsGetter interface { + EventActivations(namespace string) EventActivationInterface +} + +// EventActivationInterface has methods to work with EventActivation resources. +type EventActivationInterface interface { + Create(*v1alpha1.EventActivation) (*v1alpha1.EventActivation, error) + Update(*v1alpha1.EventActivation) (*v1alpha1.EventActivation, error) + Delete(name string, options *v1.DeleteOptions) error + DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error + Get(name string, options v1.GetOptions) (*v1alpha1.EventActivation, error) + List(opts v1.ListOptions) (*v1alpha1.EventActivationList, error) + Watch(opts v1.ListOptions) (watch.Interface, error) + Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.EventActivation, err error) + EventActivationExpansion +} + +// eventActivations implements EventActivationInterface +type eventActivations struct { + client rest.Interface + ns string +} + +// newEventActivations returns a EventActivations +func newEventActivations(c *RemoteenvironmentV1alpha1Client, namespace string) *eventActivations { + return &eventActivations{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the eventActivation, and returns the corresponding eventActivation object, and an error if there is any. +func (c *eventActivations) Get(name string, options v1.GetOptions) (result *v1alpha1.EventActivation, err error) { + result = &v1alpha1.EventActivation{} + err = c.client.Get(). + Namespace(c.ns). + Resource("eventactivations"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of EventActivations that match those selectors. +func (c *eventActivations) List(opts v1.ListOptions) (result *v1alpha1.EventActivationList, err error) { + result = &v1alpha1.EventActivationList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("eventactivations"). + VersionedParams(&opts, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested eventActivations. +func (c *eventActivations) Watch(opts v1.ListOptions) (watch.Interface, error) { + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("eventactivations"). + VersionedParams(&opts, scheme.ParameterCodec). + Watch() +} + +// Create takes the representation of a eventActivation and creates it. Returns the server's representation of the eventActivation, and an error, if there is any. +func (c *eventActivations) Create(eventActivation *v1alpha1.EventActivation) (result *v1alpha1.EventActivation, err error) { + result = &v1alpha1.EventActivation{} + err = c.client.Post(). + Namespace(c.ns). + Resource("eventactivations"). + Body(eventActivation). + Do(). + Into(result) + return +} + +// Update takes the representation of a eventActivation and updates it. Returns the server's representation of the eventActivation, and an error, if there is any. +func (c *eventActivations) Update(eventActivation *v1alpha1.EventActivation) (result *v1alpha1.EventActivation, err error) { + result = &v1alpha1.EventActivation{} + err = c.client.Put(). + Namespace(c.ns). + Resource("eventactivations"). + Name(eventActivation.Name). + Body(eventActivation). + Do(). + Into(result) + return +} + +// Delete takes name of the eventActivation and deletes it. Returns an error if one occurs. +func (c *eventActivations) Delete(name string, options *v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("eventactivations"). + Name(name). + Body(options). + Do(). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *eventActivations) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("eventactivations"). + VersionedParams(&listOptions, scheme.ParameterCodec). + Body(options). + Do(). + Error() +} + +// Patch applies the patch and returns the patched eventActivation. +func (c *eventActivations) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.EventActivation, err error) { + result = &v1alpha1.EventActivation{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("eventactivations"). + SubResource(subresources...). + Name(name). + Body(data). + Do(). + Into(result) + return +} diff --git a/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake/doc.go b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake/doc.go new file mode 100644 index 000000000000..2b5ba4c8e442 --- /dev/null +++ b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake/doc.go @@ -0,0 +1,4 @@ +// Code generated by client-gen. DO NOT EDIT. + +// Package fake has the automatically generated clients. +package fake diff --git a/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake/fake_environmentmapping.go b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake/fake_environmentmapping.go new file mode 100644 index 000000000000..5b509989f3fd --- /dev/null +++ b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake/fake_environmentmapping.go @@ -0,0 +1,112 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeEnvironmentMappings implements EnvironmentMappingInterface +type FakeEnvironmentMappings struct { + Fake *FakeRemoteenvironmentV1alpha1 + ns string +} + +var environmentmappingsResource = schema.GroupVersionResource{Group: "remoteenvironment.kyma.cx", Version: "v1alpha1", Resource: "environmentmappings"} + +var environmentmappingsKind = schema.GroupVersionKind{Group: "remoteenvironment.kyma.cx", Version: "v1alpha1", Kind: "EnvironmentMapping"} + +// Get takes name of the environmentMapping, and returns the corresponding environmentMapping object, and an error if there is any. +func (c *FakeEnvironmentMappings) Get(name string, options v1.GetOptions) (result *v1alpha1.EnvironmentMapping, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(environmentmappingsResource, c.ns, name), &v1alpha1.EnvironmentMapping{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.EnvironmentMapping), err +} + +// List takes label and field selectors, and returns the list of EnvironmentMappings that match those selectors. +func (c *FakeEnvironmentMappings) List(opts v1.ListOptions) (result *v1alpha1.EnvironmentMappingList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(environmentmappingsResource, environmentmappingsKind, c.ns, opts), &v1alpha1.EnvironmentMappingList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha1.EnvironmentMappingList{} + for _, item := range obj.(*v1alpha1.EnvironmentMappingList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested environmentMappings. +func (c *FakeEnvironmentMappings) Watch(opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(environmentmappingsResource, c.ns, opts)) + +} + +// Create takes the representation of a environmentMapping and creates it. Returns the server's representation of the environmentMapping, and an error, if there is any. +func (c *FakeEnvironmentMappings) Create(environmentMapping *v1alpha1.EnvironmentMapping) (result *v1alpha1.EnvironmentMapping, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(environmentmappingsResource, c.ns, environmentMapping), &v1alpha1.EnvironmentMapping{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.EnvironmentMapping), err +} + +// Update takes the representation of a environmentMapping and updates it. Returns the server's representation of the environmentMapping, and an error, if there is any. +func (c *FakeEnvironmentMappings) Update(environmentMapping *v1alpha1.EnvironmentMapping) (result *v1alpha1.EnvironmentMapping, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(environmentmappingsResource, c.ns, environmentMapping), &v1alpha1.EnvironmentMapping{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.EnvironmentMapping), err +} + +// Delete takes name of the environmentMapping and deletes it. Returns an error if one occurs. +func (c *FakeEnvironmentMappings) Delete(name string, options *v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(environmentmappingsResource, c.ns, name), &v1alpha1.EnvironmentMapping{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeEnvironmentMappings) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(environmentmappingsResource, c.ns, listOptions) + + _, err := c.Fake.Invokes(action, &v1alpha1.EnvironmentMappingList{}) + return err +} + +// Patch applies the patch and returns the patched environmentMapping. +func (c *FakeEnvironmentMappings) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.EnvironmentMapping, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(environmentmappingsResource, c.ns, name, data, subresources...), &v1alpha1.EnvironmentMapping{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.EnvironmentMapping), err +} diff --git a/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake/fake_eventactivation.go b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake/fake_eventactivation.go new file mode 100644 index 000000000000..8e3da20ec124 --- /dev/null +++ b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake/fake_eventactivation.go @@ -0,0 +1,112 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeEventActivations implements EventActivationInterface +type FakeEventActivations struct { + Fake *FakeRemoteenvironmentV1alpha1 + ns string +} + +var eventactivationsResource = schema.GroupVersionResource{Group: "remoteenvironment.kyma.cx", Version: "v1alpha1", Resource: "eventactivations"} + +var eventactivationsKind = schema.GroupVersionKind{Group: "remoteenvironment.kyma.cx", Version: "v1alpha1", Kind: "EventActivation"} + +// Get takes name of the eventActivation, and returns the corresponding eventActivation object, and an error if there is any. +func (c *FakeEventActivations) Get(name string, options v1.GetOptions) (result *v1alpha1.EventActivation, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(eventactivationsResource, c.ns, name), &v1alpha1.EventActivation{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.EventActivation), err +} + +// List takes label and field selectors, and returns the list of EventActivations that match those selectors. +func (c *FakeEventActivations) List(opts v1.ListOptions) (result *v1alpha1.EventActivationList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(eventactivationsResource, eventactivationsKind, c.ns, opts), &v1alpha1.EventActivationList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha1.EventActivationList{} + for _, item := range obj.(*v1alpha1.EventActivationList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested eventActivations. +func (c *FakeEventActivations) Watch(opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(eventactivationsResource, c.ns, opts)) + +} + +// Create takes the representation of a eventActivation and creates it. Returns the server's representation of the eventActivation, and an error, if there is any. +func (c *FakeEventActivations) Create(eventActivation *v1alpha1.EventActivation) (result *v1alpha1.EventActivation, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(eventactivationsResource, c.ns, eventActivation), &v1alpha1.EventActivation{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.EventActivation), err +} + +// Update takes the representation of a eventActivation and updates it. Returns the server's representation of the eventActivation, and an error, if there is any. +func (c *FakeEventActivations) Update(eventActivation *v1alpha1.EventActivation) (result *v1alpha1.EventActivation, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(eventactivationsResource, c.ns, eventActivation), &v1alpha1.EventActivation{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.EventActivation), err +} + +// Delete takes name of the eventActivation and deletes it. Returns an error if one occurs. +func (c *FakeEventActivations) Delete(name string, options *v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(eventactivationsResource, c.ns, name), &v1alpha1.EventActivation{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeEventActivations) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(eventactivationsResource, c.ns, listOptions) + + _, err := c.Fake.Invokes(action, &v1alpha1.EventActivationList{}) + return err +} + +// Patch applies the patch and returns the patched eventActivation. +func (c *FakeEventActivations) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.EventActivation, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(eventactivationsResource, c.ns, name, data, subresources...), &v1alpha1.EventActivation{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.EventActivation), err +} diff --git a/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake/fake_remoteenvironment.go b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake/fake_remoteenvironment.go new file mode 100644 index 000000000000..accece8fb9fa --- /dev/null +++ b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake/fake_remoteenvironment.go @@ -0,0 +1,115 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeRemoteEnvironments implements RemoteEnvironmentInterface +type FakeRemoteEnvironments struct { + Fake *FakeRemoteenvironmentV1alpha1 +} + +var remoteenvironmentsResource = schema.GroupVersionResource{Group: "remoteenvironment.kyma.cx", Version: "v1alpha1", Resource: "remoteenvironments"} + +var remoteenvironmentsKind = schema.GroupVersionKind{Group: "remoteenvironment.kyma.cx", Version: "v1alpha1", Kind: "RemoteEnvironment"} + +// Get takes name of the remoteEnvironment, and returns the corresponding remoteEnvironment object, and an error if there is any. +func (c *FakeRemoteEnvironments) Get(name string, options v1.GetOptions) (result *v1alpha1.RemoteEnvironment, err error) { + obj, err := c.Fake. + Invokes(testing.NewRootGetAction(remoteenvironmentsResource, name), &v1alpha1.RemoteEnvironment{}) + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.RemoteEnvironment), err +} + +// List takes label and field selectors, and returns the list of RemoteEnvironments that match those selectors. +func (c *FakeRemoteEnvironments) List(opts v1.ListOptions) (result *v1alpha1.RemoteEnvironmentList, err error) { + obj, err := c.Fake. + Invokes(testing.NewRootListAction(remoteenvironmentsResource, remoteenvironmentsKind, opts), &v1alpha1.RemoteEnvironmentList{}) + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha1.RemoteEnvironmentList{} + for _, item := range obj.(*v1alpha1.RemoteEnvironmentList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested remoteEnvironments. +func (c *FakeRemoteEnvironments) Watch(opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewRootWatchAction(remoteenvironmentsResource, opts)) +} + +// Create takes the representation of a remoteEnvironment and creates it. Returns the server's representation of the remoteEnvironment, and an error, if there is any. +func (c *FakeRemoteEnvironments) Create(remoteEnvironment *v1alpha1.RemoteEnvironment) (result *v1alpha1.RemoteEnvironment, err error) { + obj, err := c.Fake. + Invokes(testing.NewRootCreateAction(remoteenvironmentsResource, remoteEnvironment), &v1alpha1.RemoteEnvironment{}) + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.RemoteEnvironment), err +} + +// Update takes the representation of a remoteEnvironment and updates it. Returns the server's representation of the remoteEnvironment, and an error, if there is any. +func (c *FakeRemoteEnvironments) Update(remoteEnvironment *v1alpha1.RemoteEnvironment) (result *v1alpha1.RemoteEnvironment, err error) { + obj, err := c.Fake. + Invokes(testing.NewRootUpdateAction(remoteenvironmentsResource, remoteEnvironment), &v1alpha1.RemoteEnvironment{}) + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.RemoteEnvironment), err +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). +func (c *FakeRemoteEnvironments) UpdateStatus(remoteEnvironment *v1alpha1.RemoteEnvironment) (*v1alpha1.RemoteEnvironment, error) { + obj, err := c.Fake. + Invokes(testing.NewRootUpdateSubresourceAction(remoteenvironmentsResource, "status", remoteEnvironment), &v1alpha1.RemoteEnvironment{}) + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.RemoteEnvironment), err +} + +// Delete takes name of the remoteEnvironment and deletes it. Returns an error if one occurs. +func (c *FakeRemoteEnvironments) Delete(name string, options *v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewRootDeleteAction(remoteenvironmentsResource, name), &v1alpha1.RemoteEnvironment{}) + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeRemoteEnvironments) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + action := testing.NewRootDeleteCollectionAction(remoteenvironmentsResource, listOptions) + + _, err := c.Fake.Invokes(action, &v1alpha1.RemoteEnvironmentList{}) + return err +} + +// Patch applies the patch and returns the patched remoteEnvironment. +func (c *FakeRemoteEnvironments) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.RemoteEnvironment, err error) { + obj, err := c.Fake. + Invokes(testing.NewRootPatchSubresourceAction(remoteenvironmentsResource, name, data, subresources...), &v1alpha1.RemoteEnvironment{}) + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.RemoteEnvironment), err +} diff --git a/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake/fake_remoteenvironment_client.go b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake/fake_remoteenvironment_client.go new file mode 100644 index 000000000000..506e2d163c04 --- /dev/null +++ b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake/fake_remoteenvironment_client.go @@ -0,0 +1,32 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1" + rest "k8s.io/client-go/rest" + testing "k8s.io/client-go/testing" +) + +type FakeRemoteenvironmentV1alpha1 struct { + *testing.Fake +} + +func (c *FakeRemoteenvironmentV1alpha1) EnvironmentMappings(namespace string) v1alpha1.EnvironmentMappingInterface { + return &FakeEnvironmentMappings{c, namespace} +} + +func (c *FakeRemoteenvironmentV1alpha1) EventActivations(namespace string) v1alpha1.EventActivationInterface { + return &FakeEventActivations{c, namespace} +} + +func (c *FakeRemoteenvironmentV1alpha1) RemoteEnvironments() v1alpha1.RemoteEnvironmentInterface { + return &FakeRemoteEnvironments{c} +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *FakeRemoteenvironmentV1alpha1) RESTClient() rest.Interface { + var ret *rest.RESTClient + return ret +} diff --git a/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/generated_expansion.go b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/generated_expansion.go new file mode 100644 index 000000000000..d100a57f7476 --- /dev/null +++ b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/generated_expansion.go @@ -0,0 +1,9 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +type EnvironmentMappingExpansion interface{} + +type EventActivationExpansion interface{} + +type RemoteEnvironmentExpansion interface{} diff --git a/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/remoteenvironment.go b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/remoteenvironment.go new file mode 100644 index 000000000000..988d0212f937 --- /dev/null +++ b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/remoteenvironment.go @@ -0,0 +1,147 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + scheme "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// RemoteEnvironmentsGetter has a method to return a RemoteEnvironmentInterface. +// A group's client should implement this interface. +type RemoteEnvironmentsGetter interface { + RemoteEnvironments() RemoteEnvironmentInterface +} + +// RemoteEnvironmentInterface has methods to work with RemoteEnvironment resources. +type RemoteEnvironmentInterface interface { + Create(*v1alpha1.RemoteEnvironment) (*v1alpha1.RemoteEnvironment, error) + Update(*v1alpha1.RemoteEnvironment) (*v1alpha1.RemoteEnvironment, error) + UpdateStatus(*v1alpha1.RemoteEnvironment) (*v1alpha1.RemoteEnvironment, error) + Delete(name string, options *v1.DeleteOptions) error + DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error + Get(name string, options v1.GetOptions) (*v1alpha1.RemoteEnvironment, error) + List(opts v1.ListOptions) (*v1alpha1.RemoteEnvironmentList, error) + Watch(opts v1.ListOptions) (watch.Interface, error) + Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.RemoteEnvironment, err error) + RemoteEnvironmentExpansion +} + +// remoteEnvironments implements RemoteEnvironmentInterface +type remoteEnvironments struct { + client rest.Interface +} + +// newRemoteEnvironments returns a RemoteEnvironments +func newRemoteEnvironments(c *RemoteenvironmentV1alpha1Client) *remoteEnvironments { + return &remoteEnvironments{ + client: c.RESTClient(), + } +} + +// Get takes name of the remoteEnvironment, and returns the corresponding remoteEnvironment object, and an error if there is any. +func (c *remoteEnvironments) Get(name string, options v1.GetOptions) (result *v1alpha1.RemoteEnvironment, err error) { + result = &v1alpha1.RemoteEnvironment{} + err = c.client.Get(). + Resource("remoteenvironments"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of RemoteEnvironments that match those selectors. +func (c *remoteEnvironments) List(opts v1.ListOptions) (result *v1alpha1.RemoteEnvironmentList, err error) { + result = &v1alpha1.RemoteEnvironmentList{} + err = c.client.Get(). + Resource("remoteenvironments"). + VersionedParams(&opts, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested remoteEnvironments. +func (c *remoteEnvironments) Watch(opts v1.ListOptions) (watch.Interface, error) { + opts.Watch = true + return c.client.Get(). + Resource("remoteenvironments"). + VersionedParams(&opts, scheme.ParameterCodec). + Watch() +} + +// Create takes the representation of a remoteEnvironment and creates it. Returns the server's representation of the remoteEnvironment, and an error, if there is any. +func (c *remoteEnvironments) Create(remoteEnvironment *v1alpha1.RemoteEnvironment) (result *v1alpha1.RemoteEnvironment, err error) { + result = &v1alpha1.RemoteEnvironment{} + err = c.client.Post(). + Resource("remoteenvironments"). + Body(remoteEnvironment). + Do(). + Into(result) + return +} + +// Update takes the representation of a remoteEnvironment and updates it. Returns the server's representation of the remoteEnvironment, and an error, if there is any. +func (c *remoteEnvironments) Update(remoteEnvironment *v1alpha1.RemoteEnvironment) (result *v1alpha1.RemoteEnvironment, err error) { + result = &v1alpha1.RemoteEnvironment{} + err = c.client.Put(). + Resource("remoteenvironments"). + Name(remoteEnvironment.Name). + Body(remoteEnvironment). + Do(). + Into(result) + return +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + +func (c *remoteEnvironments) UpdateStatus(remoteEnvironment *v1alpha1.RemoteEnvironment) (result *v1alpha1.RemoteEnvironment, err error) { + result = &v1alpha1.RemoteEnvironment{} + err = c.client.Put(). + Resource("remoteenvironments"). + Name(remoteEnvironment.Name). + SubResource("status"). + Body(remoteEnvironment). + Do(). + Into(result) + return +} + +// Delete takes name of the remoteEnvironment and deletes it. Returns an error if one occurs. +func (c *remoteEnvironments) Delete(name string, options *v1.DeleteOptions) error { + return c.client.Delete(). + Resource("remoteenvironments"). + Name(name). + Body(options). + Do(). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *remoteEnvironments) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + return c.client.Delete(). + Resource("remoteenvironments"). + VersionedParams(&listOptions, scheme.ParameterCodec). + Body(options). + Do(). + Error() +} + +// Patch applies the patch and returns the patched remoteEnvironment. +func (c *remoteEnvironments) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.RemoteEnvironment, err error) { + result = &v1alpha1.RemoteEnvironment{} + err = c.client.Patch(pt). + Resource("remoteenvironments"). + SubResource(subresources...). + Name(name). + Body(data). + Do(). + Into(result) + return +} diff --git a/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/remoteenvironment_client.go b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/remoteenvironment_client.go new file mode 100644 index 000000000000..1b070cc58e1f --- /dev/null +++ b/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/remoteenvironment_client.go @@ -0,0 +1,84 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/scheme" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + rest "k8s.io/client-go/rest" +) + +type RemoteenvironmentV1alpha1Interface interface { + RESTClient() rest.Interface + EnvironmentMappingsGetter + EventActivationsGetter + RemoteEnvironmentsGetter +} + +// RemoteenvironmentV1alpha1Client is used to interact with features provided by the remoteenvironment.kyma.cx group. +type RemoteenvironmentV1alpha1Client struct { + restClient rest.Interface +} + +func (c *RemoteenvironmentV1alpha1Client) EnvironmentMappings(namespace string) EnvironmentMappingInterface { + return newEnvironmentMappings(c, namespace) +} + +func (c *RemoteenvironmentV1alpha1Client) EventActivations(namespace string) EventActivationInterface { + return newEventActivations(c, namespace) +} + +func (c *RemoteenvironmentV1alpha1Client) RemoteEnvironments() RemoteEnvironmentInterface { + return newRemoteEnvironments(c) +} + +// NewForConfig creates a new RemoteenvironmentV1alpha1Client for the given config. +func NewForConfig(c *rest.Config) (*RemoteenvironmentV1alpha1Client, error) { + config := *c + if err := setConfigDefaults(&config); err != nil { + return nil, err + } + client, err := rest.RESTClientFor(&config) + if err != nil { + return nil, err + } + return &RemoteenvironmentV1alpha1Client{client}, nil +} + +// NewForConfigOrDie creates a new RemoteenvironmentV1alpha1Client for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *RemoteenvironmentV1alpha1Client { + client, err := NewForConfig(c) + if err != nil { + panic(err) + } + return client +} + +// New creates a new RemoteenvironmentV1alpha1Client for the given RESTClient. +func New(c rest.Interface) *RemoteenvironmentV1alpha1Client { + return &RemoteenvironmentV1alpha1Client{c} +} + +func setConfigDefaults(config *rest.Config) error { + gv := v1alpha1.SchemeGroupVersion + config.GroupVersion = &gv + config.APIPath = "/apis" + config.NegotiatedSerializer = serializer.DirectCodecFactory{CodecFactory: scheme.Codecs} + + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } + + return nil +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *RemoteenvironmentV1alpha1Client) RESTClient() rest.Interface { + if c == nil { + return nil + } + return c.restClient +} diff --git a/components/remote-environment-broker/pkg/client/informers/externalversions/factory.go b/components/remote-environment-broker/pkg/client/informers/externalversions/factory.go new file mode 100644 index 000000000000..73a6932a6d1d --- /dev/null +++ b/components/remote-environment-broker/pkg/client/informers/externalversions/factory.go @@ -0,0 +1,115 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + reflect "reflect" + sync "sync" + time "time" + + versioned "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/informers/externalversions/internalinterfaces" + remoteenvironment "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +type sharedInformerFactory struct { + client versioned.Interface + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc + lock sync.Mutex + defaultResync time.Duration + + informers map[reflect.Type]cache.SharedIndexInformer + // startedInformers is used for tracking which informers have been started. + // This allows Start() to be called multiple times safely. + startedInformers map[reflect.Type]bool +} + +// NewSharedInformerFactory constructs a new instance of sharedInformerFactory +func NewSharedInformerFactory(client versioned.Interface, defaultResync time.Duration) SharedInformerFactory { + return NewFilteredSharedInformerFactory(client, defaultResync, v1.NamespaceAll, nil) +} + +// NewFilteredSharedInformerFactory constructs a new instance of sharedInformerFactory. +// Listers obtained via this SharedInformerFactory will be subject to the same filters +// as specified here. +func NewFilteredSharedInformerFactory(client versioned.Interface, defaultResync time.Duration, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) SharedInformerFactory { + return &sharedInformerFactory{ + client: client, + namespace: namespace, + tweakListOptions: tweakListOptions, + defaultResync: defaultResync, + informers: make(map[reflect.Type]cache.SharedIndexInformer), + startedInformers: make(map[reflect.Type]bool), + } +} + +// Start initializes all requested informers. +func (f *sharedInformerFactory) Start(stopCh <-chan struct{}) { + f.lock.Lock() + defer f.lock.Unlock() + + for informerType, informer := range f.informers { + if !f.startedInformers[informerType] { + go informer.Run(stopCh) + f.startedInformers[informerType] = true + } + } +} + +// WaitForCacheSync waits for all started informers' cache were synced. +func (f *sharedInformerFactory) WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool { + informers := func() map[reflect.Type]cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informers := map[reflect.Type]cache.SharedIndexInformer{} + for informerType, informer := range f.informers { + if f.startedInformers[informerType] { + informers[informerType] = informer + } + } + return informers + }() + + res := map[reflect.Type]bool{} + for informType, informer := range informers { + res[informType] = cache.WaitForCacheSync(stopCh, informer.HasSynced) + } + return res +} + +// InternalInformerFor returns the SharedIndexInformer for obj using an internal +// client. +func (f *sharedInformerFactory) InformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informerType := reflect.TypeOf(obj) + informer, exists := f.informers[informerType] + if exists { + return informer + } + informer = newFunc(f.client, f.defaultResync) + f.informers[informerType] = informer + + return informer +} + +// SharedInformerFactory provides shared informers for resources in all known +// API group versions. +type SharedInformerFactory interface { + internalinterfaces.SharedInformerFactory + ForResource(resource schema.GroupVersionResource) (GenericInformer, error) + WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool + + Remoteenvironment() remoteenvironment.Interface +} + +func (f *sharedInformerFactory) Remoteenvironment() remoteenvironment.Interface { + return remoteenvironment.New(f, f.namespace, f.tweakListOptions) +} diff --git a/components/remote-environment-broker/pkg/client/informers/externalversions/generic.go b/components/remote-environment-broker/pkg/client/informers/externalversions/generic.go new file mode 100644 index 000000000000..1206828fe870 --- /dev/null +++ b/components/remote-environment-broker/pkg/client/informers/externalversions/generic.go @@ -0,0 +1,50 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + "fmt" + + v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +// GenericInformer is type of SharedIndexInformer which will locate and delegate to other +// sharedInformers based on type +type GenericInformer interface { + Informer() cache.SharedIndexInformer + Lister() cache.GenericLister +} + +type genericInformer struct { + informer cache.SharedIndexInformer + resource schema.GroupResource +} + +// Informer returns the SharedIndexInformer. +func (f *genericInformer) Informer() cache.SharedIndexInformer { + return f.informer +} + +// Lister returns the GenericLister. +func (f *genericInformer) Lister() cache.GenericLister { + return cache.NewGenericLister(f.Informer().GetIndexer(), f.resource) +} + +// ForResource gives generic access to a shared informer of the matching type +// TODO extend this to unknown resources with a client pool +func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { + switch resource { + // Group=remoteenvironment.kyma.cx, Version=v1alpha1 + case v1alpha1.SchemeGroupVersion.WithResource("environmentmappings"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Remoteenvironment().V1alpha1().EnvironmentMappings().Informer()}, nil + case v1alpha1.SchemeGroupVersion.WithResource("eventactivations"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Remoteenvironment().V1alpha1().EventActivations().Informer()}, nil + case v1alpha1.SchemeGroupVersion.WithResource("remoteenvironments"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Remoteenvironment().V1alpha1().RemoteEnvironments().Informer()}, nil + + } + + return nil, fmt.Errorf("no informer found for %v", resource) +} diff --git a/components/remote-environment-broker/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go b/components/remote-environment-broker/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go new file mode 100644 index 000000000000..3aea1c1bba4e --- /dev/null +++ b/components/remote-environment-broker/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go @@ -0,0 +1,22 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package internalinterfaces + +import ( + time "time" + + versioned "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + cache "k8s.io/client-go/tools/cache" +) + +type NewInformerFunc func(versioned.Interface, time.Duration) cache.SharedIndexInformer + +// SharedInformerFactory a small interface to allow for adding an informer without an import cycle +type SharedInformerFactory interface { + Start(stopCh <-chan struct{}) + InformerFor(obj runtime.Object, newFunc NewInformerFunc) cache.SharedIndexInformer +} + +type TweakListOptionsFunc func(*v1.ListOptions) diff --git a/components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/interface.go b/components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/interface.go new file mode 100644 index 000000000000..fd75a58a2c8e --- /dev/null +++ b/components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/interface.go @@ -0,0 +1,30 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package remoteenvironment + +import ( + internalinterfaces "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/informers/externalversions/internalinterfaces" + v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/v1alpha1" +) + +// Interface provides access to each of this group's versions. +type Interface interface { + // V1alpha1 provides access to shared informers for resources in V1alpha1. + V1alpha1() v1alpha1.Interface +} + +type group struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// V1alpha1 returns a new v1alpha1.Interface. +func (g *group) V1alpha1() v1alpha1.Interface { + return v1alpha1.New(g.factory, g.namespace, g.tweakListOptions) +} diff --git a/components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/v1alpha1/environmentmapping.go b/components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/v1alpha1/environmentmapping.go new file mode 100644 index 000000000000..5efb8f9167f7 --- /dev/null +++ b/components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/v1alpha1/environmentmapping.go @@ -0,0 +1,73 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + time "time" + + remoteenvironment_v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + versioned "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/informers/externalversions/internalinterfaces" + v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// EnvironmentMappingInformer provides access to a shared informer and lister for +// EnvironmentMappings. +type EnvironmentMappingInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha1.EnvironmentMappingLister +} + +type environmentMappingInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewEnvironmentMappingInformer constructs a new informer for EnvironmentMapping type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewEnvironmentMappingInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredEnvironmentMappingInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredEnvironmentMappingInformer constructs a new informer for EnvironmentMapping type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredEnvironmentMappingInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.RemoteenvironmentV1alpha1().EnvironmentMappings(namespace).List(options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.RemoteenvironmentV1alpha1().EnvironmentMappings(namespace).Watch(options) + }, + }, + &remoteenvironment_v1alpha1.EnvironmentMapping{}, + resyncPeriod, + indexers, + ) +} + +func (f *environmentMappingInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredEnvironmentMappingInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *environmentMappingInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&remoteenvironment_v1alpha1.EnvironmentMapping{}, f.defaultInformer) +} + +func (f *environmentMappingInformer) Lister() v1alpha1.EnvironmentMappingLister { + return v1alpha1.NewEnvironmentMappingLister(f.Informer().GetIndexer()) +} diff --git a/components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/v1alpha1/eventactivation.go b/components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/v1alpha1/eventactivation.go new file mode 100644 index 000000000000..a628b63c091b --- /dev/null +++ b/components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/v1alpha1/eventactivation.go @@ -0,0 +1,73 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + time "time" + + remoteenvironment_v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + versioned "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/informers/externalversions/internalinterfaces" + v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// EventActivationInformer provides access to a shared informer and lister for +// EventActivations. +type EventActivationInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha1.EventActivationLister +} + +type eventActivationInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewEventActivationInformer constructs a new informer for EventActivation type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewEventActivationInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredEventActivationInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredEventActivationInformer constructs a new informer for EventActivation type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredEventActivationInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.RemoteenvironmentV1alpha1().EventActivations(namespace).List(options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.RemoteenvironmentV1alpha1().EventActivations(namespace).Watch(options) + }, + }, + &remoteenvironment_v1alpha1.EventActivation{}, + resyncPeriod, + indexers, + ) +} + +func (f *eventActivationInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredEventActivationInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *eventActivationInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&remoteenvironment_v1alpha1.EventActivation{}, f.defaultInformer) +} + +func (f *eventActivationInformer) Lister() v1alpha1.EventActivationLister { + return v1alpha1.NewEventActivationLister(f.Informer().GetIndexer()) +} diff --git a/components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/v1alpha1/interface.go b/components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/v1alpha1/interface.go new file mode 100644 index 000000000000..fb47d71627ba --- /dev/null +++ b/components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/v1alpha1/interface.go @@ -0,0 +1,43 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + internalinterfaces "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/informers/externalversions/internalinterfaces" +) + +// Interface provides access to all the informers in this group version. +type Interface interface { + // EnvironmentMappings returns a EnvironmentMappingInformer. + EnvironmentMappings() EnvironmentMappingInformer + // EventActivations returns a EventActivationInformer. + EventActivations() EventActivationInformer + // RemoteEnvironments returns a RemoteEnvironmentInformer. + RemoteEnvironments() RemoteEnvironmentInformer +} + +type version struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// EnvironmentMappings returns a EnvironmentMappingInformer. +func (v *version) EnvironmentMappings() EnvironmentMappingInformer { + return &environmentMappingInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + +// EventActivations returns a EventActivationInformer. +func (v *version) EventActivations() EventActivationInformer { + return &eventActivationInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + +// RemoteEnvironments returns a RemoteEnvironmentInformer. +func (v *version) RemoteEnvironments() RemoteEnvironmentInformer { + return &remoteEnvironmentInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} +} diff --git a/components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/v1alpha1/remoteenvironment.go b/components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/v1alpha1/remoteenvironment.go new file mode 100644 index 000000000000..e4e6433fb513 --- /dev/null +++ b/components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/v1alpha1/remoteenvironment.go @@ -0,0 +1,72 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + time "time" + + remoteenvironment_v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + versioned "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned" + internalinterfaces "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/informers/externalversions/internalinterfaces" + v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// RemoteEnvironmentInformer provides access to a shared informer and lister for +// RemoteEnvironments. +type RemoteEnvironmentInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha1.RemoteEnvironmentLister +} + +type remoteEnvironmentInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// NewRemoteEnvironmentInformer constructs a new informer for RemoteEnvironment type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewRemoteEnvironmentInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredRemoteEnvironmentInformer(client, resyncPeriod, indexers, nil) +} + +// NewFilteredRemoteEnvironmentInformer constructs a new informer for RemoteEnvironment type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredRemoteEnvironmentInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.RemoteenvironmentV1alpha1().RemoteEnvironments().List(options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.RemoteenvironmentV1alpha1().RemoteEnvironments().Watch(options) + }, + }, + &remoteenvironment_v1alpha1.RemoteEnvironment{}, + resyncPeriod, + indexers, + ) +} + +func (f *remoteEnvironmentInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredRemoteEnvironmentInformer(client, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *remoteEnvironmentInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&remoteenvironment_v1alpha1.RemoteEnvironment{}, f.defaultInformer) +} + +func (f *remoteEnvironmentInformer) Lister() v1alpha1.RemoteEnvironmentLister { + return v1alpha1.NewRemoteEnvironmentLister(f.Informer().GetIndexer()) +} diff --git a/components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1/environmentmapping.go b/components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1/environmentmapping.go new file mode 100644 index 000000000000..0be41083c769 --- /dev/null +++ b/components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1/environmentmapping.go @@ -0,0 +1,78 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// EnvironmentMappingLister helps list EnvironmentMappings. +type EnvironmentMappingLister interface { + // List lists all EnvironmentMappings in the indexer. + List(selector labels.Selector) (ret []*v1alpha1.EnvironmentMapping, err error) + // EnvironmentMappings returns an object that can list and get EnvironmentMappings. + EnvironmentMappings(namespace string) EnvironmentMappingNamespaceLister + EnvironmentMappingListerExpansion +} + +// environmentMappingLister implements the EnvironmentMappingLister interface. +type environmentMappingLister struct { + indexer cache.Indexer +} + +// NewEnvironmentMappingLister returns a new EnvironmentMappingLister. +func NewEnvironmentMappingLister(indexer cache.Indexer) EnvironmentMappingLister { + return &environmentMappingLister{indexer: indexer} +} + +// List lists all EnvironmentMappings in the indexer. +func (s *environmentMappingLister) List(selector labels.Selector) (ret []*v1alpha1.EnvironmentMapping, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.EnvironmentMapping)) + }) + return ret, err +} + +// EnvironmentMappings returns an object that can list and get EnvironmentMappings. +func (s *environmentMappingLister) EnvironmentMappings(namespace string) EnvironmentMappingNamespaceLister { + return environmentMappingNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// EnvironmentMappingNamespaceLister helps list and get EnvironmentMappings. +type EnvironmentMappingNamespaceLister interface { + // List lists all EnvironmentMappings in the indexer for a given namespace. + List(selector labels.Selector) (ret []*v1alpha1.EnvironmentMapping, err error) + // Get retrieves the EnvironmentMapping from the indexer for a given namespace and name. + Get(name string) (*v1alpha1.EnvironmentMapping, error) + EnvironmentMappingNamespaceListerExpansion +} + +// environmentMappingNamespaceLister implements the EnvironmentMappingNamespaceLister +// interface. +type environmentMappingNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all EnvironmentMappings in the indexer for a given namespace. +func (s environmentMappingNamespaceLister) List(selector labels.Selector) (ret []*v1alpha1.EnvironmentMapping, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.EnvironmentMapping)) + }) + return ret, err +} + +// Get retrieves the EnvironmentMapping from the indexer for a given namespace and name. +func (s environmentMappingNamespaceLister) Get(name string) (*v1alpha1.EnvironmentMapping, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha1.Resource("environmentmapping"), name) + } + return obj.(*v1alpha1.EnvironmentMapping), nil +} diff --git a/components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1/eventactivation.go b/components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1/eventactivation.go new file mode 100644 index 000000000000..ad2d3b29d6ca --- /dev/null +++ b/components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1/eventactivation.go @@ -0,0 +1,78 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// EventActivationLister helps list EventActivations. +type EventActivationLister interface { + // List lists all EventActivations in the indexer. + List(selector labels.Selector) (ret []*v1alpha1.EventActivation, err error) + // EventActivations returns an object that can list and get EventActivations. + EventActivations(namespace string) EventActivationNamespaceLister + EventActivationListerExpansion +} + +// eventActivationLister implements the EventActivationLister interface. +type eventActivationLister struct { + indexer cache.Indexer +} + +// NewEventActivationLister returns a new EventActivationLister. +func NewEventActivationLister(indexer cache.Indexer) EventActivationLister { + return &eventActivationLister{indexer: indexer} +} + +// List lists all EventActivations in the indexer. +func (s *eventActivationLister) List(selector labels.Selector) (ret []*v1alpha1.EventActivation, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.EventActivation)) + }) + return ret, err +} + +// EventActivations returns an object that can list and get EventActivations. +func (s *eventActivationLister) EventActivations(namespace string) EventActivationNamespaceLister { + return eventActivationNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// EventActivationNamespaceLister helps list and get EventActivations. +type EventActivationNamespaceLister interface { + // List lists all EventActivations in the indexer for a given namespace. + List(selector labels.Selector) (ret []*v1alpha1.EventActivation, err error) + // Get retrieves the EventActivation from the indexer for a given namespace and name. + Get(name string) (*v1alpha1.EventActivation, error) + EventActivationNamespaceListerExpansion +} + +// eventActivationNamespaceLister implements the EventActivationNamespaceLister +// interface. +type eventActivationNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all EventActivations in the indexer for a given namespace. +func (s eventActivationNamespaceLister) List(selector labels.Selector) (ret []*v1alpha1.EventActivation, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.EventActivation)) + }) + return ret, err +} + +// Get retrieves the EventActivation from the indexer for a given namespace and name. +func (s eventActivationNamespaceLister) Get(name string) (*v1alpha1.EventActivation, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha1.Resource("eventactivation"), name) + } + return obj.(*v1alpha1.EventActivation), nil +} diff --git a/components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1/expansion_generated.go b/components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1/expansion_generated.go new file mode 100644 index 000000000000..a972bb61304c --- /dev/null +++ b/components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1/expansion_generated.go @@ -0,0 +1,23 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +// EnvironmentMappingListerExpansion allows custom methods to be added to +// EnvironmentMappingLister. +type EnvironmentMappingListerExpansion interface{} + +// EnvironmentMappingNamespaceListerExpansion allows custom methods to be added to +// EnvironmentMappingNamespaceLister. +type EnvironmentMappingNamespaceListerExpansion interface{} + +// EventActivationListerExpansion allows custom methods to be added to +// EventActivationLister. +type EventActivationListerExpansion interface{} + +// EventActivationNamespaceListerExpansion allows custom methods to be added to +// EventActivationNamespaceLister. +type EventActivationNamespaceListerExpansion interface{} + +// RemoteEnvironmentListerExpansion allows custom methods to be added to +// RemoteEnvironmentLister. +type RemoteEnvironmentListerExpansion interface{} diff --git a/components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1/remoteenvironment.go b/components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1/remoteenvironment.go new file mode 100644 index 000000000000..9a9c32611769 --- /dev/null +++ b/components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1/remoteenvironment.go @@ -0,0 +1,49 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// RemoteEnvironmentLister helps list RemoteEnvironments. +type RemoteEnvironmentLister interface { + // List lists all RemoteEnvironments in the indexer. + List(selector labels.Selector) (ret []*v1alpha1.RemoteEnvironment, err error) + // Get retrieves the RemoteEnvironment from the index for a given name. + Get(name string) (*v1alpha1.RemoteEnvironment, error) + RemoteEnvironmentListerExpansion +} + +// remoteEnvironmentLister implements the RemoteEnvironmentLister interface. +type remoteEnvironmentLister struct { + indexer cache.Indexer +} + +// NewRemoteEnvironmentLister returns a new RemoteEnvironmentLister. +func NewRemoteEnvironmentLister(indexer cache.Indexer) RemoteEnvironmentLister { + return &remoteEnvironmentLister{indexer: indexer} +} + +// List lists all RemoteEnvironments in the indexer. +func (s *remoteEnvironmentLister) List(selector labels.Selector) (ret []*v1alpha1.RemoteEnvironment, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.RemoteEnvironment)) + }) + return ret, err +} + +// Get retrieves the RemoteEnvironment from the index for a given name. +func (s *remoteEnvironmentLister) Get(name string) (*v1alpha1.RemoteEnvironment, error) { + obj, exists, err := s.indexer.GetByKey(name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha1.Resource("remoteenvironment"), name) + } + return obj.(*v1alpha1.RemoteEnvironment), nil +} diff --git a/components/remote-environment-broker/platform/idprovider/id_provider.go b/components/remote-environment-broker/platform/idprovider/id_provider.go new file mode 100644 index 000000000000..2f5ecd93b756 --- /dev/null +++ b/components/remote-environment-broker/platform/idprovider/id_provider.go @@ -0,0 +1,23 @@ +package idprovider + +//noinspection SpellCheckingInspection +import ( + crand "crypto/rand" + "time" + + "github.com/oklog/ulid" + "github.com/pkg/errors" +) + +// New returns function which generates ULID ids. +// Reader from crypto/rand is used as a entropy source. It is safe for concurrent use. +func New() func() (string, error) { + return func() (string, error) { + ulidGen, err := ulid.New(ulid.Timestamp(time.Now()), crand.Reader) + if err != nil { + // not covered directly by tests due to quite difficult trigger scenario. + return "", errors.Wrap(err, "while generating ID") + } + return ulidGen.String(), nil + } +} diff --git a/components/remote-environment-broker/platform/logger/config.go b/components/remote-environment-broker/platform/logger/config.go new file mode 100644 index 000000000000..eaf24490dab3 --- /dev/null +++ b/components/remote-environment-broker/platform/logger/config.go @@ -0,0 +1,37 @@ +package logger + +import ( + "fmt" + + "github.com/sirupsen/logrus" +) + +type ( + // LogLevel is a config field type holding minimal log level. + // It's compatible with logrus level types. + LogLevel logrus.Level + + // Config is responsible for configuring logger. + Config struct { + // Level sets the minimal logging level + // values: debug, info (default), warn, warning, error, fatal, panic + Level LogLevel `envconfig:"default=info"` + + // BuildHash holds hash of the git commit from which binary was build. + BuildHash string `envconfig:"-"` + } +) + +// Unmarshal provides custom parsing of Log Level. +// Implements envconfig.Unmarshal interface. +func (m *LogLevel) Unmarshal(in string) error { + out, err := logrus.ParseLevel(in) + + if err != nil { + return fmt.Errorf("unable to unmarshal %s", in) + } + + *m = LogLevel(out) + + return nil +} diff --git a/components/remote-environment-broker/platform/logger/doc.go b/components/remote-environment-broker/platform/logger/doc.go new file mode 100644 index 000000000000..de2b9ff76124 --- /dev/null +++ b/components/remote-environment-broker/platform/logger/doc.go @@ -0,0 +1,2 @@ +// Package logger is responsible for logging. +package logger diff --git a/components/remote-environment-broker/platform/logger/logger.go b/components/remote-environment-broker/platform/logger/logger.go new file mode 100644 index 000000000000..d22d808acbc3 --- /dev/null +++ b/components/remote-environment-broker/platform/logger/logger.go @@ -0,0 +1,95 @@ +package logger + +//noinspection SpellCheckingInspection +import ( + "encoding/json" + "fmt" + "os" + + "github.com/sirupsen/logrus" +) + +const ( + timestampFormat = "2006-01-02T15:04:05.999Z" + fieldBuildHash = "buildHash" + + // FieldCtx is a key of logged context + FieldCtx = "ctx" +) + +// reservedFields defines all fields keys which cannot be used when +// we logging additional fields with YaaS formatter +var reservedFields = map[string]struct{}{ + "hop": {}, + "requestId": {}, + "time": {}, + "message": {}, + "requestOrg": {}, + "requestUser": {}, +} + +// Formatter is a log formatter +type Formatter struct{} + +// Format formats log entry to adhere to YaaS specs +func (f *Formatter) Format(entry *logrus.Entry) ([]byte, error) { + data := make(logrus.Fields, len(entry.Data)+3) + + for k, v := range entry.Data { + if shouldSkipThisField(k) { + continue + } + + switch v := v.(type) { + case error: + data[k] = v.Error() + default: + data[k] = v + } + } + + data["time"] = entry.Time.Format(timestampFormat) + data["message"] = entry.Message + + logEntry := map[string]interface{}{ + "log": data, + "level": entry.Level.String(), + } + + serialized, err := json.Marshal(logEntry) + if err != nil { + return nil, fmt.Errorf("Failed to marshal fields to JSON, %v", err) + } + return append(serialized, '\n'), nil +} + +func shouldSkipThisField(name string) bool { + // skips because is should be process by another function + if name == FieldCtx { + return true + } + + // skip because this fields are reserved + _, found := reservedFields[name] + return found +} + +// New creates new instance of logging apparatus. +// Logrus.Entry is returned as it's always decorated with fields. +func New(cfg *Config) *logrus.Entry { + + lgr := &logrus.Logger{ + Out: os.Stdout, + Formatter: new(Formatter), + Hooks: make(logrus.LevelHooks), + Level: logrus.Level(cfg.Level), + } + + fields := logrus.Fields{} + + if cfg.BuildHash != "" { + fields[fieldBuildHash] = cfg.BuildHash + } + + return lgr.WithFields(fields) +} diff --git a/components/remote-environment-broker/platform/logger/logger_mock.go b/components/remote-environment-broker/platform/logger/logger_mock.go new file mode 100644 index 000000000000..b2e4c12b1be2 --- /dev/null +++ b/components/remote-environment-broker/platform/logger/logger_mock.go @@ -0,0 +1,34 @@ +package logger + +import ( + "time" + + "github.com/sirupsen/logrus" +) + +// THTimeForcedFormatter is a Logrus compatible formatter which wraps original formatter and forces specific time. +// Designed to be used in testing. +type THTimeForcedFormatter struct { + // OrigFormatter is an original formatter + OrigFormatter logrus.Formatter + + // Time is a time to be forces in entries. + Time time.Time +} + +// Format entry but forces time for testing purposes +func (f *THTimeForcedFormatter) Format(entry *logrus.Entry) ([]byte, error) { + data := make(logrus.Fields, len(entry.Data)) + for k, v := range entry.Data { + data[k] = v + } + entryModified := &logrus.Entry{ + Logger: entry.Logger, + Data: data, + Time: f.Time, + Level: entry.Level, + Message: entry.Message, + } + + return f.OrigFormatter.Format(entryModified) +} diff --git a/components/remote-environment-broker/platform/logger/spy/formatter.go b/components/remote-environment-broker/platform/logger/spy/formatter.go new file mode 100644 index 000000000000..14494b7c4913 --- /dev/null +++ b/components/remote-environment-broker/platform/logger/spy/formatter.go @@ -0,0 +1,56 @@ +package spy + +import ( + "sync" + + "github.com/sirupsen/logrus" +) + +// EntryAssertFormatter is a log formatter, which gather all logged entries +type EntryAssertFormatter struct { + entries []logrus.Entry + Underlying logrus.Formatter + mu sync.RWMutex +} + +// Format appends each entry to entries slice +func (f *EntryAssertFormatter) Format(entry *logrus.Entry) ([]byte, error) { + f.appendThreadSafe(*entry) + return f.Underlying.Format(entry) +} + +// AnyMatches iterates over all stored entries and execute given matcher on it. +// Return true if any entries was successful matched +func (f *EntryAssertFormatter) AnyMatches(matcher func(entry logrus.Entry) bool) bool { + f.mu.RLock() + copyOfEntries := make([]logrus.Entry, len(f.entries)) + copy(copyOfEntries, f.entries) + f.mu.RUnlock() + for _, entry := range copyOfEntries { + if matcher(entry) { + return true + } + } + return false +} + +// AllEntriesMatches iterates over all stored entries and execute given matcher on it. +// Return true only if all entries was successful matched +func (f *EntryAssertFormatter) AllEntriesMatches(matcher func(entry logrus.Entry) bool) bool { + f.mu.RLock() + copyOfEntries := make([]logrus.Entry, len(f.entries)) + copy(copyOfEntries, f.entries) + f.mu.RUnlock() + for _, entry := range copyOfEntries { + if !matcher(entry) { + return false + } + } + return true +} + +func (f *EntryAssertFormatter) appendThreadSafe(entry logrus.Entry) { + f.mu.Lock() + defer f.mu.Unlock() + f.entries = append(f.entries, entry) +} diff --git a/components/remote-environment-broker/platform/logger/spy/logger.go b/components/remote-environment-broker/platform/logger/spy/logger.go new file mode 100644 index 000000000000..45ed1f468737 --- /dev/null +++ b/components/remote-environment-broker/platform/logger/spy/logger.go @@ -0,0 +1,133 @@ +// Package spy provides an implementation of go-sdk.logger that helps test logging. +package spy + +import ( + "bufio" + "bytes" + "encoding/json" + "io/ioutil" + "strings" + "testing" + + "github.com/sirupsen/logrus" +) + +// LogSink is a helper construct for testing logging in unit tests. +// Beware: all methods are working on copies of of original messages buffer and are safe for multiple uses. +type LogSink struct { + buffer *bytes.Buffer + RawLogger *logrus.Logger + Logger *logrus.Entry +} + +// NewLogSink is a factory for LogSink +func NewLogSink() *LogSink { + buffer := bytes.NewBuffer([]byte("")) + + rawLgr := &logrus.Logger{ + Out: buffer, + // standard json formatter is used to ease testing + Formatter: new(logrus.JSONFormatter), + Hooks: make(logrus.LevelHooks), + Level: logrus.DebugLevel, + } + + lgr := rawLgr.WithField("testing", true) + + return &LogSink{ + buffer: buffer, + RawLogger: rawLgr, + Logger: lgr, + } +} + +// AssertErrorLogged checks whatever a specific string was logged as error. +// +// Compared elements: level, message +// +// Wrapped errors are supported as long as original error message ends up in resulting one. +func (s *LogSink) AssertErrorLogged(t *testing.T, errorExpected error) { + if !s.wasLogged(t, logrus.ErrorLevel, errorExpected.Error()) { + t.Errorf("error was not logged, expected: \"%s\"", errorExpected.Error()) + } +} + +// AssertLogged checks whatever a specific string was logged at a specific level. +// +// Compared elements: level, message +// +// Beware: we are checking for sub-strings and not for the exact match. +func (s *LogSink) AssertLogged(t *testing.T, level logrus.Level, message string) { + if !s.wasLogged(t, level, message) { + t.Errorf("message was not logged, message: \"%s\", level: %s", message, level) + } +} + +// AssertNotLogged checks whatever a specific string was not logged at a specific level. +// +// Compared elements: level, message +// +// Beware: we are checking for sub-strings and not for the exact match. +func (s *LogSink) AssertNotLogged(t *testing.T, level logrus.Level, message string) { + if s.wasLogged(t, level, message) { + t.Errorf("message was logged, message: \"%s\", level: %s", message, level) + } +} + +// wasLogged checks whatever a message was logged. +// +// Compared elements: level, message +func (s *LogSink) wasLogged(t *testing.T, level logrus.Level, message string) bool { + // new reader is created so we are safe for multiple reads + buf := bytes.NewReader(s.buffer.Bytes()) + scanner := bufio.NewScanner(buf) + var entryPartial struct { + Level string `json:"level"` + Msg string `json:"msg"` + } + + for scanner.Scan() { + line := scanner.Text() + + err := json.Unmarshal([]byte(line), &entryPartial) + if err != nil { + t.Fatalf("unexpected error on log line unmarshalling, line: %s", line) + } + + levelMatches := entryPartial.Level == level.String() + + // We are looking only if expected is contained (as opposed to exact match check), + // so that e.g. errors wrapping is supported. + containsMessage := strings.Contains(entryPartial.Msg, message) + + if levelMatches && containsMessage { + return true + } + } + + return false +} + +// DumpAll returns all logged messages. +func (s *LogSink) DumpAll() []string { + // new reader is created so we are safe for multiple reads + buf := bytes.NewReader(s.buffer.Bytes()) + scanner := bufio.NewScanner(buf) + + out := []string{} + for scanner.Scan() { + out = append(out, scanner.Text()) + } + + return out +} + +// NewLogDummy returns dummy logger which discards logged messages on the fly. +// Useful when logger is required as dependency in unit testing. +func NewLogDummy() *logrus.Entry { + rawLgr := logrus.New() + rawLgr.Out = ioutil.Discard + lgr := rawLgr.WithField("testing", true) + + return lgr +} diff --git a/components/remote-environment-broker/platform/time/time.go b/components/remote-environment-broker/platform/time/time.go new file mode 100644 index 000000000000..b155e2afeec7 --- /dev/null +++ b/components/remote-environment-broker/platform/time/time.go @@ -0,0 +1,17 @@ +// Package time provide features which supplements standard time package. +package time + +import gTime "time" + +// NowProvider is a provider for current time +type NowProvider func() gTime.Time + +// Now returns current time. +// If NowProvider is not initialised, then current time is returned, +// otherwise NowProvider is called and result returned. +func (m NowProvider) Now() gTime.Time { + if m == nil { + return gTime.Now() + } + return m() +} diff --git a/components/ui-api-layer/.gitignore b/components/ui-api-layer/.gitignore new file mode 100644 index 000000000000..4d5525862dd8 --- /dev/null +++ b/components/ui-api-layer/.gitignore @@ -0,0 +1,8 @@ +.idea +*.iml +.vscode/ +/debug +/temp_* +vendor/ +.DS_Store +telepresence.log \ No newline at end of file diff --git a/components/ui-api-layer/CONTRIBUTING.md b/components/ui-api-layer/CONTRIBUTING.md new file mode 100644 index 000000000000..9f0f4047b30f --- /dev/null +++ b/components/ui-api-layer/CONTRIBUTING.md @@ -0,0 +1,85 @@ +# Overview +To contribute to this project, follow the rules from the general [CONTRIBUTING.md](https://github.com/kyma-project/community/blob/master/CONTRIBUTING.md) document in the `community` repository. +For additional, project-specific guidelines, see the respective sections of this document. + +## Contribution rules +Before you make a pull request, review the following rules. + +> **NOTE:** These rules mention terms described in the [Terminology](./docs/terminology.md) document. + +### Project structure +- Place all custom GraphQL types in the `gqlschema` package. Generate them first and move them to a separate files. Customize them and include them in the `types.json` file. +- Keep the first level of a domain package consistent. Create these files for every resource: + - `{NAME}_resolver.go`, which contains resolver type. They usually call services and convert types. + - `{NAME}_service.go`, which contains the business logic. The service uses data transfer object (DTO) type. + - `{NAME}_converter.go`, which is used for DTO to GraphQL type conversion. The type conversion for basic types, for example `string`, can be performed in a resolver. +- You can create subpackages and define their custom structure. +- Put all kind of generic utilities in the `pkg` directory. For example, see the [Gateway](./internal/domain/remoteenvironment) utility in the `remoteenvironment` domain. +- Place utilities tied to a specific domain in domain subpackages. For example, see the [Resource](./pkg/resource) utility. +- Place cross-domain utils in the `internal` directory. For example, see the [Pager](./internal/pager) utility. +- The domain resolver must be composed of resource resolvers defined in that package. +- Every domain must have the main file with the same name as the name of the domain package. For example, there must be the `servicecatalog.go` file in `servicecatalog` domain package. This is the root of the domain which should expose the `Resolver` type. Optionally, it can expose the `Config` type for passing the configuration values. For cross-domain implementations, it can contain the `Container` type which must contain the `Resolver` field and other fields needed by other packages. +- Place interfaces, which are shared between files in single domain in `interfaces.go`. + +### Implementation guidelines +Follow these rules while you develop new features for this project. + +**General implementation rules:** +- Avoid creating cross-domain packages. Do not create domain-to-domain, direct dependencies. Use interfaces on both sides to avoid circular dependencies. +- Do not make direct dependencies between a resolver type and services. Define interfaces, which contain only used methods. Use these interface types in a constructor of a resolver type. +- Do not export domain's interfaces and types which are not used in other places. For testing purposes, use the `export_test.go` file, which exports constructors only for tests. +- If an error appears, the resolver must return a general error message, which hides the applied solutions and logic behind them. Log the details of the error using the `glog` logger. +- Avoid creating functions in domains as they are accessible in whole domain. Create types and define their methods. +- Return pointers for objects that represents resources in services and converters. Pass objects by pointer as method arguments in converters. +- If a specific resource does not exist during `find` operation, return `nil` without an error. +- Use cache whenever possible for small pieces of data. Monitor resources usage and consider invalidating cache after some period of inactivity that lasts, for example, one day. + +**GraphQL:** +- For queries and mutations that have more than three arguments, use [input types](https://graphql.org/learn/schema/#input-types). +- Define the mutated object as a result of the mutation. +- For a query that returns a collection of objects, always return an empty array instead of `nil`. Mark all array elements as non-nullable. For example, define a query in the GraphQL schema that returns an array of service instances as `serviceInstances: [ServiceInstance!]!`. + +**Kubernetes resources:** +- For read only operations, use SharedIndexInformers, a client-side caching mechanism, which synchronize with Kubernetes API. Use them to find and list resources. SharedIndexInformers have different API from IndexInformers, but it is possible to attach multiple event handlers to them to facilitate future modifications. +- Add indexers for SharedIndexInformers in services. +- Use Kubernetes Go client for `create`, `update`, and `delete` operations. Do not operate on cache. + +**Acceptance tests:** +- Query all possible fields during testing queries and mutations. +- To check if nested objects are correctly resolved, perform a minimum validation and check the required fields, such as the name. + +### Naming guidelines +Use these patterns for naming GraphQL operations: +- Use the imperative mood to name mutations. For example, name a mutation that creates a new service instance `createServiceInstance`. +- Name queries with singular or plural nouns. For example, name the query that returns a single service instance `serviceInstance`. Name the query that returns all service instances `serviceInstances`. + +Use these patterns for naming types in the first level of a domain package: +- `Config` for an exported type, which stores configuration values +- `Resolver` for an exported type, which is composed of resources resolvers in the domain package +- `Container` for an exported type, which exports `Resolver` and other types required by other domains +- `{RESOURCE_NAME}Resolver` for a resolver type of a specific resource +- `{RESOURCE_NAME}Service` for a service type of a specific resource +- `{RESOURCE_NAME}Converter` for a converter type of a specific resource + +Use these patterns for naming methods in resource resolvers: +- `{RESOURCE_NAME}Query` for a query resolver +- `{RESOURCE_NAME}Mutation` for a mutation resolver +- `{RESOURCE_NAME}Subscription` for a subscription resolver + +Use these patterns for naming methods in services: +- `Create` to create a single resource +- `Find` to get a single resource +- `List` to get multiple resources +- `Update` to update a resource +- `Delete` to delete a resource +- For specific operations, use short, meaningful names. For example, a method of `instanceService` that lists instances for a specific class should be named `ListForClass`. + +Use this pattern for naming methods in converters: +- `ToGQL` for DTO to GraphQL type conversion +- For the conversion in the opposite direction, use a similar naming convention. For example, `ToK8S`. + +### Code quality +- All Go code must have unit and acceptance tests for all business logic. +- All Go code must pass `go vet ./...`. The CI build job performs the check automatically. +- Format the Go code with `gofmt`. +- Describe any new application configuration options in the [README.md](./README.md) document. diff --git a/components/ui-api-layer/Dockerfile b/components/ui-api-layer/Dockerfile new file mode 100644 index 000000000000..c9f0a5add4d8 --- /dev/null +++ b/components/ui-api-layer/Dockerfile @@ -0,0 +1,42 @@ +FROM golang:1.10.2-alpine3.7 as builder + +ENV BASE_APP_DIR /go/src/github.com/kyma-project/kyma/components/ui-api-layer +WORKDIR ${BASE_APP_DIR} + +# +# Copy files +# + +COPY ./vendor/ ${BASE_APP_DIR}/vendor/ +COPY ./internal/ ${BASE_APP_DIR}/internal/ +COPY ./pkg/ ${BASE_APP_DIR}/pkg/ +COPY ./main.go ${BASE_APP_DIR}/ + +# +# Build app +# + +RUN go build -v -o main . +RUN mkdir /app && mv ./main /app/main + +FROM alpine:3.7 +LABEL source = git@github.com:kyma-project/kyma.git +WORKDIR /app + +# +# Install certificates +# + +RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/* + +# +# Copy binary +# + +COPY --from=builder /app /app + +# +# Run app +# + +CMD ["/app/main"] diff --git a/components/ui-api-layer/Gopkg.lock b/components/ui-api-layer/Gopkg.lock new file mode 100644 index 000000000000..f3794f7a3562 --- /dev/null +++ b/components/ui-api-layer/Gopkg.lock @@ -0,0 +1,630 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/allegro/bigcache" + packages = [ + ".", + "queue" + ] + revision = "f31987a23e44c5121ef8c8b2f2ea2e8ffa37b068" + version = "v1.1.0" + +[[projects]] + name = "github.com/davecgh/go-spew" + packages = ["spew"] + revision = "346938d642f2ec3594ed81d874461961cd0faa76" + version = "v1.1.0" + +[[projects]] + branch = "master" + name = "github.com/dustin/go-humanize" + packages = ["."] + revision = "02af3965c54e8cacf948b97fef38925c4120652c" + +[[projects]] + name = "github.com/ghodss/yaml" + packages = ["."] + revision = "0ca9ea5df5451ffdf184b4428c902747c2c11cd7" + version = "v1.0.0" + +[[projects]] + name = "github.com/go-ini/ini" + packages = ["."] + revision = "06f5f3d67269ccec1fe5fe4134ba6e982984f7f5" + version = "v1.37.0" + +[[projects]] + name = "github.com/gogo/protobuf" + packages = [ + "proto", + "sortkeys" + ] + revision = "1adfc126b41513cc696b209667c8656ea7aac67c" + version = "v1.0.0" + +[[projects]] + name = "github.com/golang/glog" + packages = ["."] + revision = "44145f04b68cf362d9c4df2182967c2275eaefed" + +[[projects]] + name = "github.com/golang/protobuf" + packages = [ + "proto", + "ptypes", + "ptypes/any", + "ptypes/duration", + "ptypes/timestamp" + ] + revision = "b4deda0973fb4c70b50d226b1af49f3da59f5265" + version = "v1.1.0" + +[[projects]] + branch = "master" + name = "github.com/google/gofuzz" + packages = ["."] + revision = "24818f796faf91cd76ec7bddd72458fbced7a6c1" + +[[projects]] + name = "github.com/googleapis/gnostic" + packages = [ + "OpenAPIv2", + "compiler", + "extensions" + ] + revision = "7c663266750e7d82587642f65e60bc4083f1f84e" + version = "v0.2.0" + +[[projects]] + name = "github.com/gorilla/websocket" + packages = ["."] + revision = "ea4d1f681babbce9545c9c5f3d5194a789c89f5b" + version = "v1.2.0" + +[[projects]] + branch = "master" + name = "github.com/hashicorp/golang-lru" + packages = [ + ".", + "simplelru" + ] + revision = "0fb14efe8c47ae851c0034ed7a448854d3d34cf3" + +[[projects]] + branch = "master" + name = "github.com/howeyc/gopass" + packages = ["."] + revision = "bf9dde6d0d2c004a008c27aaee91170c786f6db8" + +[[projects]] + name = "github.com/imdario/mergo" + packages = ["."] + revision = "9316a62528ac99aaecb4e47eadd6dc8aa6533d58" + version = "v0.3.5" + +[[projects]] + name = "github.com/json-iterator/go" + packages = ["."] + revision = "ab8a2e0c74be9d3be70b3184d9acc634935ded82" + version = "1.1.4" + +[[projects]] + name = "github.com/kubeless/kubeless" + packages = [ + "pkg/apis/kubeless", + "pkg/apis/kubeless/v1beta1", + "pkg/client/clientset/versioned", + "pkg/client/clientset/versioned/fake", + "pkg/client/clientset/versioned/scheme", + "pkg/client/clientset/versioned/typed/kubeless/v1beta1", + "pkg/client/clientset/versioned/typed/kubeless/v1beta1/fake", + "pkg/client/informers/externalversions", + "pkg/client/informers/externalversions/internalinterfaces", + "pkg/client/informers/externalversions/kubeless", + "pkg/client/informers/externalversions/kubeless/v1beta1", + "pkg/client/listers/kubeless/v1beta1" + ] + revision = "4f4f531f6a1b685bf3842b26cfff5ca7eee533cc" + version = "v0.4.0" + +[[projects]] + name = "github.com/kubernetes-incubator/service-catalog" + packages = [ + "pkg/apis/servicecatalog", + "pkg/apis/servicecatalog/v1beta1", + "pkg/apis/settings", + "pkg/apis/settings/v1alpha1", + "pkg/client/clientset_generated/clientset", + "pkg/client/clientset_generated/clientset/fake", + "pkg/client/clientset_generated/clientset/scheme", + "pkg/client/clientset_generated/clientset/typed/servicecatalog/v1beta1", + "pkg/client/clientset_generated/clientset/typed/servicecatalog/v1beta1/fake", + "pkg/client/clientset_generated/clientset/typed/settings/v1alpha1", + "pkg/client/clientset_generated/clientset/typed/settings/v1alpha1/fake", + "pkg/client/informers_generated/externalversions", + "pkg/client/informers_generated/externalversions/internalinterfaces", + "pkg/client/informers_generated/externalversions/servicecatalog", + "pkg/client/informers_generated/externalversions/servicecatalog/v1beta1", + "pkg/client/informers_generated/externalversions/settings", + "pkg/client/informers_generated/externalversions/settings/v1alpha1", + "pkg/client/listers_generated/servicecatalog/v1beta1", + "pkg/client/listers_generated/settings/v1alpha1", + "pkg/filter" + ] + revision = "87a5db0e1e0359ce372037a235ce4448944c8611" + version = "v0.1.23" + +[[projects]] + branch = "master" + name = "github.com/kyma-project/kyma" + packages = [ + "components/api-controller/pkg/apis/gateway.kyma.cx/meta/v1", + "components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2", + "components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned", + "components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/fake", + "components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/scheme", + "components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2", + "components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/typed/gateway.kyma.cx/v1alpha2/fake", + "components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions", + "components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/gateway.kyma.cx", + "components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/gateway.kyma.cx/v1alpha2", + "components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions/internalinterfaces", + "components/api-controller/pkg/clients/gateway.kyma.cx/listers/gateway.kyma.cx/v1alpha2", + "components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1", + "components/binding-usage-controller/pkg/client/clientset/versioned", + "components/binding-usage-controller/pkg/client/clientset/versioned/fake", + "components/binding-usage-controller/pkg/client/clientset/versioned/scheme", + "components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1", + "components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1/fake", + "components/binding-usage-controller/pkg/client/informers/externalversions", + "components/binding-usage-controller/pkg/client/informers/externalversions/internalinterfaces", + "components/binding-usage-controller/pkg/client/informers/externalversions/servicecatalog", + "components/binding-usage-controller/pkg/client/informers/externalversions/servicecatalog/v1alpha1", + "components/binding-usage-controller/pkg/client/listers/servicecatalog/v1alpha1", + "components/idppreset/pkg/apis/ui/v1alpha1", + "components/idppreset/pkg/client/clientset/versioned", + "components/idppreset/pkg/client/clientset/versioned/fake", + "components/idppreset/pkg/client/clientset/versioned/scheme", + "components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1", + "components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1/fake", + "components/idppreset/pkg/client/informers/externalversions", + "components/idppreset/pkg/client/informers/externalversions/internalinterfaces", + "components/idppreset/pkg/client/informers/externalversions/ui", + "components/idppreset/pkg/client/informers/externalversions/ui/v1alpha1", + "components/idppreset/pkg/client/listers/ui/v1alpha1", + "components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1", + "components/remote-environment-broker/pkg/client/clientset/versioned", + "components/remote-environment-broker/pkg/client/clientset/versioned/fake", + "components/remote-environment-broker/pkg/client/clientset/versioned/scheme", + "components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1", + "components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1/fake", + "components/remote-environment-broker/pkg/client/informers/externalversions", + "components/remote-environment-broker/pkg/client/informers/externalversions/internalinterfaces", + "components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment", + "components/remote-environment-broker/pkg/client/informers/externalversions/remoteenvironment/v1alpha1", + "components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1" + ] + revision = "98ffd39a35793983f4d291b743e192daa0701d3b" + +[[projects]] + name = "github.com/minio/minio-go" + packages = [ + ".", + "pkg/credentials", + "pkg/encrypt", + "pkg/policy", + "pkg/s3signer", + "pkg/s3utils", + "pkg/set" + ] + revision = "9e124ec59547551cb3f1324f73623bbb30650cf8" + version = "4.0.9" + +[[projects]] + branch = "master" + name = "github.com/mitchellh/go-homedir" + packages = ["."] + revision = "3864e76763d94a6df2f9960b16a20a33da9f9a66" + +[[projects]] + name = "github.com/modern-go/concurrent" + packages = ["."] + revision = "bacd9c7ef1dd9b15be4a9909b8ac7a4e313eec94" + version = "1.0.3" + +[[projects]] + name = "github.com/modern-go/reflect2" + packages = ["."] + revision = "4b7aa43c6742a2c18fdef89dd197aaae7dac7ccd" + version = "1.0.1" + +[[projects]] + name = "github.com/pkg/errors" + packages = ["."] + revision = "645ef00459ed84a119197bfb8d8205042c6df63d" + version = "v0.8.0" + +[[projects]] + name = "github.com/pmezard/go-difflib" + packages = ["difflib"] + revision = "792786c7400a136282c1664665ae0a8db921c6c2" + version = "v1.0.0" + +[[projects]] + name = "github.com/rs/cors" + packages = ["."] + revision = "ca016a06a5753f8ba03029c0aa5e54afb1bf713f" + version = "v1.4.0" + +[[projects]] + name = "github.com/sirupsen/logrus" + packages = ["."] + revision = "c155da19408a8799da419ed3eeb0cb5db0ad5dbc" + version = "v1.0.5" + +[[projects]] + name = "github.com/spf13/pflag" + packages = ["."] + revision = "583c0c0531f06d5278b7d917446061adc344b5cd" + version = "v1.0.1" + +[[projects]] + name = "github.com/stretchr/objx" + packages = ["."] + revision = "477a77ecc69700c7cdeb1fa9e129548e1c1c393c" + version = "v0.1.1" + +[[projects]] + name = "github.com/stretchr/testify" + packages = [ + "assert", + "mock", + "require" + ] + revision = "f35b8ab0b5a2cef36673838d662e249dd9c94686" + version = "v1.2.2" + +[[projects]] + name = "github.com/thoas/go-funk" + packages = ["."] + revision = "14304e8530de7dbb909c8305591b3a8370dd9dbb" + version = "0.2" + +[[projects]] + name = "github.com/vektah/gqlgen" + packages = [ + "graphql", + "handler", + "neelance/common", + "neelance/errors", + "neelance/introspection", + "neelance/query", + "neelance/schema", + "neelance/validation" + ] + revision = "e1fd79fed15f60c47471d901c8250ab56aff1c55" + +[[projects]] + name = "github.com/vrischmann/envconfig" + packages = ["."] + revision = "98b0b9a570bdd3eb00e3e6eeb15548e7f982bfd3" + version = "1.0.0" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = ["ssh/terminal"] + revision = "a49355c7e3f8fe157a85be2f77e6e269a0f89602" + +[[projects]] + branch = "release-branch.go1.10" + name = "golang.org/x/net" + packages = [ + "context", + "http2", + "http2/hpack", + "idna", + "lex/httplex" + ] + revision = "0ed95abb35c445290478a5348a7b38bb154135fd" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = [ + "unix", + "windows" + ] + revision = "1b2967e3c290b7c545b3db0deeda16e9be4f98a2" + +[[projects]] + name = "golang.org/x/text" + packages = [ + "collate", + "collate/build", + "internal/colltab", + "internal/gen", + "internal/tag", + "internal/triegen", + "internal/ucd", + "language", + "secure/bidirule", + "transform", + "unicode/bidi", + "unicode/cldr", + "unicode/norm", + "unicode/rangetable" + ] + revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" + version = "v0.3.0" + +[[projects]] + branch = "master" + name = "golang.org/x/time" + packages = ["rate"] + revision = "fbb02b2291d28baffd63558aa44b4b56f178d650" + +[[projects]] + branch = "master" + name = "golang.org/x/tools" + packages = [ + "cmd/goimports", + "go/ast/astutil", + "imports", + "internal/fastwalk" + ] + revision = "e2be0f7276f6ea2d8290dea1ffd89c98f6ac0b9e" + +[[projects]] + name = "gopkg.in/inf.v0" + packages = ["."] + revision = "d2d2541c53f18d2a059457998ce2876cc8e67cbf" + version = "v0.9.1" + +[[projects]] + name = "gopkg.in/yaml.v2" + packages = ["."] + revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183" + version = "v2.2.1" + +[[projects]] + name = "k8s.io/api" + packages = [ + "admissionregistration/v1alpha1", + "admissionregistration/v1beta1", + "apps/v1", + "apps/v1beta1", + "apps/v1beta2", + "authentication/v1", + "authentication/v1beta1", + "authorization/v1", + "authorization/v1beta1", + "autoscaling/v1", + "autoscaling/v2beta1", + "batch/v1", + "batch/v1beta1", + "batch/v2alpha1", + "certificates/v1beta1", + "core/v1", + "events/v1beta1", + "extensions/v1beta1", + "networking/v1", + "policy/v1beta1", + "rbac/v1", + "rbac/v1alpha1", + "rbac/v1beta1", + "scheduling/v1alpha1", + "settings/v1alpha1", + "storage/v1", + "storage/v1alpha1", + "storage/v1beta1" + ] + revision = "73d903622b7391f3312dcbac6483fed484e185f8" + version = "kubernetes-1.10.1" + +[[projects]] + name = "k8s.io/apimachinery" + packages = [ + "pkg/api/errors", + "pkg/api/meta", + "pkg/api/resource", + "pkg/apis/meta/internalversion", + "pkg/apis/meta/v1", + "pkg/apis/meta/v1/unstructured", + "pkg/apis/meta/v1beta1", + "pkg/conversion", + "pkg/conversion/queryparams", + "pkg/fields", + "pkg/labels", + "pkg/runtime", + "pkg/runtime/schema", + "pkg/runtime/serializer", + "pkg/runtime/serializer/json", + "pkg/runtime/serializer/protobuf", + "pkg/runtime/serializer/recognizer", + "pkg/runtime/serializer/streaming", + "pkg/runtime/serializer/versioning", + "pkg/selection", + "pkg/types", + "pkg/util/cache", + "pkg/util/clock", + "pkg/util/diff", + "pkg/util/errors", + "pkg/util/framer", + "pkg/util/intstr", + "pkg/util/json", + "pkg/util/net", + "pkg/util/runtime", + "pkg/util/sets", + "pkg/util/validation", + "pkg/util/validation/field", + "pkg/util/wait", + "pkg/util/yaml", + "pkg/version", + "pkg/watch", + "third_party/forked/golang/reflect" + ] + revision = "302974c03f7e50f16561ba237db776ab93594ef6" + +[[projects]] + name = "k8s.io/client-go" + packages = [ + "discovery", + "discovery/fake", + "informers", + "informers/admissionregistration", + "informers/admissionregistration/v1alpha1", + "informers/admissionregistration/v1beta1", + "informers/apps", + "informers/apps/v1", + "informers/apps/v1beta1", + "informers/apps/v1beta2", + "informers/autoscaling", + "informers/autoscaling/v1", + "informers/autoscaling/v2beta1", + "informers/batch", + "informers/batch/v1", + "informers/batch/v1beta1", + "informers/batch/v2alpha1", + "informers/certificates", + "informers/certificates/v1beta1", + "informers/core", + "informers/core/v1", + "informers/events", + "informers/events/v1beta1", + "informers/extensions", + "informers/extensions/v1beta1", + "informers/internalinterfaces", + "informers/networking", + "informers/networking/v1", + "informers/policy", + "informers/policy/v1beta1", + "informers/rbac", + "informers/rbac/v1", + "informers/rbac/v1alpha1", + "informers/rbac/v1beta1", + "informers/scheduling", + "informers/scheduling/v1alpha1", + "informers/settings", + "informers/settings/v1alpha1", + "informers/storage", + "informers/storage/v1", + "informers/storage/v1alpha1", + "informers/storage/v1beta1", + "kubernetes", + "kubernetes/fake", + "kubernetes/scheme", + "kubernetes/typed/admissionregistration/v1alpha1", + "kubernetes/typed/admissionregistration/v1alpha1/fake", + "kubernetes/typed/admissionregistration/v1beta1", + "kubernetes/typed/admissionregistration/v1beta1/fake", + "kubernetes/typed/apps/v1", + "kubernetes/typed/apps/v1/fake", + "kubernetes/typed/apps/v1beta1", + "kubernetes/typed/apps/v1beta1/fake", + "kubernetes/typed/apps/v1beta2", + "kubernetes/typed/apps/v1beta2/fake", + "kubernetes/typed/authentication/v1", + "kubernetes/typed/authentication/v1/fake", + "kubernetes/typed/authentication/v1beta1", + "kubernetes/typed/authentication/v1beta1/fake", + "kubernetes/typed/authorization/v1", + "kubernetes/typed/authorization/v1/fake", + "kubernetes/typed/authorization/v1beta1", + "kubernetes/typed/authorization/v1beta1/fake", + "kubernetes/typed/autoscaling/v1", + "kubernetes/typed/autoscaling/v1/fake", + "kubernetes/typed/autoscaling/v2beta1", + "kubernetes/typed/autoscaling/v2beta1/fake", + "kubernetes/typed/batch/v1", + "kubernetes/typed/batch/v1/fake", + "kubernetes/typed/batch/v1beta1", + "kubernetes/typed/batch/v1beta1/fake", + "kubernetes/typed/batch/v2alpha1", + "kubernetes/typed/batch/v2alpha1/fake", + "kubernetes/typed/certificates/v1beta1", + "kubernetes/typed/certificates/v1beta1/fake", + "kubernetes/typed/core/v1", + "kubernetes/typed/core/v1/fake", + "kubernetes/typed/events/v1beta1", + "kubernetes/typed/events/v1beta1/fake", + "kubernetes/typed/extensions/v1beta1", + "kubernetes/typed/extensions/v1beta1/fake", + "kubernetes/typed/networking/v1", + "kubernetes/typed/networking/v1/fake", + "kubernetes/typed/policy/v1beta1", + "kubernetes/typed/policy/v1beta1/fake", + "kubernetes/typed/rbac/v1", + "kubernetes/typed/rbac/v1/fake", + "kubernetes/typed/rbac/v1alpha1", + "kubernetes/typed/rbac/v1alpha1/fake", + "kubernetes/typed/rbac/v1beta1", + "kubernetes/typed/rbac/v1beta1/fake", + "kubernetes/typed/scheduling/v1alpha1", + "kubernetes/typed/scheduling/v1alpha1/fake", + "kubernetes/typed/settings/v1alpha1", + "kubernetes/typed/settings/v1alpha1/fake", + "kubernetes/typed/storage/v1", + "kubernetes/typed/storage/v1/fake", + "kubernetes/typed/storage/v1alpha1", + "kubernetes/typed/storage/v1alpha1/fake", + "kubernetes/typed/storage/v1beta1", + "kubernetes/typed/storage/v1beta1/fake", + "listers/admissionregistration/v1alpha1", + "listers/admissionregistration/v1beta1", + "listers/apps/v1", + "listers/apps/v1beta1", + "listers/apps/v1beta2", + "listers/autoscaling/v1", + "listers/autoscaling/v2beta1", + "listers/batch/v1", + "listers/batch/v1beta1", + "listers/batch/v2alpha1", + "listers/certificates/v1beta1", + "listers/core/v1", + "listers/events/v1beta1", + "listers/extensions/v1beta1", + "listers/networking/v1", + "listers/policy/v1beta1", + "listers/rbac/v1", + "listers/rbac/v1alpha1", + "listers/rbac/v1beta1", + "listers/scheduling/v1alpha1", + "listers/settings/v1alpha1", + "listers/storage/v1", + "listers/storage/v1alpha1", + "listers/storage/v1beta1", + "pkg/apis/clientauthentication", + "pkg/apis/clientauthentication/v1alpha1", + "pkg/version", + "plugin/pkg/client/auth/exec", + "rest", + "rest/watch", + "testing", + "tools/auth", + "tools/cache", + "tools/clientcmd", + "tools/clientcmd/api", + "tools/clientcmd/api/latest", + "tools/clientcmd/api/v1", + "tools/metrics", + "tools/pager", + "tools/reference", + "transport", + "util/buffer", + "util/cert", + "util/flowcontrol", + "util/homedir", + "util/integer", + "util/retry" + ] + revision = "989be4278f353e42f26c416c53757d16fcff77db" + version = "kubernetes-1.10.1" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "a4f01f10991a3ea7164d4613c0c5f5eaebb206c83b1bf91d24c945e6d222a844" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/components/ui-api-layer/Gopkg.toml b/components/ui-api-layer/Gopkg.toml new file mode 100644 index 000000000000..e2aab8e0b91d --- /dev/null +++ b/components/ui-api-layer/Gopkg.toml @@ -0,0 +1,64 @@ +required = [ + "golang.org/x/tools/cmd/goimports", +] + +[[override]] + name = "golang.org/x/net" + branch = "release-branch.go1.10" + +[[constraint]] + name = "k8s.io/api" + version = "kubernetes-1.10.1" + +[[constraint]] + name = "k8s.io/apimachinery" + version = "kubernetes-1.10.1" + +[[constraint]] + name = "k8s.io/client-go" + version = "kubernetes-1.10.1" + +[[constraint]] + revision = "44145f04b68cf362d9c4df2182967c2275eaefed" + name = "github.com/golang/glog" + +[[constraint]] + name = "github.com/kubernetes-incubator/service-catalog" + version = "v0.1.3" + +[[constraint]] + name = "github.com/stretchr/testify" + version = "1.2.1" + +[[constraint]] + name = "github.com/rs/cors" + version = "1.3.0" + +[[constraint]] + name = "github.com/allegro/bigcache" + version = "1.1.0" + +[[constraint]] + name = "github.com/minio/minio-go" + version = "4.0.8" + +[[constraint]] + revision = "e1fd79fed15f60c47471d901c8250ab56aff1c55" + name = "github.com/vektah/gqlgen" + +[[constraint]] + name = "github.com/kubeless/kubeless" + version = "v0.4.0" + +[[constraint]] + name = "github.com/kyma-project/kyma" + branch = "master" + +[prune] + go-tests = true + unused-packages = true + non-go = true + + [[prune.project]] + name = "github.com/vektah/gqlgen" + unused-packages = false diff --git a/components/ui-api-layer/Jenkinsfile b/components/ui-api-layer/Jenkinsfile new file mode 100644 index 000000000000..e77be8445869 --- /dev/null +++ b/components/ui-api-layer/Jenkinsfile @@ -0,0 +1,88 @@ +#!/usr/bin/env groovy +def label = "kyma-${UUID.randomUUID().toString()}" +def application = 'ui-api-layer' +def isMaster = params.GIT_BRANCH == 'master' + +def dockerPushRoot = isMaster + ? "${env.DOCKER_REGISTRY}" + : "${env.DOCKER_REGISTRY}snapshot/" + +def dockerImageTag = isMaster + ? params.APP_VERSION + : params.GIT_BRANCH + +echo """ +******************************** +Job started with the following parameters: +DOCKER_REGISTRY=${env.DOCKER_REGISTRY} +DOCKER_CREDENTIALS=${env.DOCKER_CREDENTIALS} +GIT_REVISION=${params.GIT_REVISION} +GIT_BRANCH=${params.GIT_BRANCH} +APP_VERSION=${params.APP_VERSION} +APP_FOLDER=${env.APP_FOLDER} +******************************** +""" + +podTemplate(label: label) { + node(label) { + try { + timestamps { + timeout(time:20, unit:"MINUTES") { + ansiColor('xterm') { + stage("setup") { + checkout scm + + if(dockerImageTag == ""){ + error("No version for docker tag defined, please set APP_VERSION parameter for master branch or GIT_BRANCH parameter for any branch") + } + + withCredentials([usernamePassword(credentialsId: env.DOCKER_CREDENTIALS, passwordVariable: 'pwd', usernameVariable: 'uname')]) { + sh "docker login -u $uname -p '$pwd' $env.DOCKER_REGISTRY" + } + } + + stage("install dependencies $application") { + execute("dep ensure -v -vendor-only") + } + + stage("build and test $application") { + execute("./before-commit.sh ci") + } + + if (isMaster) { + stage("IP scan $application (Sourceclear)") { + withCredentials([string(credentialsId: 'SRCCLR_API_TOKEN', variable: 'SRCCLR_API_TOKEN')]) { + execute("make scan","SRCCLR_API_TOKEN=$SRCCLR_API_TOKEN") + } + } + } + + stage("build image $application") { + dir(env.APP_FOLDER) { + sh "docker build -t $application:latest ." + } + } + + stage("push image $application") { + sh "docker tag ${application}:latest ${dockerPushRoot}${application}:${dockerImageTag}" + sh "docker push ${dockerPushRoot}${application}:${dockerImageTag}" + } + } + } + } + } catch (ex) { + echo "Got exception: ${ex}" + currentBuild.result = "FAILURE" + def body = "${currentBuild.currentResult} ${env.JOB_NAME}${env.BUILD_DISPLAY_NAME}: on branch: ${params.GIT_BRANCH}. See details: ${env.BUILD_URL}" + emailext body: body, recipientProviders: [[$class: 'DevelopersRecipientProvider'], [$class: 'CulpritsRecipientProvider'], [$class: 'RequesterRecipientProvider']], subject: "${currentBuild.currentResult}: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'" + } + } +} + +def execute(command, envs = '') { + def buildpack = 'golang-buildpack:0.0.8' + def repositoryName = 'kyma' + def envText = envs=='' ? '' : "--env $envs" + workDir = pwd() + sh "docker run --rm -v $workDir:/go/src/github.com/kyma-project/$repositoryName/ -w /go/src/github.com/kyma-project/$repositoryName/$env.APP_FOLDER $envText ${env.DOCKER_REGISTRY}$buildpack /bin/bash -c '$command'" +} diff --git a/components/ui-api-layer/LICENSE b/components/ui-api-layer/LICENSE new file mode 100644 index 000000000000..f6cd2bc8085f --- /dev/null +++ b/components/ui-api-layer/LICENSE @@ -0,0 +1,202 @@ + + 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/components/ui-api-layer/README.md b/components/ui-api-layer/README.md new file mode 100644 index 000000000000..fc6c58751161 --- /dev/null +++ b/components/ui-api-layer/README.md @@ -0,0 +1,99 @@ +# UI API Layer + +## Overview + +This project includes a server that exposes the GraphQL API for all Kyma UIs. It consumes the Kubernetes API using the K8S Go client. +This document describes how to use the application and how to develop new features in this project. + +> **NOTE:** The description of the application configuration, the project structure, the architecture, and other project-specific details are located in the [`docs`](./docs/README.md) directory. + +See the [GraphQL schema definition](internal/gqlschema/schema.graphql) file for the list of supported queries and mutations. + +## Prerequisites + +Use the following tools to set up the project: + +* [Go distribution](https://golang.org) +* [Docker](https://www.docker.com/) + +## Usage + +### Run a local version + +To run the application without building the binary, run this command: + +```bash +APP_KUBECONFIG_PATH=/Users/$USER/.kube/config APP_VERBOSE=true APP_CONTENT_ACCESS_KEY={accessKey} APP_CONTENT_SECRET_KEY={secretKey} APP_CONTENT_VERIFY_SSL=false APP_REMOTE_ENVIRONMENT_GATEWAY_INTEGRATION_NAMESPACE=kyma-integration APP_REMOTE_ENVIRONMENT_CONNECTOR_URL=http://dummy.url go run main.go +``` + +For the descriptions of the available environment variables, see the [Configuration](./docs/configuration.md) document. + +The service listens on port `3000`. Open `http://localhost:3000` to see the GraphQL console in your browser. + +### Use GraphQL console on cluster + +Before using the console on a cluster, set a valid token for all requests. Click the **Header** option at the bottom of the GraphQL console and paste this snippet: + +```json +{ + "Authorization": "Bearer {YOUR_BEARER_TOKEN}" +} +``` + +After you paste the custom HTTP header, reload the page. GraphQL console allows you to access the schema documentation and test queries, mutations, and subscriptions. + +### Build a production version + +To build the production Docker image, run this command: + +```bash +docker build {image_name}:{image_tag} +``` + +The variables are: + +* `{image_name}` - name of the output image (default: `ui-api-layer`) +* `{image_tag}` - tag of the output image (default: `latest`) + +## Development + +### Install dependencies + +This project uses `dep` as a dependency manager. To install all required dependencies, use the following command: +```bash +dep ensure -vendor-only +``` + +#### Generate code from GraphQL schema + +This project uses the [GQLGen](https://github.com/vektah/gqlgen) library, which improves development by generating code from the [GraphQL schema definition](internal/gqlschema/schema.graphql). + +1. Define types and their fields in `/internal/gqlschema/schema.graphql` using the [Schema Definition Language](https://graphql.org/learn/schema/). +1. Execute the `./codegen.sh` script to run the code generator. +1. Navigate to the `/internal/gqlschema/` directory. +1. Find newly generated methods in the `Resolvers` interface located in `./schema_gen.go`. +1. Implement resolvers in specific domains according to the project structure and rules in this guide. Use generated models from `./models_gen.go` in your business logic. Move them to a new file in the `gqlschema` package and include in the `./types.json` file, if you want to customize them. + +To use advanced features, such as custom scalars, read the [documentation](https://vektah.github.io/gqlgen/) of the used library. + +### Run tests + +To run all unit tests, execute the following command: + +```bash +go test ./... +``` + +To run acceptance tests outside the cluster and against the local UI API Layer, use this command to enable port forwarding for the Helm client: +```bash +kubectl port-forward $(kubectl get po -n kube-system | grep tiller | awk '{print $1}') 44134:44134 -n kube-system +``` + +Run acceptance tests using the following command: +```bash +KUBE_CONFIG=/Users/{your_username}/.kube/config go test ./... -tags=acceptance +``` + +### Verify the code + +To check if the code is correct and you can push it, run the `before-commit.sh` script. It builds the application, runs tests, checks the status of the vendored libraries, runs the static code analysis, and ensures that the formatting of the code is correct. diff --git a/components/ui-api-layer/before-commit.sh b/components/ui-api-layer/before-commit.sh new file mode 100755 index 000000000000..37ddb63f7c89 --- /dev/null +++ b/components/ui-api-layer/before-commit.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +readonly CI_FLAG=ci + +RED='\033[0;31m' +GREEN='\033[0;32m' +INVERTED='\033[7m' +NC='\033[0m' # No Color + +echo -e "${INVERTED}" +echo "USER: " + $USER +echo "PATH: " + $PATH +echo "GOPATH:" + $GOPATH +echo -e "${NC}" + +## +# GO BUILD +## +buildEnv="" +if [ "$1" == "$CI_FLAG" ]; then + # build binary statically + buildEnv="env CGO_ENABLED=0" +fi + +${buildEnv} go build -o ui-api-layer +goBuildResult=$? +rm ui-api-layer + +if [ ${goBuildResult} != 0 ]; then + echo -e "${RED}✗ go build${NC}\n$goBuildResult${NC}" + exit 1 +else echo -e "${GREEN}√ go build${NC}" +fi + +## +# DEP +## +echo "? dep status" +depResult=$(dep status -v) +if [ $? != 0 ] + then + echo -e "${RED}✗ dep status\n$depResult${NC}" + exit 1; + else echo -e "${GREEN}√ dep status${NC}" +fi + +## +# GO TEST +## +echo "? go test" +go test ./... +# Check if tests passed +if [ $? != 0 ]; + then + echo -e "${RED}✗ go test\n${NC}" + exit 1; + else echo -e "${GREEN}√ go test${NC}" +fi + +filesToCheck=$(find . -type f -name "*.go" | egrep -v "\/vendor\/|_*/automock/|_*/testdata/|/pkg\/|_*export_test.go") +# +# GO IMPORTS +# +go build -o goimports-vendored ./vendor/golang.org/x/tools/cmd/goimports +goImportsResult=$(echo "${filesToCheck}" | xargs -L1 ./goimports-vendored -w -l) +rm goimports-vendored + +if [ $(echo ${#goImportsResult}) != 0 ] + then + echo -e "${RED}✗ goimports ${NC}\n$goImportsResult${NC}" + exit 1; + else echo -e "${GREEN}√ goimports ${NC}" +fi + +# +# GO FMT +# +goFmtResult=$(echo "${filesToCheck}" | xargs -L1 go fmt) +if [ $(echo ${#goFmtResult}) != 0 ] + then + echo -e "${RED}✗ go fmt${NC}\n$goFmtResult${NC}" + exit 1; + else echo -e "${GREEN}√ go fmt${NC}" +fi + +# +# GO VET +# +goVetResult=$(echo "${filesToCheck}" | xargs -L1 go vet) +if [ $(echo ${#goVetResult}) != 0 ] + then + echo -e "${RED}✗ go vet${NC}\n$goVetResult${NC}" + exit 1; + else echo -e "${GREEN}√ go vet${NC}" +fi \ No newline at end of file diff --git a/components/ui-api-layer/codegen.sh b/components/ui-api-layer/codegen.sh new file mode 100755 index 000000000000..05305f2bb2b0 --- /dev/null +++ b/components/ui-api-layer/codegen.sh @@ -0,0 +1,19 @@ +#!/bin/sh +COLOR='\033[0;36m' +NO_COLOR='\033[0m' + +APP_NAME='gqlgen' +REPOSITORY="github.com/vektah/$APP_NAME" +TEMP_DIR="./temp_$APP_NAME" + +PACKAGE='gqlschema' +SCHEMA_DIR="./internal/$PACKAGE" + +echo "${COLOR}Building generator...${NO_COLOR}" +go build -o $TEMP_DIR/$APP_NAME ./vendor/$REPOSITORY + +echo "${COLOR}Generating code from GraphQL schema...${NO_COLOR}" +$TEMP_DIR/$APP_NAME -schema $SCHEMA_DIR/schema.graphql -typemap $SCHEMA_DIR/types.json -out $SCHEMA_DIR/schema_gen.go -models $SCHEMA_DIR/models_gen.go -package $PACKAGE + +echo "${COLOR}Cleaning up...${NO_COLOR}" +rm -rf $TEMP_DIR \ No newline at end of file diff --git a/components/ui-api-layer/contrib/examples/service-catalog-test-data.yaml b/components/ui-api-layer/contrib/examples/service-catalog-test-data.yaml new file mode 100644 index 000000000000..ec296b072176 --- /dev/null +++ b/components/ui-api-layer/contrib/examples/service-catalog-test-data.yaml @@ -0,0 +1,195 @@ +# This file defines objects which allows to make more complicated queries for ServiceBinding, ServiceBindingUsage. +# All objects are created in the `mordor` namespace. +# BEWARE: there is assumption that RemoteEnvironment `ec-prod` already exists. + +apiVersion: remoteenvironment.kyma.cx/v1alpha1 +kind: EnvironmentMapping +metadata: + # Instance of the EnvrionmentMapping enables + # Remote Environment in the given namespace. + # The name must be the same as the name of RemoteEnvironment. + name: ec-prod + namespace: mordor + +--- + +apiVersion: servicecatalog.k8s.io/v1beta1 +kind: ServiceInstance +metadata: + name: promotions-service + namespace: mordor +spec: + clusterServiceClassExternalName: promotions + clusterServicePlanExternalName: default + parameters: + ##### + # Additional parameters can be added here, + # which may be used by the service broker. + #### + zzz: zzz + +--- + +apiVersion: servicecatalog.k8s.io/v1beta1 +kind: ServiceInstance +metadata: + name: redis-instance + namespace: mordor +spec: + clusterServiceClassExternalName: redis + clusterServicePlanExternalName: micro + parameters: + ##### + # Additional parameters can be added here, + # which may be used by the service broker. + #### + zzz: zzz + +--- + +apiVersion: servicecatalog.k8s.io/v1beta1 +kind: ServiceBinding +metadata: + name: redis-binding-1 + namespace: mordor +spec: + instanceRef: + name: redis-instance + +--- + +apiVersion: servicecatalog.k8s.io/v1beta1 +kind: ServiceBinding +metadata: + name: redis-binding-2 + namespace: mordor +spec: + instanceRef: + name: redis-instance + +--- + +apiVersion: servicecatalog.k8s.io/v1beta1 +kind: ServiceBinding +metadata: + name: promotions-binding-1 + namespace: mordor +spec: + instanceRef: + name: promotions-service + +--- + +apiVersion: servicecatalog.kyma.cx/v1alpha1 +kind: ServiceBindingUsage +metadata: + name: nike-uses-redis-1 + namespace: mordor +spec: + serviceBindingRef: + name: redis-binding-1 + usedBy: + kind: Deployment + name: nike-app + +--- + +apiVersion: servicecatalog.kyma.cx/v1alpha1 +kind: ServiceBindingUsage +metadata: + name: adidas-uses-redis-2 + namespace: mordor +spec: + serviceBindingRef: + name: redis-binding-2 + usedBy: + kind: Deployment + name: adidas-app + +--- + +apiVersion: servicecatalog.kyma.cx/v1alpha1 +kind: ServiceBindingUsage +metadata: + name: adidas-uses-promotions-1 + namespace: mordor +spec: + serviceBindingRef: + name: promotions-binding-1 + usedBy: + kind: Deployment + name: adidas-app + +--- + +apiVersion: servicecatalog.kyma.cx/v1alpha1 +kind: ServiceBindingUsage +metadata: + name: reebok-uses-promotions-1 + namespace: mordor +spec: + serviceBindingRef: + name: promotions-binding-1 + usedBy: + kind: Deployment + name: reebok-app + +--- + +apiVersion: apps/v1beta1 +kind: Deployment +metadata: + name: adidas-app + namespace: mordor +spec: + replicas: 1 + template: + metadata: + labels: + app: printer + spec: + containers: + - name: printer + image: "alpine" + command: [ "/bin/sh", "-c", "--" ] + args: [ "while sleep 10; do printenv; done;" ] + +--- + +apiVersion: apps/v1beta1 +kind: Deployment +metadata: + name: nike-app + namespace: mordor +spec: + replicas: 1 + template: + metadata: + labels: + app: printer + spec: + containers: + - name: printer + image: "alpine" + command: [ "/bin/sh", "-c", "--" ] + args: [ "while sleep 10; do printenv; done;" ] + +--- + +apiVersion: apps/v1beta1 +kind: Deployment +metadata: + name: reebok-app + namespace: mordor +spec: + replicas: 1 + template: + metadata: + labels: + app: printer + spec: + containers: + - name: printer + image: "alpine" + command: [ "/bin/sh", "-c", "--" ] + args: [ "while sleep 10; do printenv; done;" ] diff --git a/components/ui-api-layer/docs/README.md b/components/ui-api-layer/docs/README.md new file mode 100644 index 000000000000..5ec3451135ca --- /dev/null +++ b/components/ui-api-layer/docs/README.md @@ -0,0 +1,9 @@ +# Documentation + +## Overview +This directory contains the following documents that relate to the project: + +- [Configuration](./configuration.md) lists environmental variables and other parameters to configure the application +- [Terminology](./terminology.md) defines general terms used in the project's documentation +- [Project Structure](./project-structure.md) describes the structure of this repository +- [Architecture](./architecture.md) presents the overall architecture of this application \ No newline at end of file diff --git a/components/ui-api-layer/docs/architecture.md b/components/ui-api-layer/docs/architecture.md new file mode 100644 index 000000000000..00f1a0fd111a --- /dev/null +++ b/components/ui-api-layer/docs/architecture.md @@ -0,0 +1,5 @@ +# Architecture + +The following diagram illustrates the overall architecture of this application: + +![Architecture](./assets/ui-api-layer-architecture.png) diff --git a/components/ui-api-layer/docs/assets/ui-api-layer-architecture.html b/components/ui-api-layer/docs/assets/ui-api-layer-architecture.html new file mode 100644 index 000000000000..8823c864e283 --- /dev/null +++ b/components/ui-api-layer/docs/assets/ui-api-layer-architecture.html @@ -0,0 +1,12 @@ + + + + +ui-api-layer-architecture.html + + + +

    + + + diff --git a/components/ui-api-layer/docs/assets/ui-api-layer-architecture.png b/components/ui-api-layer/docs/assets/ui-api-layer-architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..31a5dca822fce77e95ef7c9cc36c594f5913ef2a GIT binary patch literal 79140 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 Zq26mOYkzSt){6uFZPVVNm9AlX;_nEP;yC~S literal 0 HcmV?d00001 diff --git a/components/ui-api-layer/docs/configuration.md b/components/ui-api-layer/docs/configuration.md new file mode 100644 index 000000000000..65938c3e6a25 --- /dev/null +++ b/components/ui-api-layer/docs/configuration.md @@ -0,0 +1,37 @@ +# Configuration +This document describes configuration details of the application. + +## Environmental Variables +Use the following environment variables to configure the application: + +| Name | Required | Default | Description | +|------|----------|---------|-------------| +| APP_HOST | No | `127.0.0.1` | The host on which the HTTP server listens. | +| APP_PORT | No | `3000` | The port on which the HTTP server listens. | +| APP_ALLOWED_ORIGINS | No | `*` | Origins that have access to the HTTP server. Origins must be comma-separated list of strings. | +| APP_SERVER_TIMEOUT | No | `10s` | The period of time after which the system kills active requests and stops the server. | +| APP_VERBOSE | No | No | Show detailed logs in the application. | +| APP_KUBECONFIG_PATH | No | | The path to the `kubeconfig` file, needed for running an application outside of the cluster. | +| APP_INFORMER_RESYNC_PERIOD | No | `10m` | The period of time after which the system resynchronizes the informers. | +| APP_CONTENT_ADDRESS | No | `minio.kyma.local` | The address of the content storage server. | +| APP_CONTENT_PORT | No | `443` | The port on which the content storage server listens. | +| APP_CONTENT_ACCESS_KEY | Yes | | The access key required to sign in to the content storage server. | +| APP_CONTENT_SECRET_KEY | Yes | | The secret key required to sign in to the content storage server. | +| APP_CONTENT_BUCKET | No | `content` | The name of the bucket with the content. | +| APP_CONTENT_SECURE | No | `true` | Use HTTPS for the connection with the content storage server. | +| APP_CONTENT_EXTERNAL_ADDRESS | No | | The external address of the content storage server. If not set, the system uses the `APP_CONTENT_ADDRESS` variable. | +| APP_CONTENT_ASSETS_FOLDER | No | `assets` | The name of the `assets` folder. | +| APP_CONTENT_VERIFY_SSL | No | `true` | Ignore invalid SSL certificates. | +| APP_REMOTE_ENVIRONMENT_GATEWAY_STATUS_REFRESH_PERIOD | No | `15s` | The period of time after which the application refreshes the remote environment statuses. | +| APP_REMOTE_ENVIRONMENT_GATEWAY_STATUS_CALL_TIMEOUT | No | `500ms` | The timeout of the HTTP call status check. | +| APP_REMOTE_ENVIRONMENT_GATEWAY_INTEGRATION_NAMESPACE | Yes | | The namespace with gateway services. | +| APP_REMOTE_ENVIRONMENT_CONNECTOR_URL | Yes | | The address of the connector service. | +| APP_REMOTE_ENVIRONMENT_CONNECTOR_CALL_HTTP_TIMEOUT | No | `500ms` | The timeout of the HTTP call. | + +## Configure logger verbosity level +This application uses `glog` to log messages. Pass command line arguments described in the [glog.go](https://github.com/golang/glog/blob/master/glog.go) document to customize the log, such as log level and output. + +For example: +```bash +go run main.go --stderrthreshold=INFO -logtostderr=false +``` diff --git a/components/ui-api-layer/docs/project-structure.md b/components/ui-api-layer/docs/project-structure.md new file mode 100644 index 000000000000..b2d5ca6e814f --- /dev/null +++ b/components/ui-api-layer/docs/project-structure.md @@ -0,0 +1,33 @@ +# Project structure +> **NOTE:** These rules mention terms described in the [Terminology](./terminology.md) document. + +This repository has the following structure: + +``` + . + ├── internal # Internal packages + │ ├── gqlschema # GraphQL types and schema + │ │ ├── schema.graphql # GraphQL schema defined with Schema Definition Language + │ │ ├── models_gen.go # GraphQL structs (do not modify - autogenerated file) + │ │ ├── schema_gen.go # GraphQL schema (do not modify - autogenerated file) + │ │ ├── types.json # Custom types mapping + │ │ └── {CUSTOM_TYPE}.go # Custom types definition + │ ├── domain # Business logic and resolvers split by domains + │ │ ├── {DOMAIN_NAME} # Domain name directory + │ │ │ ├── automock # Generated interface mocks + │ │ │ ├── {RESOURCE}_resolver.go # Resolver type for a specific resource + │ │ │ ├── {RESOURCE}_service.go # Business logic for a specific resource + │ │ │ ├── {RESOURCE}_converter.go # Type conversion for a specific resource + │ │ │ └── {DOMAIN_NAME}.go # The main domain file that exports the `Resolver` composed by resource resolvers + │ │ └── root_resolver.go # Type composed of all resolvers from all domains + │ └── ... # Other internal packages + ├── contrib # Examples, non-essential source files, configurations and other files + │ └── examples # Example API resources + ├── acceptance # Acceptance test setup + ├── pkg # All generic utilities + ├── vendor # Dependencies managed by Dep + ├── main.go # The main entrypoint of the application + ├── Gopkg.toml # Dep manifest + └── Gopkg.lock # Dep lock (do not modify - autogenerated file) + +``` \ No newline at end of file diff --git a/components/ui-api-layer/docs/terminology.md b/components/ui-api-layer/docs/terminology.md new file mode 100644 index 000000000000..6372a366bfd3 --- /dev/null +++ b/components/ui-api-layer/docs/terminology.md @@ -0,0 +1,7 @@ +# Terminology + +The project documentation mentions different types of resolvers. + +- **Resolver** is a term related to GraphQL itself. It is a function that defines what data to return for a specific [field](https://graphql.org/learn/queries/#fields). +- **Resolver type** is a custom type named `Resolver` that contains methods, which essentially are resolver functions. +- **Resource resolver** is a resolver type that contains methods related to the specific resource. diff --git a/components/ui-api-layer/internal/domain/apicontroller/api_converter.go b/components/ui-api-layer/internal/domain/apicontroller/api_converter.go new file mode 100644 index 000000000000..69e0c28dac67 --- /dev/null +++ b/components/ui-api-layer/internal/domain/apicontroller/api_converter.go @@ -0,0 +1,53 @@ +package apicontroller + +import ( + "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +) + +type apiConverter struct{} + +func (ac *apiConverter) ToGQL(in *v1alpha2.Api) *gqlschema.API { + if in == nil { + return nil + } + + var authenticationPolicies []gqlschema.AuthenticationPolicy + for _, policy := range in.Spec.Authentication { + + authenticationPolicies = append(authenticationPolicies, gqlschema.AuthenticationPolicy{ + Type: ac.parseAuthenticationPolicyType(&policy.Type), + Issuer: policy.Jwt.Issuer, + JwksURI: policy.Jwt.JwksUri, + }) + } + + return &gqlschema.API{ + Name: in.Name, + Hostname: in.Spec.Hostname, + Service: gqlschema.Service{ + Name: in.Spec.Service.Name, + Port: in.Spec.Service.Port, + }, + AuthenticationPolicies: authenticationPolicies, + } +} + +func (c *apiConverter) ToGQLs(in []*v1alpha2.Api) []gqlschema.API { + var result []gqlschema.API + for _, item := range in { + converted := c.ToGQL(item) + + if converted != nil { + result = append(result, *converted) + } + } + + return result +} + +func (c *apiConverter) parseAuthenticationPolicyType(in *v1alpha2.AuthenticationType) gqlschema.AuthenticationPolicyType { + + //Map everything to JWT type as for now, there is only one type + return gqlschema.AuthenticationPolicyTypeJwt +} diff --git a/components/ui-api-layer/internal/domain/apicontroller/api_converter_test.go b/components/ui-api-layer/internal/domain/apicontroller/api_converter_test.go new file mode 100644 index 000000000000..3a9374220f09 --- /dev/null +++ b/components/ui-api-layer/internal/domain/apicontroller/api_converter_test.go @@ -0,0 +1,105 @@ +package apicontroller + +import ( + "testing" + + "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestApiConverter_ToGQL(t *testing.T) { + t.Run("API definition given", func(t *testing.T) { + api := fixApi("test") + + expected := &gqlschema.API{ + Name: api.Name, + Hostname: api.Spec.Hostname, + Service: gqlschema.Service{ + Name: api.Spec.Service.Name, + Port: api.Spec.Service.Port, + }, + AuthenticationPolicies: []gqlschema.AuthenticationPolicy{ + { + Type: gqlschema.AuthenticationPolicyTypeJwt, + Issuer: api.Spec.Authentication[0].Jwt.Issuer, + JwksURI: api.Spec.Authentication[0].Jwt.JwksUri, + }, + }, + } + + converter := apiConverter{} + result := converter.ToGQL(api) + + assert.Equal(t, expected, result) + }) + + t.Run("Nil given", func(t *testing.T) { + converter := apiConverter{} + result := converter.ToGQL(nil) + + require.Nil(t, result) + }) +} + +func TestApiConverter_ToGQLs(t *testing.T) { + t.Run("An array of APIs given", func(t *testing.T) { + apis := []*v1alpha2.Api{ + fixApi("test-1"), + fixApi("test-2"), + } + + converter := apiConverter{} + result := converter.ToGQLs(apis) + + assert.Equal(t, len(apis), len(result)) + assert.NotEqual(t, apis[0].Name, apis[1].Name) + }) + + t.Run("An array of APIs with nil given", func(t *testing.T) { + apis := []*v1alpha2.Api{ + fixApi("test-1"), + nil, + } + + converter := apiConverter{} + result := converter.ToGQLs(apis) + + assert.Equal(t, len(apis)-1, len(result)) + }) + + t.Run("An empty array given", func(t *testing.T) { + apis := []*v1alpha2.Api{} + + converter := apiConverter{} + result := converter.ToGQLs(apis) + + assert.Empty(t, result) + }) +} + +func fixApi(name string) *v1alpha2.Api { + return &v1alpha2.Api{ + ObjectMeta: v1.ObjectMeta{ + Name: name, + }, + Spec: v1alpha2.ApiSpec{ + Hostname: "test-service.dev.kyma.cx", + Service: v1alpha2.Service{ + Name: "test-service", + Port: 8080, + }, + Authentication: v1alpha2.Authentication{ + { + Type: v1alpha2.JwtType, + Jwt: v1alpha2.JwtAuthentication{ + Issuer: "sample-issuer", + JwksUri: "http://sample-issuer/keys", + }, + }, + }, + }, + } +} diff --git a/components/ui-api-layer/internal/domain/apicontroller/api_resolver.go b/components/ui-api-layer/internal/domain/apicontroller/api_resolver.go new file mode 100644 index 000000000000..790ffdac4bc9 --- /dev/null +++ b/components/ui-api-layer/internal/domain/apicontroller/api_resolver.go @@ -0,0 +1,32 @@ +package apicontroller + +import ( + "context" + "fmt" + + "github.com/golang/glog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/pkg/errors" +) + +type apiResolver struct { + apiLister apiLister + apiConverter apiConverter +} + +func newApiResolver(lister apiLister) *apiResolver { + return &apiResolver{ + apiLister: lister, + apiConverter: apiConverter{}, + } +} + +func (ar *apiResolver) APIsQuery(ctx context.Context, environment string, serviceName *string, hostname *string) ([]gqlschema.API, error) { + apis, err := ar.apiLister.List(environment, serviceName, hostname) + if err != nil { + glog.Error(errors.Wrapf(err, "while listing APIs for service name %s, hostname %s", serviceName, hostname)) + return nil, fmt.Errorf("cannot query APIs") + } + + return ar.apiConverter.ToGQLs(apis), nil +} diff --git a/components/ui-api-layer/internal/domain/apicontroller/api_resolver_test.go b/components/ui-api-layer/internal/domain/apicontroller/api_resolver_test.go new file mode 100644 index 000000000000..21f85ed5a63e --- /dev/null +++ b/components/ui-api-layer/internal/domain/apicontroller/api_resolver_test.go @@ -0,0 +1,69 @@ +package apicontroller + +import ( + "errors" + "testing" + + "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/apicontroller/automock" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestApiResolver_APIsQuery(t *testing.T) { + environment := "test-1" + + t.Run("Should return a list of APIs", func(t *testing.T) { + apis := []*v1alpha2.Api{ + { + ObjectMeta: v1.ObjectMeta{ + Name: "test-1", + }, + }, + { + ObjectMeta: v1.ObjectMeta{ + Name: "test-2", + }, + }, + } + + expected := []gqlschema.API{ + { + Name: apis[0].Name, + }, + { + Name: apis[1].Name, + }, + } + + var empty *string = nil + + service := automock.NewApiLister() + service.On("List", environment, empty, empty).Return(apis, nil).Once() + + resolver := newApiResolver(service) + + result, err := resolver.APIsQuery(nil, environment, nil, nil) + + service.AssertExpectations(t) + require.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("Should return an error", func(t *testing.T) { + var empty *string = nil + + service := automock.NewApiLister() + service.On("List", environment, empty, empty).Return(nil, errors.New("test")).Once() + + resolver := newApiResolver(service) + + _, err := resolver.APIsQuery(nil, environment, nil, nil) + + service.AssertExpectations(t) + require.Error(t, err) + assert.Equal(t, "cannot query APIs", err.Error()) + }) +} diff --git a/components/ui-api-layer/internal/domain/apicontroller/api_service.go b/components/ui-api-layer/internal/domain/apicontroller/api_service.go new file mode 100644 index 000000000000..cceb634092f3 --- /dev/null +++ b/components/ui-api-layer/internal/domain/apicontroller/api_service.go @@ -0,0 +1,52 @@ +package apicontroller + +import ( + "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2" + "k8s.io/client-go/tools/cache" + + "fmt" +) + +type apiService struct { + informer cache.SharedIndexInformer +} + +func newApiService(informer cache.SharedIndexInformer) *apiService { + return &apiService{ + informer: informer, + } +} + +func (svc *apiService) List(environment string, serviceName *string, hostname *string) ([]*v1alpha2.Api, error) { + items, err := svc.informer.GetIndexer().ByIndex("namespace", environment) + if err != nil { + return nil, err + } + + var apis []*v1alpha2.Api + for _, item := range items { + + api, ok := item.(*v1alpha2.Api) + if !ok { + return nil, fmt.Errorf("incorrect item type: %T, should be: *Api", item) + } + + match := true + if serviceName != nil { + if *serviceName != api.Spec.Service.Name { + match = false + } + } + if hostname != nil { + if *hostname != api.Spec.Hostname { + match = false + } + } + + if match { + apis = append(apis, api) + } + } + + return apis, nil +} diff --git a/components/ui-api-layer/internal/domain/apicontroller/api_service_test.go b/components/ui-api-layer/internal/domain/apicontroller/api_service_test.go new file mode 100644 index 000000000000..b27f9fecf247 --- /dev/null +++ b/components/ui-api-layer/internal/domain/apicontroller/api_service_test.go @@ -0,0 +1,122 @@ +package apicontroller + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/cache" + + testingUtils "github.com/kyma-project/kyma/components/ui-api-layer/internal/testing" + + "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2" + "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned/fake" + "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions" + + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestApiService_List(t *testing.T) { + t.Run("Should filter by environment", func(t *testing.T) { + api1 := fixAPIWith("test-1", "test-1", "", "") + api2 := fixAPIWith("test-1", "test-2", "", "") + api3 := fixAPIWith("test-2", "test-1", "", "") + + informer := fixAPIInformer(api1, api2, api3) + service := newApiService(informer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + + result, err := service.List("test-1", nil, nil) + + require.NoError(t, err) + assert.Equal(t, []*v1alpha2.Api{ + api1, api3, + }, result) + }) + + t.Run("Should filter by environment and hostname", func(t *testing.T) { + hostname := "abc" + + api1 := fixAPIWith("test-1", "test-1", hostname, "") + api2 := fixAPIWith("test-1", "test-2", hostname, "") + api3 := fixAPIWith("test-2", "test-1", "cba", "") + + informer := fixAPIInformer(api1, api2, api3) + service := newApiService(informer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + + result, err := service.List("test-1", nil, &hostname) + + require.NoError(t, err) + assert.Equal(t, []*v1alpha2.Api{ + api1, + }, result) + }) + + t.Run("Should filter by environment and serviceName", func(t *testing.T) { + serviceName := "abc" + + api1 := fixAPIWith("test-2", "test-1", "", serviceName) + api2 := fixAPIWith("test-3", "test-2", "", serviceName) + api3 := fixAPIWith("test-4", "test-1", "", "cba") + + informer := fixAPIInformer(api1, api2, api3) + service := newApiService(informer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + + result, err := service.List("test-1", &serviceName, nil) + + require.NoError(t, err) + assert.Equal(t, []*v1alpha2.Api{ + api1, + }, result) + }) + + t.Run("Should filter by environment serviceName and hostname", func(t *testing.T) { + serviceName := "abc" + hostname := "cba" + + api1 := fixAPIWith("test-4", "test-1", hostname, serviceName) + api2 := fixAPIWith("test-5", "test-2", hostname, serviceName) + api3 := fixAPIWith("test-6", "test-1", hostname, "cba") + + informer := fixAPIInformer(api1, api2, api3) + service := newApiService(informer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + + result, err := service.List("test-1", &serviceName, nil) + + require.NoError(t, err) + assert.Equal(t, []*v1alpha2.Api{ + api1, + }, result) + }) +} + +func fixAPIWith(name, environment, hostname, serviceName string) *v1alpha2.Api { + return &v1alpha2.Api{ + ObjectMeta: v1.ObjectMeta{ + Name: name, + Namespace: environment, + }, + Spec: v1alpha2.ApiSpec{ + Hostname: hostname, + Service: v1alpha2.Service{ + Name: serviceName, + }, + }, + } +} + +func fixAPIInformer(objects ...runtime.Object) cache.SharedIndexInformer { + client := fake.NewSimpleClientset(objects...) + informerFactory := externalversions.NewSharedInformerFactory(client, 10) + + return informerFactory.Gateway().V1alpha2().Apis().Informer() +} diff --git a/components/ui-api-layer/internal/domain/apicontroller/apicontroller.go b/components/ui-api-layer/internal/domain/apicontroller/apicontroller.go new file mode 100644 index 000000000000..8b4c16d50808 --- /dev/null +++ b/components/ui-api-layer/internal/domain/apicontroller/apicontroller.go @@ -0,0 +1,38 @@ +package apicontroller + +import ( + "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/clientset/versioned" + "github.com/kyma-project/kyma/components/api-controller/pkg/clients/gateway.kyma.cx/informers/externalversions" + "k8s.io/client-go/rest" + + "time" + + "github.com/pkg/errors" +) + +type Resolver struct { + *apiResolver + + informerFactory externalversions.SharedInformerFactory +} + +func New(restConfig *rest.Config, informerResyncPeriod time.Duration) (*Resolver, error) { + client, err := versioned.NewForConfig(restConfig) + if err != nil { + return nil, errors.Wrap(err, "while initializing clientset") + } + + informerFactory := externalversions.NewSharedInformerFactory(client, informerResyncPeriod) + service := newApiService(informerFactory.Gateway().V1alpha2().Apis().Informer()) + + return &Resolver{ + + apiResolver: newApiResolver(service), + informerFactory: informerFactory, + }, nil +} + +func (r *Resolver) WaitForCacheSync(stopCh <-chan struct{}) { + r.informerFactory.Start(stopCh) + r.informerFactory.WaitForCacheSync(stopCh) +} diff --git a/components/ui-api-layer/internal/domain/apicontroller/automock/api_lister.go b/components/ui-api-layer/internal/domain/apicontroller/automock/api_lister.go new file mode 100644 index 000000000000..e2e73fa173d8 --- /dev/null +++ b/components/ui-api-layer/internal/domain/apicontroller/automock/api_lister.go @@ -0,0 +1,33 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import v1alpha2 "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2" + +// apiLister is an autogenerated mock type for the apiLister type +type apiLister struct { + mock.Mock +} + +// List provides a mock function with given fields: environment, serviceName, hostname +func (_m *apiLister) List(environment string, serviceName *string, hostname *string) ([]*v1alpha2.Api, error) { + ret := _m.Called(environment, serviceName, hostname) + + var r0 []*v1alpha2.Api + if rf, ok := ret.Get(0).(func(string, *string, *string) []*v1alpha2.Api); ok { + r0 = rf(environment, serviceName, hostname) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1alpha2.Api) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, *string, *string) error); ok { + r1 = rf(environment, serviceName, hostname) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/apicontroller/automock/export.go b/components/ui-api-layer/internal/domain/apicontroller/automock/export.go new file mode 100644 index 000000000000..9bb529a61988 --- /dev/null +++ b/components/ui-api-layer/internal/domain/apicontroller/automock/export.go @@ -0,0 +1,5 @@ +package automock + +func NewApiLister() *apiLister { + return new(apiLister) +} diff --git a/components/ui-api-layer/internal/domain/apicontroller/interfaces.go b/components/ui-api-layer/internal/domain/apicontroller/interfaces.go new file mode 100644 index 000000000000..5788aecd05c8 --- /dev/null +++ b/components/ui-api-layer/internal/domain/apicontroller/interfaces.go @@ -0,0 +1,10 @@ +package apicontroller + +import ( + "github.com/kyma-project/kyma/components/api-controller/pkg/apis/gateway.kyma.cx/v1alpha2" +) + +//go:generate mockery -name=apiLister -output=automock -outpkg=automock -case=underscore +type apiLister interface { + List(environment string, serviceName *string, hostname *string) ([]*v1alpha2.Api, error) +} diff --git a/components/ui-api-layer/internal/domain/content/apispec_service.go b/components/ui-api-layer/internal/domain/content/apispec_service.go new file mode 100644 index 000000000000..cc9ef964121f --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/apispec_service.go @@ -0,0 +1,27 @@ +package content + +import ( + "fmt" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" +) + +type apiSpecService struct { + storage minioApiSpecGetter +} + +func newApiSpecService(storage minioApiSpecGetter) *apiSpecService { + return &apiSpecService{ + storage: storage, + } +} + +func (svc *apiSpecService) Find(kind, id string) (*storage.ApiSpec, error) { + key := fmt.Sprintf("%s/%s", kind, id) + apiSpec, exists, err := svc.storage.ApiSpec(key) + if !exists || err != nil { + return nil, err + } + + return apiSpec, err +} diff --git a/components/ui-api-layer/internal/domain/content/apispec_service_test.go b/components/ui-api-layer/internal/domain/content/apispec_service_test.go new file mode 100644 index 000000000000..635feffce9e2 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/apispec_service_test.go @@ -0,0 +1,61 @@ +package content_test + +import ( + "testing" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/automock" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApiSpecService_Find(t *testing.T) { + t.Run("Success", func(t *testing.T) { + getter := automock.NewMinioApiSpecGetter() + getter.On("ApiSpec", "test/id").Return(fixApiSpec(), true, nil) + defer getter.AssertExpectations(t) + + svc := content.NewApiSpecService(getter) + + result, err := svc.Find("test", "id") + + require.NoError(t, err) + assert.Equal(t, fixApiSpec(), result) + }) + + t.Run("Not found", func(t *testing.T) { + getter := automock.NewMinioApiSpecGetter() + getter.On("ApiSpec", "test/id").Return(nil, false, nil) + defer getter.AssertExpectations(t) + + svc := content.NewApiSpecService(getter) + + result, err := svc.Find("test", "id") + + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("Error", func(t *testing.T) { + getter := automock.NewMinioApiSpecGetter() + getter.On("ApiSpec", "test/id").Return(nil, false, errors.New("nope")) + defer getter.AssertExpectations(t) + + svc := content.NewApiSpecService(getter) + + _, err := svc.Find("test", "id") + + require.Error(t, err) + }) +} + +func fixApiSpec() *storage.ApiSpec { + return &storage.ApiSpec{ + Raw: map[string]interface{}{ + "kind": "trololo", + "name": "nope", + }, + } +} diff --git a/components/ui-api-layer/internal/domain/content/asyncapispec_service.go b/components/ui-api-layer/internal/domain/content/asyncapispec_service.go new file mode 100644 index 000000000000..7078eb25490e --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/asyncapispec_service.go @@ -0,0 +1,27 @@ +package content + +import ( + "fmt" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" +) + +type asyncApiSpecService struct { + storage minioAsyncApiSpecGetter +} + +func newAsyncApiSpecService(storage minioAsyncApiSpecGetter) *asyncApiSpecService { + return &asyncApiSpecService{ + storage: storage, + } +} + +func (svc *asyncApiSpecService) Find(kind, id string) (*storage.AsyncApiSpec, error) { + key := fmt.Sprintf("%s/%s", kind, id) + asyncApiSpec, exists, err := svc.storage.AsyncApiSpec(key) + if !exists || err != nil { + return nil, err + } + + return asyncApiSpec, err +} diff --git a/components/ui-api-layer/internal/domain/content/asyncapispec_service_test.go b/components/ui-api-layer/internal/domain/content/asyncapispec_service_test.go new file mode 100644 index 000000000000..99edf54cc27a --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/asyncapispec_service_test.go @@ -0,0 +1,63 @@ +package content_test + +import ( + "testing" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/automock" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAsyncApiSpecService_Find(t *testing.T) { + t.Run("Success", func(t *testing.T) { + getter := automock.NewMinioAsyncApiSpecGetter() + getter.On("AsyncApiSpec", "test/id").Return(fixAsyncApiSpec(), true, nil) + defer getter.AssertExpectations(t) + + svc := content.NewAsyncApiSpecService(getter) + + result, err := svc.Find("test", "id") + + require.NoError(t, err) + assert.Equal(t, fixAsyncApiSpec(), result) + }) + + t.Run("Not found", func(t *testing.T) { + getter := automock.NewMinioAsyncApiSpecGetter() + getter.On("AsyncApiSpec", "test/id").Return(nil, false, nil) + defer getter.AssertExpectations(t) + + svc := content.NewAsyncApiSpecService(getter) + + result, err := svc.Find("test", "id") + + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("Error", func(t *testing.T) { + getter := automock.NewMinioAsyncApiSpecGetter() + getter.On("AsyncApiSpec", "test/id").Return(nil, false, errors.New("nope")) + defer getter.AssertExpectations(t) + + svc := content.NewAsyncApiSpecService(getter) + + _, err := svc.Find("test", "id") + + require.Error(t, err) + }) +} + +func fixAsyncApiSpec() *storage.AsyncApiSpec { + return &storage.AsyncApiSpec{ + Raw: map[string]interface{}{ + "asyncapi": "0.0.1", + }, + Data: storage.AsyncApiSpecData{ + AsyncAPI: "0.0.1", + }, + } +} diff --git a/components/ui-api-layer/internal/domain/content/automock/content_getter.go b/components/ui-api-layer/internal/domain/content/automock/content_getter.go new file mode 100644 index 000000000000..ad0ec349cf61 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/automock/content_getter.go @@ -0,0 +1,33 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import storage "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + +// contentGetter is an autogenerated mock type for the contentGetter type +type contentGetter struct { + mock.Mock +} + +// Find provides a mock function with given fields: kind, id +func (_m *contentGetter) Find(kind string, id string) (*storage.Content, error) { + ret := _m.Called(kind, id) + + var r0 *storage.Content + if rf, ok := ret.Get(0).(func(string, string) *storage.Content); ok { + r0 = rf(kind, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*storage.Content) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(kind, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/content/automock/export.go b/components/ui-api-layer/internal/domain/content/automock/export.go new file mode 100644 index 000000000000..e63673a2bf55 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/automock/export.go @@ -0,0 +1,21 @@ +package automock + +func NewContentGetter() *contentGetter { + return new(contentGetter) +} + +func NewMinioContentGetter() *minioContentGetter { + return new(minioContentGetter) +} + +func NewMinioAsyncApiSpecGetter() *minioAsyncApiSpecGetter { + return new(minioAsyncApiSpecGetter) +} + +func NewMinioApiSpecGetter() *minioApiSpecGetter { + return new(minioApiSpecGetter) +} + +func NewMockTopicsConverter() *topicsConverterInterface { + return new(topicsConverterInterface) +} diff --git a/components/ui-api-layer/internal/domain/content/automock/minio_api_spec_getter.go b/components/ui-api-layer/internal/domain/content/automock/minio_api_spec_getter.go new file mode 100644 index 000000000000..b8ea7a50cf0b --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/automock/minio_api_spec_getter.go @@ -0,0 +1,40 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import storage "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + +// minioApiSpecGetter is an autogenerated mock type for the minioApiSpecGetter type +type minioApiSpecGetter struct { + mock.Mock +} + +// ApiSpec provides a mock function with given fields: id +func (_m *minioApiSpecGetter) ApiSpec(id string) (*storage.ApiSpec, bool, error) { + ret := _m.Called(id) + + var r0 *storage.ApiSpec + if rf, ok := ret.Get(0).(func(string) *storage.ApiSpec); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*storage.ApiSpec) + } + } + + var r1 bool + if rf, ok := ret.Get(1).(func(string) bool); ok { + r1 = rf(id) + } else { + r1 = ret.Get(1).(bool) + } + + var r2 error + if rf, ok := ret.Get(2).(func(string) error); ok { + r2 = rf(id) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} diff --git a/components/ui-api-layer/internal/domain/content/automock/minio_async_api_spec_getter.go b/components/ui-api-layer/internal/domain/content/automock/minio_async_api_spec_getter.go new file mode 100644 index 000000000000..e9f028472776 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/automock/minio_async_api_spec_getter.go @@ -0,0 +1,40 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import storage "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + +// minioAsyncApiSpecGetter is an autogenerated mock type for the minioAsyncApiSpecGetter type +type minioAsyncApiSpecGetter struct { + mock.Mock +} + +// AsyncApiSpec provides a mock function with given fields: id +func (_m *minioAsyncApiSpecGetter) AsyncApiSpec(id string) (*storage.AsyncApiSpec, bool, error) { + ret := _m.Called(id) + + var r0 *storage.AsyncApiSpec + if rf, ok := ret.Get(0).(func(string) *storage.AsyncApiSpec); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*storage.AsyncApiSpec) + } + } + + var r1 bool + if rf, ok := ret.Get(1).(func(string) bool); ok { + r1 = rf(id) + } else { + r1 = ret.Get(1).(bool) + } + + var r2 error + if rf, ok := ret.Get(2).(func(string) error); ok { + r2 = rf(id) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} diff --git a/components/ui-api-layer/internal/domain/content/automock/minio_content_getter.go b/components/ui-api-layer/internal/domain/content/automock/minio_content_getter.go new file mode 100644 index 000000000000..9c3028538af6 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/automock/minio_content_getter.go @@ -0,0 +1,40 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import storage "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + +// minioContentGetter is an autogenerated mock type for the minioContentGetter type +type minioContentGetter struct { + mock.Mock +} + +// Content provides a mock function with given fields: id +func (_m *minioContentGetter) Content(id string) (*storage.Content, bool, error) { + ret := _m.Called(id) + + var r0 *storage.Content + if rf, ok := ret.Get(0).(func(string) *storage.Content); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*storage.Content) + } + } + + var r1 bool + if rf, ok := ret.Get(1).(func(string) bool); ok { + r1 = rf(id) + } else { + r1 = ret.Get(1).(bool) + } + + var r2 error + if rf, ok := ret.Get(2).(func(string) error); ok { + r2 = rf(id) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} diff --git a/components/ui-api-layer/internal/domain/content/automock/topics_converter_interface.go b/components/ui-api-layer/internal/domain/content/automock/topics_converter_interface.go new file mode 100644 index 000000000000..5b5f7c358f94 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/automock/topics_converter_interface.go @@ -0,0 +1,50 @@ +// Code generated by mockery v1.0.0 +package automock + +import gqlschema "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +import mock "github.com/stretchr/testify/mock" +import storage "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + +// topicsConverterInterface is an autogenerated mock type for the topicsConverterInterface type +type topicsConverterInterface struct { + mock.Mock +} + +// ExtractSection provides a mock function with given fields: documents, internal +func (_m *topicsConverterInterface) ExtractSection(documents []storage.Document, internal bool) ([]gqlschema.Section, error) { + ret := _m.Called(documents, internal) + + var r0 []gqlschema.Section + if rf, ok := ret.Get(0).(func([]storage.Document, bool) []gqlschema.Section); ok { + r0 = rf(documents, internal) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gqlschema.Section) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func([]storage.Document, bool) error); ok { + r1 = rf(documents, internal) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ToGQL provides a mock function with given fields: in +func (_m *topicsConverterInterface) ToGQL(in []gqlschema.TopicEntry) *gqlschema.JSON { + ret := _m.Called(in) + + var r0 *gqlschema.JSON + if rf, ok := ret.Get(0).(func([]gqlschema.TopicEntry) *gqlschema.JSON); ok { + r0 = rf(in) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*gqlschema.JSON) + } + } + + return r0 +} diff --git a/components/ui-api-layer/internal/domain/content/content.go b/components/ui-api-layer/internal/domain/content/content.go new file mode 100644 index 000000000000..1a0973195736 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/content.go @@ -0,0 +1,103 @@ +package content + +import ( + "crypto/tls" + "fmt" + "net/http" + "time" + + "github.com/allegro/bigcache" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + "github.com/minio/minio-go" +) + +type AsyncApiSpecGetter interface { + Find(kind, id string) (*storage.AsyncApiSpec, error) +} + +type ApiSpecGetter interface { + Find(kind, id string) (*storage.ApiSpec, error) +} + +type ContentGetter interface { + Find(kind, id string) (*storage.Content, error) +} + +type Config struct { + Address string `envconfig:"default=minio.kyma.local"` + Port int `envconfig:"default=443"` + AccessKey string + SecretKey string + Bucket string `envconfig:"default=content"` + Secure bool `envconfig:"default=true"` + ExternalAddress string `envconfig:"optional"` + AssetsFolder string `envconfig:"default=assets"` + VerifySSL bool `envconfig:"default=true"` +} + +type Resolver struct { + *contentResolver + *topicsResolver + storage storage.Service +} + +type Container struct { + Resolver *Resolver + ApiSpecGetter ApiSpecGetter + AsyncApiSpecGetter AsyncApiSpecGetter + ContentGetter ContentGetter +} + +func New(cfg Config) (*Container, error) { + minioClient, err := minio.New(fmt.Sprintf("%s:%d", cfg.Address, cfg.Port), cfg.AccessKey, cfg.SecretKey, cfg.Secure) + if err != nil { + return nil, err + } + + if !cfg.VerifySSL { + transCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // ignore invalid SSL certificates + } + + minioClient.SetCustomTransport(transCfg) + } + + cacheConfig := bigcache.DefaultConfig(24 * time.Hour) + cacheConfig.Shards = 2 + cacheConfig.MaxEntriesInWindow = 60 + cacheConfig.HardMaxCacheSize = 10 + cache, err := bigcache.NewBigCache(cacheConfig) + if err != nil { + return nil, err + } + + externalAddress := cfg.ExternalAddress + if externalAddress == "" { + protocol := "http" + if cfg.Secure { + protocol = protocol + "s" + } + + externalAddress = fmt.Sprintf("%s://%s", protocol, cfg.Address) + } + storageSvc := storage.New(minioClient, cache, cfg.Bucket, externalAddress, cfg.AssetsFolder) + + asynApiSpecSvc := newAsyncApiSpecService(storageSvc) + apiSpecSvc := newApiSpecService(storageSvc) + contentSvc := newContentService(storageSvc) + + return &Container{ + ApiSpecGetter: apiSpecSvc, + AsyncApiSpecGetter: asynApiSpecSvc, + ContentGetter: contentSvc, + Resolver: &Resolver{ + contentResolver: newContentResolver(contentSvc), + topicsResolver: newTopicsResolver(contentSvc), + storage: storageSvc, + }, + }, nil +} + +func (r *Resolver) WaitForCacheSync(stopCh <-chan struct{}) { + r.storage.Initialize(stopCh) +} diff --git a/components/ui-api-layer/internal/domain/content/content_converter.go b/components/ui-api-layer/internal/domain/content/content_converter.go new file mode 100644 index 000000000000..cf1db17ffe5c --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/content_converter.go @@ -0,0 +1,21 @@ +package content + +import ( + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +) + +type contentConverter struct{} + +func (c *contentConverter) ToGQL(in *storage.Content) *gqlschema.JSON { + if in == nil { + return nil + } + + result := make(gqlschema.JSON) + for k, v := range in.Raw { + result[k] = v + } + + return &result +} diff --git a/components/ui-api-layer/internal/domain/content/content_converter_test.go b/components/ui-api-layer/internal/domain/content/content_converter_test.go new file mode 100644 index 000000000000..78a24facece2 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/content_converter_test.go @@ -0,0 +1,44 @@ +package content + +import ( + "testing" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" +) + +func TestContentConverter_ToGQL(t *testing.T) { + t.Run("Success", func(t *testing.T) { + content := &storage.Content{ + Raw: map[string]interface{}{ + "test": "data", + "tree": map[string]interface{}{ + "treeTest": "treeData", + }, + }, + } + + converter := &contentConverter{} + + result := converter.ToGQL(content) + assert.Equal(t, &gqlschema.JSON{ + "test": "data", + "tree": map[string]interface{}{ + "treeTest": "treeData", + }, + }, result) + }) + + t.Run("Empty", func(t *testing.T) { + converter := &contentConverter{} + converter.ToGQL(&storage.Content{}) + }) + + t.Run("Nil", func(t *testing.T) { + converter := &contentConverter{} + + result := converter.ToGQL(nil) + assert.Nil(t, result) + }) +} diff --git a/components/ui-api-layer/internal/domain/content/content_resolver.go b/components/ui-api-layer/internal/domain/content/content_resolver.go new file mode 100644 index 000000000000..300fe99c978c --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/content_resolver.go @@ -0,0 +1,35 @@ +package content + +import ( + "context" + + "github.com/golang/glog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/pkg/errors" +) + +type contentResolver struct { + contentGetter contentGetter + converter *contentConverter +} + +func newContentResolver(contentGetter contentGetter) *contentResolver { + return &contentResolver{ + contentGetter: contentGetter, + converter: &contentConverter{}, + } +} + +func (r *contentResolver) ContentQuery(ctx context.Context, contentType, id string) (*gqlschema.JSON, error) { + item, err := r.contentGetter.Find(contentType, id) + if err != nil { + glog.Error(errors.Wrapf(err, "while gathering content for type `%s` with id `%s`", contentType, id)) + return nil, r.genericError() + } + + return r.converter.ToGQL(item), nil +} + +func (r *contentResolver) genericError() error { + return errors.New("Cannot get Content") +} diff --git a/components/ui-api-layer/internal/domain/content/content_resolver_test.go b/components/ui-api-layer/internal/domain/content/content_resolver_test.go new file mode 100644 index 000000000000..21fd2cef5941 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/content_resolver_test.go @@ -0,0 +1,65 @@ +package content_test + +import ( + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/automock" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +) + +func TestContentResolver_ContentQuery(t *testing.T) { + t.Run("Success", func(t *testing.T) { + cnt := &storage.Content{ + Raw: map[string]interface{}{ + "test": "data", + "tree": map[string]interface{}{ + "testTree": "dataTree", + }, + }, + } + + getter := automock.NewContentGetter() + getter.On("Find", "test", "test").Return(cnt, nil) + + resolver := content.NewContentResolver(getter) + + result, err := resolver.ContentQuery(nil, "test", "test") + + require.NoError(t, err) + assert.Equal(t, &gqlschema.JSON{ + "test": "data", + "tree": map[string]interface{}{ + "testTree": "dataTree", + }, + }, result) + }) + + t.Run("Not found", func(t *testing.T) { + getter := automock.NewContentGetter() + getter.On("Find", "test", "test").Return(nil, nil) + + resolver := content.NewContentResolver(getter) + + result, err := resolver.ContentQuery(nil, "test", "test") + + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("Error while gathering content", func(t *testing.T) { + getter := automock.NewContentGetter() + getter.On("Find", "test", "test").Return(nil, errors.New("trolololo")) + + resolver := content.NewContentResolver(getter) + + _, err := resolver.ContentQuery(nil, "test", "test") + + require.Error(t, err) + }) +} diff --git a/components/ui-api-layer/internal/domain/content/content_service.go b/components/ui-api-layer/internal/domain/content/content_service.go new file mode 100644 index 000000000000..caf97dc9f108 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/content_service.go @@ -0,0 +1,27 @@ +package content + +import ( + "fmt" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" +) + +type contentService struct { + storage minioContentGetter +} + +func newContentService(storage minioContentGetter) *contentService { + return &contentService{ + storage: storage, + } +} + +func (svc *contentService) Find(kind, id string) (*storage.Content, error) { + key := fmt.Sprintf("%s/%s", kind, id) + content, exists, err := svc.storage.Content(key) + if !exists || err != nil { + return nil, err + } + + return content, err +} diff --git a/components/ui-api-layer/internal/domain/content/content_service_test.go b/components/ui-api-layer/internal/domain/content/content_service_test.go new file mode 100644 index 000000000000..256fcbfe3b02 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/content_service_test.go @@ -0,0 +1,61 @@ +package content_test + +import ( + "testing" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/automock" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestContentService_Find(t *testing.T) { + t.Run("Success", func(t *testing.T) { + getter := automock.NewMinioContentGetter() + getter.On("Content", "test/id").Return(fixContent(), true, nil) + defer getter.AssertExpectations(t) + + svc := content.NewContentService(getter) + + result, err := svc.Find("test", "id") + + require.NoError(t, err) + assert.Equal(t, fixContent(), result) + }) + + t.Run("Not found", func(t *testing.T) { + getter := automock.NewMinioContentGetter() + getter.On("Content", "test/id").Return(nil, false, nil) + defer getter.AssertExpectations(t) + + svc := content.NewContentService(getter) + + result, err := svc.Find("test", "id") + + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("Error", func(t *testing.T) { + getter := automock.NewMinioContentGetter() + getter.On("Content", "test/id").Return(nil, false, errors.New("nope")) + defer getter.AssertExpectations(t) + + svc := content.NewContentService(getter) + + _, err := svc.Find("test", "id") + + require.Error(t, err) + }) +} + +func fixContent() *storage.Content { + return &storage.Content{ + Raw: map[string]interface{}{ + "kind": "trololo", + "name": "nope", + }, + } +} diff --git a/components/ui-api-layer/internal/domain/content/export_test.go b/components/ui-api-layer/internal/domain/content/export_test.go new file mode 100644 index 000000000000..bd2da8a7b2ec --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/export_test.go @@ -0,0 +1,29 @@ +package content + +func NewContentResolver(contentGetter contentGetter) *contentResolver { + return newContentResolver(contentGetter) +} + +func NewTopicsResolver(contentGetter contentGetter) *topicsResolver { + return newTopicsResolver(contentGetter) +} + +func NewContentService(storage minioContentGetter) *contentService { + return newContentService(storage) +} + +func NewApiSpecService(storage minioApiSpecGetter) *apiSpecService { + return newApiSpecService(storage) +} + +func NewAsyncApiSpecService(storage minioAsyncApiSpecGetter) *asyncApiSpecService { + return newAsyncApiSpecService(storage) +} + +func (r *topicsResolver) SetTopicsConverter(converter topicsConverterInterface) { + r.converter = converter +} + +//func NewMockTopicsConverter() *mockTopicsConverterInterface { +// return new(mockTopicsConverterInterface) +//} diff --git a/components/ui-api-layer/internal/domain/content/interfaces.go b/components/ui-api-layer/internal/domain/content/interfaces.go new file mode 100644 index 000000000000..34c70a18d4c5 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/interfaces.go @@ -0,0 +1,32 @@ +package content + +import ( + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +) + +//go:generate mockery -name=topicsConverterInterface -output=automock -outpkg=automock -case=underscore +type topicsConverterInterface interface { + ToGQL(in []gqlschema.TopicEntry) *gqlschema.JSON + ExtractSection(documents []storage.Document, internal bool) ([]gqlschema.Section, error) +} + +//go:generate mockery -name=contentGetter -output=automock -outpkg=automock -case=underscore +type contentGetter interface { + Find(kind, id string) (*storage.Content, error) +} + +//go:generate mockery -name=minioAsyncApiSpecGetter -output=automock -outpkg=automock -case=underscore +type minioAsyncApiSpecGetter interface { + AsyncApiSpec(id string) (*storage.AsyncApiSpec, bool, error) +} + +//go:generate mockery -name=minioApiSpecGetter -output=automock -outpkg=automock -case=underscore +type minioApiSpecGetter interface { + ApiSpec(id string) (*storage.ApiSpec, bool, error) +} + +//go:generate mockery -name=minioContentGetter -output=automock -outpkg=automock -case=underscore +type minioContentGetter interface { + Content(id string) (*storage.Content, bool, error) +} diff --git a/components/ui-api-layer/internal/domain/content/storage/automock/cache.go b/components/ui-api-layer/internal/domain/content/storage/automock/cache.go new file mode 100644 index 000000000000..9f2b690b2d8c --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/storage/automock/cache.go @@ -0,0 +1,74 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. +package automock + +import mock "github.com/stretchr/testify/mock" + +// Cache is an autogenerated mock type for the Cache type +type Cache struct { + mock.Mock +} + +// Delete provides a mock function with given fields: key +func (_m *Cache) Delete(key string) error { + ret := _m.Called(key) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(key) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Get provides a mock function with given fields: key +func (_m *Cache) Get(key string) ([]byte, error) { + ret := _m.Called(key) + + var r0 []byte + if rf, ok := ret.Get(0).(func(string) []byte); ok { + r0 = rf(key) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(key) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Reset provides a mock function with given fields: +func (_m *Cache) Reset() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Set provides a mock function with given fields: key, entry +func (_m *Cache) Set(key string, entry []byte) error { + ret := _m.Called(key, entry) + + var r0 error + if rf, ok := ret.Get(0).(func(string, []byte) error); ok { + r0 = rf(key, entry) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/components/ui-api-layer/internal/domain/content/storage/automock/minio.go b/components/ui-api-layer/internal/domain/content/storage/automock/minio.go new file mode 100644 index 000000000000..a09f59a7fad4 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/storage/automock/minio.go @@ -0,0 +1,49 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. +package automock + +import minio "github.com/minio/minio-go" +import mock "github.com/stretchr/testify/mock" + +// Minio is an autogenerated mock type for the Minio type +type Minio struct { + mock.Mock +} + +// GetObject provides a mock function with given fields: bucketName, objectName, opts +func (_m *Minio) GetObject(bucketName string, objectName string, opts minio.GetObjectOptions) (*minio.Object, error) { + ret := _m.Called(bucketName, objectName, opts) + + var r0 *minio.Object + if rf, ok := ret.Get(0).(func(string, string, minio.GetObjectOptions) *minio.Object); ok { + r0 = rf(bucketName, objectName, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*minio.Object) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string, minio.GetObjectOptions) error); ok { + r1 = rf(bucketName, objectName, opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ListenBucketNotification provides a mock function with given fields: bucketName, prefix, suffix, events, doneCh +func (_m *Minio) ListenBucketNotification(bucketName string, prefix string, suffix string, events []string, doneCh <-chan struct{}) <-chan minio.NotificationInfo { + ret := _m.Called(bucketName, prefix, suffix, events, doneCh) + + var r0 <-chan minio.NotificationInfo + if rf, ok := ret.Get(0).(func(string, string, string, []string, <-chan struct{}) <-chan minio.NotificationInfo); ok { + r0 = rf(bucketName, prefix, suffix, events, doneCh) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(<-chan minio.NotificationInfo) + } + } + + return r0 +} diff --git a/components/ui-api-layer/internal/domain/content/storage/cache.go b/components/ui-api-layer/internal/domain/content/storage/cache.go new file mode 100644 index 000000000000..4c07468c99d7 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/storage/cache.go @@ -0,0 +1,237 @@ +package storage + +import ( + "bytes" + "encoding/gob" + "fmt" + "time" + + "github.com/allegro/bigcache" + "github.com/golang/glog" + "github.com/pkg/errors" +) + +//go:generate mockery -name=storeGetter -inpkg -case=underscore +type storeGetter interface { + ApiSpec(id string) (*ApiSpec, bool, error) + AsyncApiSpec(id string) (*AsyncApiSpec, bool, error) + Content(id string) (*Content, bool, error) + NotificationChannel(stop <-chan struct{}) <-chan notification +} + +type handler func(filename string) (interface{}, bool, error) + +type cache struct { + store storeGetter + cache Cache + isInitialized bool + isCacheEnabled bool + handlers map[string]handler +} + +func newCache(store storeGetter, cacheClient Cache) *cache { + swc := &cache{ + store: store, + cache: cacheClient, + isInitialized: false, + isCacheEnabled: false, + handlers: make(map[string]handler), + } + + swc.registerHandler("apiSpec.json", swc.apiSpecHandler) + swc.registerHandler("asyncApiSpec.json", swc.asyncApiSpecHandler) + swc.registerHandler("content.json", swc.contentHandler) + + gob.Register(map[string]interface{}{}) + gob.Register([]interface{}{}) + + return swc +} + +func (swc *cache) Initialize(stop <-chan struct{}) { + if swc.isInitialized { + return + } + swc.isInitialized = true + + go func() { + for { + if err := swc.cache.Reset(); err != nil { + glog.Error(errors.Wrap(err, "while resetting cache")) + // if cache cannot bb restarted then caching should be disabled + return + } + + notifications := swc.store.NotificationChannel(stop) + swc.isCacheEnabled = true + for notification := range notifications { + _, ok := swc.handlers[notification.filename] + if !ok { + // unknown file type + continue + } + + err := swc.updateCache(notification.parent, notification.filename) + if err != nil { + glog.Error(errors.Wrapf(err, "while handling %v notification", notification)) + } + } + swc.isCacheEnabled = false + + select { + case <-stop: + return + default: + time.Sleep(15 * time.Second) + } + } + }() +} + +func (swc *cache) IsSynced() bool { + return swc.isCacheEnabled +} + +func (swc *cache) ApiSpec(id string) (*ApiSpec, bool, error) { + apiSpec := new(ApiSpec) + exists, err := swc.object(id, "apiSpec.json", apiSpec) + + return apiSpec, exists, err +} + +func (swc *cache) AsyncApiSpec(id string) (*AsyncApiSpec, bool, error) { + asyncApiSpec := new(AsyncApiSpec) + exists, err := swc.object(id, "asyncApiSpec.json", asyncApiSpec) + + return asyncApiSpec, exists, err +} + +func (swc *cache) Content(id string) (*Content, bool, error) { + content := new(Content) + exists, err := swc.object(id, "content.json", content) + + return content, exists, err +} + +func (swc *cache) object(parent, filename string, value interface{}) (bool, error) { + data, isCached, err := swc.fromCache(parent, filename) + if err != nil { + return false, err + } + + if !isCached || !swc.isCacheEnabled { + err = swc.updateCache(parent, filename) + if err != nil { + return false, errors.Wrapf(err, "while updating cache for `%s/%s`", parent, filename) + } + + data, isCached, err = swc.fromCache(parent, filename) + if err != nil || !isCached { + return false, err + } + } + + err = swc.convertFromCache(data, value) + if err != nil { + return false, errors.Wrapf(err, "while decoding `%s/%s` from cache", parent, filename) + } + + return true, nil +} + +func (swc *cache) updateCache(parent, filename string) error { + handle, ok := swc.handlers[filename] + if !ok { + return fmt.Errorf("unknown handler for `%s/%s`", parent, filename) + } + + object, exists, err := handle(parent) + if err != nil { + return errors.Wrapf(err, "while handling `%s/%s`", parent, filename) + } + + if exists { + return swc.storeInCache(parent, filename, object) + } + + return swc.removeFromCache(parent, filename) +} + +func (swc *cache) storeInCache(parent, filename string, object interface{}) error { + data, err := swc.convertToCache(object) + if err != nil { + return errors.Wrapf(err, "while converting `%s/%s` to cache format", parent, filename) + } + + err = swc.cache.Set(swc.cacheId(parent, filename), data) + if err != nil { + return errors.Wrapf(err, "while storing `%s/%s` in cache", parent, filename) + } + + return nil +} + +func (swc *cache) removeFromCache(parent, filename string) error { + err := swc.cache.Delete(swc.cacheId(parent, filename)) + if err != nil && !swc.isEntryNotFound(err) { + return errors.Wrapf(err, "while removing `%s/%s` from cache", parent, filename) + } + + return nil +} + +func (swc *cache) fromCache(parent, filename string) ([]byte, bool, error) { + inCache := true + data, err := swc.cache.Get(swc.cacheId(parent, filename)) + if err != nil { + if !swc.isEntryNotFound(err) { + return nil, false, errors.Wrapf(err, "while gathering from cache `%s/%s`", parent, filename) + } + + inCache = false + } + + return data, inCache, nil +} + +func (swc *cache) apiSpecHandler(id string) (interface{}, bool, error) { + return swc.store.ApiSpec(id) +} + +func (swc *cache) asyncApiSpecHandler(id string) (interface{}, bool, error) { + return swc.store.AsyncApiSpec(id) +} + +func (swc *cache) contentHandler(id string) (interface{}, bool, error) { + return swc.store.Content(id) +} + +func (swc *cache) registerHandler(filename string, handler func(string) (interface{}, bool, error)) { + _, registered := swc.handlers[filename] + if registered { + glog.Warning("handler: `%s` already registered", filename) + } + swc.handlers[filename] = handler +} + +func (swc *cache) cacheId(parent, filename string) string { + return fmt.Sprintf("%s/%s", parent, filename) +} + +func (swc *cache) isEntryNotFound(err error) bool { + _, ok := err.(*bigcache.EntryNotFoundError) + return ok +} + +func (swc *cache) convertToCache(object interface{}) ([]byte, error) { + buffer := new(bytes.Buffer) + err := gob.NewEncoder(buffer).Encode(object) + if err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func (swc *cache) convertFromCache(data []byte, value interface{}) error { + return gob.NewDecoder(bytes.NewReader(data)).Decode(value) +} diff --git a/components/ui-api-layer/internal/domain/content/storage/cache_test.go b/components/ui-api-layer/internal/domain/content/storage/cache_test.go new file mode 100644 index 000000000000..5651b93e5395 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/storage/cache_test.go @@ -0,0 +1,975 @@ +package storage_test + +import ( + "bytes" + "encoding/gob" + "fmt" + "testing" + "time" + + "github.com/allegro/bigcache" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage/automock" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const synchronizationTimeout = 1 * time.Second + +func TestCache_Initialize(t *testing.T) { + t.Run("Initialize twice", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + cacheClient.On("Reset").Return(nil).Once() + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + cache.Initialize(stop) + + waitAtMost(cache.IsSynced, synchronizationTimeout) + }) + + t.Run("Initialize once", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + cacheClient.On("Reset").Return(nil).Once() + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + + waitAtMost(cache.IsSynced, synchronizationTimeout) + }) +} + +func TestCache_ApiSpec_Initialized(t *testing.T) { + filename := "apiSpec.json" + function := "ApiSpec" + + id := "some-object" + cacheId := fmt.Sprintf("%s/%s", id, filename) + + expected := new(storage.ApiSpec) + expectedBytes, err := convertToCache(&expected) + if err != nil { + t.Error(err) + } + + t.Run("Not existing object", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + storeGetter.On(function, id).Return(nil, false, nil).Once() + cacheClient.On("Reset").Return(nil).Once() + cacheClient.On("Get", cacheId).Return(nil, &bigcache.EntryNotFoundError{}).Twice() + cacheClient.On("Delete", cacheId).Return(nil).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + waitAtMost(cache.IsSynced, synchronizationTimeout) + + _, exists, err := cache.ApiSpec(id) + + require.NoError(t, err) + assert.False(t, exists) + }) + + t.Run("Existing not cached object", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + storeGetter.On(function, id).Return(expected, true, nil).Once() + cacheClient.On("Reset").Return(nil).Once() + cacheClient.On("Get", cacheId).Return(nil, &bigcache.EntryNotFoundError{}).Once() + cacheClient.On("Set", cacheId, expectedBytes).Return(nil).Once() + cacheClient.On("Get", cacheId).Return(expectedBytes, nil).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + waitAtMost(cache.IsSynced, synchronizationTimeout) + + apiSpec, exists, err := cache.ApiSpec(id) + + require.NoError(t, err) + assert.True(t, exists) + assert.Equal(t, expected, apiSpec) + }) + + t.Run("Existing cached object", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + cacheClient.On("Reset").Return(nil).Once() + cacheClient.On("Get", cacheId).Return(expectedBytes, nil).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + waitAtMost(cache.IsSynced, synchronizationTimeout) + + apiSpec, exists, err := cache.ApiSpec(id) + + require.NoError(t, err) + assert.True(t, exists) + assert.Equal(t, expected, apiSpec) + }) + + t.Run("store error", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + storeGetter.On(function, id).Return(nil, false, errors.New(id)) + cacheClient.On("Reset").Return(nil).Once() + cacheClient.On("Get", cacheId).Return(nil, &bigcache.EntryNotFoundError{}).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + waitAtMost(cache.IsSynced, synchronizationTimeout) + + _, exists, err := cache.ApiSpec(id) + + require.Error(t, err) + assert.False(t, exists) + }) + + t.Run("Cache error", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + cacheClient.On("Reset").Return(nil).Once() + cacheClient.On("Get", cacheId).Return(nil, errors.New(id)).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + waitAtMost(cache.IsSynced, synchronizationTimeout) + + _, exists, err := cache.ApiSpec(id) + + require.Error(t, err) + assert.False(t, exists) + }) + + t.Run("Cache error after update", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + storeGetter.On(function, id).Return(expected, true, nil).Once() + cacheClient.On("Reset").Return(nil).Once() + cacheClient.On("Get", cacheId).Return(nil, &bigcache.EntryNotFoundError{}).Once() + cacheClient.On("Set", cacheId, expectedBytes).Return(nil).Once() + cacheClient.On("Get", cacheId).Return(nil, errors.New(id)).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + waitAtMost(cache.IsSynced, synchronizationTimeout) + + _, exists, err := cache.ApiSpec(id) + + require.Error(t, err) + assert.False(t, exists) + }) + + t.Run("Error while storing in cache", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + storeGetter.On(function, id).Return(expected, true, nil).Once() + cacheClient.On("Reset").Return(nil).Once() + cacheClient.On("Get", cacheId).Return(nil, &bigcache.EntryNotFoundError{}).Once() + cacheClient.On("Set", cacheId, expectedBytes).Return(errors.New(id)).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + waitAtMost(cache.IsSynced, synchronizationTimeout) + + _, exists, err := cache.ApiSpec(id) + + require.Error(t, err) + assert.False(t, exists) + }) +} + +func TestCache_ApiSpec_NotInitialized(t *testing.T) { + filename := "apiSpec.json" + function := "ApiSpec" + + id := "some-object" + cacheId := fmt.Sprintf("%s/%s", id, filename) + + expected := new(storage.ApiSpec) + expectedBytes, err := convertToCache(&expected) + if err != nil { + t.Error(err) + } + + t.Run("Not existing object", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On(function, id).Return(nil, false, nil).Once() + cacheClient.On("Get", cacheId).Return(nil, &bigcache.EntryNotFoundError{}).Twice() + cacheClient.On("Delete", cacheId).Return(nil).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + _, exists, err := cache.ApiSpec(id) + + require.NoError(t, err) + assert.False(t, exists) + }) + + t.Run("Existing not cached object", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On(function, id).Return(expected, true, nil).Once() + cacheClient.On("Get", cacheId).Return(nil, &bigcache.EntryNotFoundError{}).Once() + cacheClient.On("Set", cacheId, expectedBytes).Return(nil).Once() + cacheClient.On("Get", cacheId).Return(expectedBytes, nil).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + apiSpec, exists, err := cache.ApiSpec(id) + + require.NoError(t, err) + assert.True(t, exists) + assert.Equal(t, expected, apiSpec) + }) + + t.Run("Existing cached object", func(t *testing.T) { + notExpected := storage.ApiSpec{ + Raw: map[string]interface{}{ + "test": nil, + }, + } + notExpectedBytes, err := convertToCache(notExpected) + if err != nil { + t.Error(err) + } + + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On(function, id).Return(expected, true, nil).Once() + cacheClient.On("Get", cacheId).Return(notExpectedBytes, nil).Once() + cacheClient.On("Set", cacheId, expectedBytes).Return(nil).Once() + cacheClient.On("Get", cacheId).Return(expectedBytes, nil).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + apiSpec, exists, err := cache.ApiSpec(id) + + require.NoError(t, err) + assert.True(t, exists) + assert.Equal(t, expected, apiSpec) + }) +} + +func TestCache_AsyncApiSpec_Initialized(t *testing.T) { + filename := "asyncApiSpec.json" + function := "AsyncApiSpec" + + id := "some-object" + cacheId := fmt.Sprintf("%s/%s", id, filename) + + expected := new(storage.AsyncApiSpec) + expectedBytes, err := convertToCache(&expected) + if err != nil { + t.Error(err) + } + + t.Run("Not existing object", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + storeGetter.On(function, id).Return(nil, false, nil).Once() + cacheClient.On("Reset").Return(nil).Once() + cacheClient.On("Get", cacheId).Return(nil, &bigcache.EntryNotFoundError{}).Twice() + cacheClient.On("Delete", cacheId).Return(nil).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + waitAtMost(cache.IsSynced, synchronizationTimeout) + + _, exists, err := cache.AsyncApiSpec(id) + + require.NoError(t, err) + assert.False(t, exists) + }) + + t.Run("Existing not cached object", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + storeGetter.On(function, id).Return(expected, true, nil).Once() + cacheClient.On("Reset").Return(nil).Once() + cacheClient.On("Get", cacheId).Return(nil, &bigcache.EntryNotFoundError{}).Once() + cacheClient.On("Set", cacheId, expectedBytes).Return(nil).Once() + cacheClient.On("Get", cacheId).Return(expectedBytes, nil).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + waitAtMost(cache.IsSynced, synchronizationTimeout) + + apiSpec, exists, err := cache.AsyncApiSpec(id) + + require.NoError(t, err) + assert.True(t, exists) + assert.Equal(t, expected, apiSpec) + }) + + t.Run("Existing cached object", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + cacheClient.On("Reset").Return(nil).Once() + cacheClient.On("Get", cacheId).Return(expectedBytes, nil).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + waitAtMost(cache.IsSynced, synchronizationTimeout) + + apiSpec, exists, err := cache.AsyncApiSpec(id) + + require.NoError(t, err) + assert.True(t, exists) + assert.Equal(t, expected, apiSpec) + }) + + t.Run("store error", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + storeGetter.On(function, id).Return(nil, false, errors.New(id)) + cacheClient.On("Reset").Return(nil).Once() + cacheClient.On("Get", cacheId).Return(nil, &bigcache.EntryNotFoundError{}).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + waitAtMost(cache.IsSynced, synchronizationTimeout) + + _, exists, err := cache.AsyncApiSpec(id) + + require.Error(t, err) + assert.False(t, exists) + }) + + t.Run("Cache error", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + cacheClient.On("Reset").Return(nil).Once() + cacheClient.On("Get", cacheId).Return(nil, errors.New(id)).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + waitAtMost(cache.IsSynced, synchronizationTimeout) + + _, exists, err := cache.AsyncApiSpec(id) + + require.Error(t, err) + assert.False(t, exists) + }) + + t.Run("Cache error after update", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + storeGetter.On(function, id).Return(expected, true, nil).Once() + cacheClient.On("Reset").Return(nil).Once() + cacheClient.On("Get", cacheId).Return(nil, &bigcache.EntryNotFoundError{}).Once() + cacheClient.On("Set", cacheId, expectedBytes).Return(nil).Once() + cacheClient.On("Get", cacheId).Return(nil, errors.New(id)).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + waitAtMost(cache.IsSynced, synchronizationTimeout) + + _, exists, err := cache.AsyncApiSpec(id) + + require.Error(t, err) + assert.False(t, exists) + }) + + t.Run("Error while storing in cache", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + storeGetter.On(function, id).Return(expected, true, nil).Once() + cacheClient.On("Reset").Return(nil).Once() + cacheClient.On("Get", cacheId).Return(nil, &bigcache.EntryNotFoundError{}).Once() + cacheClient.On("Set", cacheId, expectedBytes).Return(errors.New(id)).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + waitAtMost(cache.IsSynced, synchronizationTimeout) + + _, exists, err := cache.AsyncApiSpec(id) + + require.Error(t, err) + assert.False(t, exists) + }) +} + +func TestCache_AsyncApiSpec_NotInitialized(t *testing.T) { + filename := "asyncApiSpec.json" + function := "AsyncApiSpec" + + id := "some-object" + cacheId := fmt.Sprintf("%s/%s", id, filename) + + expected := new(storage.AsyncApiSpec) + expectedBytes, err := convertToCache(&expected) + if err != nil { + t.Error(err) + } + + t.Run("Not existing object", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On(function, id).Return(nil, false, nil).Once() + cacheClient.On("Get", cacheId).Return(nil, &bigcache.EntryNotFoundError{}).Twice() + cacheClient.On("Delete", cacheId).Return(nil).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + _, exists, err := cache.AsyncApiSpec(id) + + require.NoError(t, err) + assert.False(t, exists) + }) + + t.Run("Existing not cached object", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On(function, id).Return(expected, true, nil).Once() + cacheClient.On("Get", cacheId).Return(nil, &bigcache.EntryNotFoundError{}).Once() + cacheClient.On("Set", cacheId, expectedBytes).Return(nil).Once() + cacheClient.On("Get", cacheId).Return(expectedBytes, nil).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + apiSpec, exists, err := cache.AsyncApiSpec(id) + + require.NoError(t, err) + assert.True(t, exists) + assert.Equal(t, expected, apiSpec) + }) + + t.Run("Existing cached object", func(t *testing.T) { + notExpected := storage.AsyncApiSpec{ + Raw: map[string]interface{}{ + "test": nil, + }, + } + notExpectedBytes, err := convertToCache(notExpected) + if err != nil { + t.Error(err) + } + + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On(function, id).Return(expected, true, nil).Once() + cacheClient.On("Get", cacheId).Return(notExpectedBytes, nil).Once() + cacheClient.On("Set", cacheId, expectedBytes).Return(nil).Once() + cacheClient.On("Get", cacheId).Return(expectedBytes, nil).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + apiSpec, exists, err := cache.AsyncApiSpec(id) + + require.NoError(t, err) + assert.True(t, exists) + assert.Equal(t, expected, apiSpec) + }) +} + +func TestCache_Content_Initialized(t *testing.T) { + filename := "content.json" + function := "Content" + + id := "some-object" + cacheId := fmt.Sprintf("%s/%s", id, filename) + + expected := new(storage.Content) + expectedBytes, err := convertToCache(&expected) + if err != nil { + t.Error(err) + } + + t.Run("Not existing object", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + storeGetter.On(function, id).Return(nil, false, nil).Once() + cacheClient.On("Reset").Return(nil).Once() + cacheClient.On("Get", cacheId).Return(nil, &bigcache.EntryNotFoundError{}).Twice() + cacheClient.On("Delete", cacheId).Return(nil).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + waitAtMost(cache.IsSynced, synchronizationTimeout) + + _, exists, err := cache.Content(id) + + require.NoError(t, err) + assert.False(t, exists) + }) + + t.Run("Existing not cached object", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + storeGetter.On(function, id).Return(expected, true, nil).Once() + cacheClient.On("Reset").Return(nil).Once() + cacheClient.On("Get", cacheId).Return(nil, &bigcache.EntryNotFoundError{}).Once() + cacheClient.On("Set", cacheId, expectedBytes).Return(nil).Once() + cacheClient.On("Get", cacheId).Return(expectedBytes, nil).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + waitAtMost(cache.IsSynced, synchronizationTimeout) + + apiSpec, exists, err := cache.Content(id) + + require.NoError(t, err) + assert.True(t, exists) + assert.Equal(t, expected, apiSpec) + }) + + t.Run("Existing cached object", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + cacheClient.On("Reset").Return(nil).Once() + cacheClient.On("Get", cacheId).Return(expectedBytes, nil).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + waitAtMost(cache.IsSynced, synchronizationTimeout) + + apiSpec, exists, err := cache.Content(id) + + require.NoError(t, err) + assert.True(t, exists) + assert.Equal(t, expected, apiSpec) + }) + + t.Run("store error", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + storeGetter.On(function, id).Return(nil, false, errors.New(id)) + cacheClient.On("Reset").Return(nil).Once() + cacheClient.On("Get", cacheId).Return(nil, &bigcache.EntryNotFoundError{}).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + waitAtMost(cache.IsSynced, synchronizationTimeout) + + _, exists, err := cache.Content(id) + + require.Error(t, err) + assert.False(t, exists) + }) + + t.Run("Cache error", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + cacheClient.On("Reset").Return(nil).Once() + cacheClient.On("Get", cacheId).Return(nil, errors.New(id)).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + waitAtMost(cache.IsSynced, synchronizationTimeout) + + _, exists, err := cache.Content(id) + + require.Error(t, err) + assert.False(t, exists) + }) + + t.Run("Cache error after update", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + storeGetter.On(function, id).Return(expected, true, nil).Once() + cacheClient.On("Reset").Return(nil).Once() + cacheClient.On("Get", cacheId).Return(nil, &bigcache.EntryNotFoundError{}).Once() + cacheClient.On("Set", cacheId, expectedBytes).Return(nil).Once() + cacheClient.On("Get", cacheId).Return(nil, errors.New(id)).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + waitAtMost(cache.IsSynced, synchronizationTimeout) + + _, exists, err := cache.Content(id) + + require.Error(t, err) + assert.False(t, exists) + }) + + t.Run("Error while storing in cache", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On("NotificationChannel", mock.Anything).Return(storage.GetDirectNotificationChan(notifications)).Once() + storeGetter.On(function, id).Return(expected, true, nil).Once() + cacheClient.On("Reset").Return(nil).Once() + cacheClient.On("Get", cacheId).Return(nil, &bigcache.EntryNotFoundError{}).Once() + cacheClient.On("Set", cacheId, expectedBytes).Return(errors.New(id)).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + cache.Initialize(stop) + waitAtMost(cache.IsSynced, synchronizationTimeout) + + _, exists, err := cache.Content(id) + + require.Error(t, err) + assert.False(t, exists) + }) +} + +func TestCache_Content_NotInitialized(t *testing.T) { + filename := "content.json" + function := "Content" + + id := "some-object" + cacheId := fmt.Sprintf("%s/%s", id, filename) + + expected := new(storage.Content) + expectedBytes, err := convertToCache(&expected) + if err != nil { + t.Error(err) + } + + t.Run("Not existing object", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On(function, id).Return(nil, false, nil).Once() + cacheClient.On("Get", cacheId).Return(nil, &bigcache.EntryNotFoundError{}).Twice() + cacheClient.On("Delete", cacheId).Return(nil).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + _, exists, err := cache.Content(id) + + require.NoError(t, err) + assert.False(t, exists) + }) + + t.Run("Existing not cached object", func(t *testing.T) { + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On(function, id).Return(expected, true, nil).Once() + cacheClient.On("Get", cacheId).Return(nil, &bigcache.EntryNotFoundError{}).Once() + cacheClient.On("Set", cacheId, expectedBytes).Return(nil).Once() + cacheClient.On("Get", cacheId).Return(expectedBytes, nil).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + apiSpec, exists, err := cache.Content(id) + + require.NoError(t, err) + assert.True(t, exists) + assert.Equal(t, expected, apiSpec) + }) + + t.Run("Existing cached object", func(t *testing.T) { + notExpected := storage.Content{ + Raw: map[string]interface{}{ + "test": nil, + }, + } + notExpectedBytes, err := convertToCache(notExpected) + if err != nil { + t.Error(err) + } + + storeGetter := storage.NewStoreGetter() + cacheClient := new(automock.Cache) + + cache := storage.NewCache(storeGetter, cacheClient) + stop := make(chan struct{}) + defer close(stop) + notifications := storage.NewNotificationChan() + defer close(notifications) + + storeGetter.On(function, id).Return(expected, true, nil).Once() + cacheClient.On("Get", cacheId).Return(notExpectedBytes, nil).Once() + cacheClient.On("Set", cacheId, expectedBytes).Return(nil).Once() + cacheClient.On("Get", cacheId).Return(expectedBytes, nil).Once() + defer cacheClient.AssertExpectations(t) + defer storeGetter.AssertExpectations(t) + + apiSpec, exists, err := cache.Content(id) + + require.NoError(t, err) + assert.True(t, exists) + assert.Equal(t, expected, apiSpec) + }) +} + +func convertToCache(object interface{}) ([]byte, error) { + buffer := new(bytes.Buffer) + err := gob.NewEncoder(buffer).Encode(object) + if err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func waitAtMost(fn func() bool, duration time.Duration) error { + timeout := time.After(duration) + tick := time.Tick(1 * time.Millisecond) + + for { + select { + case <-timeout: + return errors.New(fmt.Sprintf("Waiting for resource failed in given timeout %f second(s)", duration.Seconds())) + case <-tick: + if fn() { + return nil + } + } + } +} diff --git a/components/ui-api-layer/internal/domain/content/storage/export_test.go b/components/ui-api-layer/internal/domain/content/storage/export_test.go new file mode 100644 index 000000000000..7a651e7bfa34 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/storage/export_test.go @@ -0,0 +1,33 @@ +package storage + +func NewCache(store storeGetter, cacheClient Cache) *cache { + return newCache(store, cacheClient) +} + +func NewStore(client client, bucketName, externalAddress, assetFolder string) *store { + return newStore(client, bucketName, externalAddress, assetFolder) +} + +func NewNotificationChan() chan notification { + return make(chan notification) +} + +func GetDirectNotificationChan(notifications chan notification) <-chan notification { + return notifications +} + +func NewNotification() notification { + return notification{} +} + +func NewStoreGetter() *mockStoreGetter { + return new(mockStoreGetter) +} + +func NewMockClient() *mockClient { + return new(mockClient) +} + +func NewMinioClient(minio Minio) *minioClient { + return newMinioClient(minio) +} diff --git a/components/ui-api-layer/internal/domain/content/storage/interfaces.go b/components/ui-api-layer/internal/domain/content/storage/interfaces.go new file mode 100644 index 000000000000..ad994dee0cd6 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/storage/interfaces.go @@ -0,0 +1,28 @@ +package storage + +import ( + "io" + + "github.com/minio/minio-go" +) + +//go:generate mockery -name=Cache -output=automock -outpkg=automock -case=underscore +type Cache interface { + Delete(key string) error + Get(key string) ([]byte, error) + Set(key string, entry []byte) error + Reset() error +} + +//go:generate mockery -name=Minio -output=automock -outpkg=automock -case=underscore +type Minio interface { + GetObject(bucketName, objectName string, opts minio.GetObjectOptions) (*minio.Object, error) + ListenBucketNotification(bucketName, prefix, suffix string, events []string, doneCh <-chan struct{}) <-chan minio.NotificationInfo +} + +//go:generate mockery -name=client -inpkg -case=underscore +type client interface { + Object(bucketName, objectName string) (io.Reader, error) + NotificationChannel(bucketName string, stop <-chan struct{}) <-chan notification + IsNotExistsError(err error) bool +} diff --git a/components/ui-api-layer/internal/domain/content/storage/minioclient.go b/components/ui-api-layer/internal/domain/content/storage/minioclient.go new file mode 100644 index 000000000000..73c4b51a261f --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/storage/minioclient.go @@ -0,0 +1,75 @@ +package storage + +import ( + "io" + "net/url" + "path/filepath" + + "github.com/golang/glog" + "github.com/minio/minio-go" + "github.com/pkg/errors" +) + +type minioClient struct { + client Minio +} + +func newMinioClient(client Minio) *minioClient { + return &minioClient{ + client: client, + } +} + +func (mc *minioClient) Object(bucketName, objectName string) (io.Reader, error) { + return mc.client.GetObject(bucketName, objectName, minio.GetObjectOptions{}) +} + +func (mc *minioClient) IsNotExistsError(err error) bool { + switch err := err.(type) { + case minio.ErrorResponse: + return err.Code == "NoSuchKey" + default: + return false + } +} + +func (mc *minioClient) NotificationChannel(bucketName string, stop <-chan struct{}) <-chan notification { + notificationChannel := make(chan notification) + + channel := mc.client.ListenBucketNotification(bucketName, "", "", []string{ + "s3:ObjectCreated:*", + "s3:ObjectRemoved:*", + }, stop) + + go func() { + defer close(notificationChannel) + for info := range channel { + if info.Err != nil { + glog.Error(errors.Wrapf(info.Err, "while listening notifications on `%s` bucket", bucketName)) + } + + for _, record := range info.Records { + key, err := url.QueryUnescape(record.S3.Object.Key) + if err != nil { + glog.Warningf("Cannot parse object key: `%s`", record.S3.Object.Key) + continue + } + + parent := filepath.Dir(key) + if parent == "." { + parent = "" + } + + notification := notification{ + parent: parent, + filename: filepath.Base(key), + eventType: record.EventName, + } + + notificationChannel <- notification + } + } + }() + + return notificationChannel +} diff --git a/components/ui-api-layer/internal/domain/content/storage/minioclient_test.go b/components/ui-api-layer/internal/domain/content/storage/minioclient_test.go new file mode 100644 index 000000000000..cf7275aaebdb --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/storage/minioclient_test.go @@ -0,0 +1,60 @@ +package storage_test + +import ( + "testing" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage/automock" + minio2 "github.com/minio/minio-go" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestMinioClient_IsNotExistsError(t *testing.T) { + minio := new(automock.Minio) + client := storage.NewMinioClient(minio) + + t.Run("Other error", func(t *testing.T) { + ok := client.IsNotExistsError(errors.New("other error")) + assert.False(t, ok) + }) + + t.Run("Nil error", func(t *testing.T) { + ok := client.IsNotExistsError(nil) + assert.False(t, ok) + }) + + t.Run("Not exists error", func(t *testing.T) { + ok := client.IsNotExistsError(minio2.ErrorResponse{Code: "NoSuchKey"}) + assert.True(t, ok) + }) + + t.Run("Different code", func(t *testing.T) { + ok := client.IsNotExistsError(minio2.ErrorResponse{Code: "Different Code"}) + assert.False(t, ok) + }) +} +func TestMinioClient_Object(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + minio := new(automock.Minio) + client := storage.NewMinioClient(minio) + + minio.On("GetObject", "valid", "name", mock.Anything). + Return(&minio2.Object{}, nil) + obj, err := client.Object("valid", "name") + require.NoError(t, err) + assert.IsType(t, &minio2.Object{}, obj) + }) + + t.Run("Error while getting object", func(t *testing.T) { + minio := new(automock.Minio) + client := storage.NewMinioClient(minio) + + minio.On("GetObject", "invalid", "name", mock.Anything). + Return(nil, errors.New("get-object")) + _, err := client.Object("invalid", "name") + require.Error(t, err) + }) +} diff --git a/components/ui-api-layer/internal/domain/content/storage/mock_client.go b/components/ui-api-layer/internal/domain/content/storage/mock_client.go new file mode 100644 index 000000000000..d32b3460a773 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/storage/mock_client.go @@ -0,0 +1,63 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. +package storage + +import io "io" +import mock "github.com/stretchr/testify/mock" + +// mockClient is an autogenerated mock type for the client type +type mockClient struct { + mock.Mock +} + +// IsNotExistsError provides a mock function with given fields: err +func (_m *mockClient) IsNotExistsError(err error) bool { + ret := _m.Called(err) + + var r0 bool + if rf, ok := ret.Get(0).(func(error) bool); ok { + r0 = rf(err) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// NotificationChannel provides a mock function with given fields: bucketName, stop +func (_m *mockClient) NotificationChannel(bucketName string, stop <-chan struct{}) <-chan notification { + ret := _m.Called(bucketName, stop) + + var r0 <-chan notification + if rf, ok := ret.Get(0).(func(string, <-chan struct{}) <-chan notification); ok { + r0 = rf(bucketName, stop) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(<-chan notification) + } + } + + return r0 +} + +// Object provides a mock function with given fields: bucketName, objectName +func (_m *mockClient) Object(bucketName string, objectName string) (io.Reader, error) { + ret := _m.Called(bucketName, objectName) + + var r0 io.Reader + if rf, ok := ret.Get(0).(func(string, string) io.Reader); ok { + r0 = rf(bucketName, objectName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(io.Reader) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(bucketName, objectName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/content/storage/mock_store_getter.go b/components/ui-api-layer/internal/domain/content/storage/mock_store_getter.go new file mode 100644 index 000000000000..bf554996e8b7 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/storage/mock_store_getter.go @@ -0,0 +1,115 @@ +// Code generated by mockery v1.0.0 +package storage + +import mock "github.com/stretchr/testify/mock" + +// mockStoreGetter is an autogenerated mock type for the storeGetter type +type mockStoreGetter struct { + mock.Mock +} + +// ApiSpec provides a mock function with given fields: id +func (_m *mockStoreGetter) ApiSpec(id string) (*ApiSpec, bool, error) { + ret := _m.Called(id) + + var r0 *ApiSpec + if rf, ok := ret.Get(0).(func(string) *ApiSpec); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*ApiSpec) + } + } + + var r1 bool + if rf, ok := ret.Get(1).(func(string) bool); ok { + r1 = rf(id) + } else { + r1 = ret.Get(1).(bool) + } + + var r2 error + if rf, ok := ret.Get(2).(func(string) error); ok { + r2 = rf(id) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// AsyncApiSpec provides a mock function with given fields: id +func (_m *mockStoreGetter) AsyncApiSpec(id string) (*AsyncApiSpec, bool, error) { + ret := _m.Called(id) + + var r0 *AsyncApiSpec + if rf, ok := ret.Get(0).(func(string) *AsyncApiSpec); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*AsyncApiSpec) + } + } + + var r1 bool + if rf, ok := ret.Get(1).(func(string) bool); ok { + r1 = rf(id) + } else { + r1 = ret.Get(1).(bool) + } + + var r2 error + if rf, ok := ret.Get(2).(func(string) error); ok { + r2 = rf(id) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// Content provides a mock function with given fields: id +func (_m *mockStoreGetter) Content(id string) (*Content, bool, error) { + ret := _m.Called(id) + + var r0 *Content + if rf, ok := ret.Get(0).(func(string) *Content); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*Content) + } + } + + var r1 bool + if rf, ok := ret.Get(1).(func(string) bool); ok { + r1 = rf(id) + } else { + r1 = ret.Get(1).(bool) + } + + var r2 error + if rf, ok := ret.Get(2).(func(string) error); ok { + r2 = rf(id) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// NotificationChannel provides a mock function with given fields: stop +func (_m *mockStoreGetter) NotificationChannel(stop <-chan struct{}) <-chan notification { + ret := _m.Called(stop) + + var r0 <-chan notification + if rf, ok := ret.Get(0).(func(<-chan struct{}) <-chan notification); ok { + r0 = rf(stop) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(<-chan notification) + } + } + + return r0 +} diff --git a/components/ui-api-layer/internal/domain/content/storage/storage.go b/components/ui-api-layer/internal/domain/content/storage/storage.go new file mode 100644 index 000000000000..f9947b84fd58 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/storage/storage.go @@ -0,0 +1,14 @@ +package storage + +type Service interface { + ApiSpec(id string) (*ApiSpec, bool, error) + AsyncApiSpec(id string) (*AsyncApiSpec, bool, error) + Content(id string) (*Content, bool, error) + Initialize(stop <-chan struct{}) +} + +func New(minio Minio, cache Cache, bucketName, externalAddress, assetsFolder string) Service { + client := newMinioClient(minio) + store := newStore(client, bucketName, externalAddress, assetsFolder) + return newCache(store, cache) +} diff --git a/components/ui-api-layer/internal/domain/content/storage/store.go b/components/ui-api-layer/internal/domain/content/storage/store.go new file mode 100644 index 000000000000..67dc965fd6fa --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/storage/store.go @@ -0,0 +1,146 @@ +package storage + +import ( + "encoding/json" + "fmt" + "io" + + "regexp" + "strings" + + "github.com/pkg/errors" +) + +type notification struct { + parent string + filename string + eventType string +} + +type store struct { + bucketName string + externalAddress string + assetFolder string + client client + assetsRegexp *regexp.Regexp +} + +func newStore(client client, bucketName, externalAddress, assetFolder string) *store { + pattern := fmt.Sprintf(`"%s/|"\./%s/`, assetFolder, assetFolder) + + return &store{ + bucketName: bucketName, + externalAddress: externalAddress, + assetFolder: assetFolder, + client: client, + assetsRegexp: regexp.MustCompile(pattern), + } +} + +func (s *store) Content(id string) (*Content, bool, error) { + content := new(Content) + exists, err := s.object(id, "content.json", content) + if exists { + s.prepareDocs(content, id) + } + + return content, exists, err +} + +func (s *store) ApiSpec(id string) (*ApiSpec, bool, error) { + apiSpec := new(ApiSpec) + exists, err := s.object(id, "apiSpec.json", apiSpec) + + return apiSpec, exists, err +} + +func (s *store) AsyncApiSpec(id string) (*AsyncApiSpec, bool, error) { + asyncApiSpec := new(AsyncApiSpec) + exists, err := s.object(id, "asyncApiSpec.json", asyncApiSpec) + + return asyncApiSpec, exists, err +} + +func (s *store) NotificationChannel(stop <-chan struct{}) <-chan notification { + return s.client.NotificationChannel(s.bucketName, stop) +} + +func (s *store) object(id, filename string, value interface{}) (bool, error) { + objectName := fmt.Sprintf("%s/%s", id, filename) + reader, err := s.client.Object(s.bucketName, objectName) + if err != nil { + return false, errors.Wrapf(err, "while getting object `%s`", objectName) + } + + exists, err := s.decode(reader, value) + if err != nil || !exists { + return false, errors.Wrapf(err, "while decoding object `%s`", objectName) + } + + return true, nil +} + +func (s *store) prepareDocs(content *Content, id string) { + if content == nil { + return + } + + docsObj, exists := content.Raw["docs"] + if !exists { + return + } + + docs, ok := docsObj.([]interface{}) + if !ok { + return + } + + var result []interface{} + for _, v := range docs { + replaced := s.replaceAssetsAddress(v, id) + if replaced != nil { + result = append(result, replaced) + } + } + + content.Raw["docs"] = result +} + +func (s *store) replaceAssetsAddress(in interface{}, id string) interface{} { + if in == nil { + return in + } + + doc, ok := in.(map[string]interface{}) + if !ok { + return in + } + + result := make(map[string]interface{}) + for k, v := range doc { + value, ok := v.(string) + if ok { + replaced := strings.Replace(value, "{PLACEHOLDER_APP_RESOURCES_BASE_URI}", s.externalAddress, -1) + address := fmt.Sprintf(`"%s/%s/%s/%s/`, s.externalAddress, s.bucketName, id, s.assetFolder) + result[k] = s.assetsRegexp.ReplaceAllString(replaced, address) + } else { + result[k] = v + } + } + + return result +} + +func (s *store) decode(reader io.Reader, value interface{}) (bool, error) { + err := json.NewDecoder(reader).Decode(value) + if err != nil { + ok := s.client.IsNotExistsError(err) + if ok { + return false, nil + } + + return false, err + } + + return true, nil +} diff --git a/components/ui-api-layer/internal/domain/content/storage/store_test.go b/components/ui-api-layer/internal/domain/content/storage/store_test.go new file mode 100644 index 000000000000..b8fd507dee09 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/storage/store_test.go @@ -0,0 +1,336 @@ +package storage_test + +import ( + "bytes" + "testing" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestStore_ApiSpec(t *testing.T) { + t.Run("Not existing object", func(t *testing.T) { + client := storage.NewMockClient() + service := storage.NewStore(client, "test", "https://test.ninja", "assets") + + client.On("Object", "test", "not-existing/apiSpec.json"). + Return(bytes.NewReader([]byte{}), nil) + client.On("IsNotExistsError", mock.Anything). + Return(true) + + _, exists, err := service.ApiSpec("not-existing") + + require.NoError(t, err) + assert.False(t, exists) + }) + + t.Run("Invalid object", func(t *testing.T) { + client := storage.NewMockClient() + service := storage.NewStore(client, "test", "https://test.ninja", "assets") + + client.On("Object", "test", "invalid/apiSpec.json"). + Return(bytes.NewReader([]byte{}), nil) + client.On("IsNotExistsError", mock.Anything). + Return(false) + + _, exists, err := service.ApiSpec("invalid") + + require.Error(t, err) + assert.False(t, exists) + }) + + t.Run("Client error", func(t *testing.T) { + client := storage.NewMockClient() + service := storage.NewStore(client, "test", "https://test.ninja", "assets") + + client.On("Object", "test", "client-error/apiSpec.json"). + Return(bytes.NewReader([]byte{}), errors.New("Random error")) + + _, exists, err := service.ApiSpec("client-error") + + require.Error(t, err) + assert.False(t, exists) + }) + + t.Run("Valid object", func(t *testing.T) { + client := storage.NewMockClient() + service := storage.NewStore(client, "test", "https://test.ninja", "assets") + + expected := &storage.ApiSpec{ + Raw: map[string]interface{}{}, + } + + client.On("Object", "test", "valid/apiSpec.json"). + Return(bytes.NewReader([]byte("{}")), nil) + + apiSpec, exists, err := service.ApiSpec("valid") + + require.NoError(t, err) + assert.True(t, exists) + assert.Equal(t, expected, apiSpec) + }) +} + +func TestStore_AsyncApiSpec(t *testing.T) { + t.Run("Not existing object", func(t *testing.T) { + client := storage.NewMockClient() + service := storage.NewStore(client, "test", "https://test.ninja", "assets") + + client.On("Object", "test", "not-existing/asyncApiSpec.json"). + Return(bytes.NewReader([]byte{}), nil) + client.On("IsNotExistsError", mock.Anything). + Return(true) + + _, exists, err := service.AsyncApiSpec("not-existing") + + require.NoError(t, err) + assert.False(t, exists) + }) + + t.Run("Invalid object", func(t *testing.T) { + client := storage.NewMockClient() + service := storage.NewStore(client, "test", "https://test.ninja", "assets") + + client.On("Object", "test", "invalid/asyncApiSpec.json"). + Return(bytes.NewReader([]byte{}), nil) + client.On("IsNotExistsError", mock.Anything). + Return(false) + + _, exists, err := service.AsyncApiSpec("invalid") + + require.Error(t, err) + assert.False(t, exists) + }) + + t.Run("Client error", func(t *testing.T) { + client := storage.NewMockClient() + service := storage.NewStore(client, "test", "https://test.ninja", "assets") + + client.On("Object", "test", "client-error/asyncApiSpec.json"). + Return(bytes.NewReader([]byte{}), errors.New("Random error")) + + _, exists, err := service.AsyncApiSpec("client-error") + + require.Error(t, err) + assert.False(t, exists) + }) + + t.Run("Valid object", func(t *testing.T) { + client := storage.NewMockClient() + service := storage.NewStore(client, "test", "https://test.ninja", "assets") + + expected := &storage.AsyncApiSpec{ + Data: storage.AsyncApiSpecData{}, + Raw: map[string]interface{}{ + "name": "test", + "other": "yhm", + }, + } + + client.On("Object", "test", "valid/asyncApiSpec.json"). + Return(bytes.NewReader([]byte("{\"name\":\"test\",\"other\":\"yhm\"}")), nil) + + apiSpec, exists, err := service.AsyncApiSpec("valid") + + require.NoError(t, err) + assert.True(t, exists) + assert.Equal(t, expected, apiSpec) + }) +} + +func TestStore_Content(t *testing.T) { + t.Run("Not existing object", func(t *testing.T) { + client := storage.NewMockClient() + service := storage.NewStore(client, "test", "https://test.ninja", "assets") + + client.On("Object", "test", "not-existing/content.json"). + Return(bytes.NewReader([]byte{}), nil) + client.On("IsNotExistsError", mock.Anything). + Return(true) + + _, exists, err := service.Content("not-existing") + + require.NoError(t, err) + assert.False(t, exists) + }) + + t.Run("Invalid object", func(t *testing.T) { + client := storage.NewMockClient() + service := storage.NewStore(client, "test", "https://test.ninja", "assets") + + client.On("Object", "test", "invalid/content.json"). + Return(bytes.NewReader([]byte{}), nil) + client.On("IsNotExistsError", mock.Anything). + Return(false) + + _, exists, err := service.Content("invalid") + + require.Error(t, err) + assert.False(t, exists) + }) + + t.Run("Client error", func(t *testing.T) { + client := storage.NewMockClient() + service := storage.NewStore(client, "test", "https://test.ninja", "assets") + + client.On("Object", "test", "client-error/content.json"). + Return(bytes.NewReader([]byte{}), errors.New("Random error")) + + _, exists, err := service.Content("client-error") + + require.Error(t, err) + assert.False(t, exists) + }) + + t.Run("Docs with assets", func(t *testing.T) { + client := storage.NewMockClient() + service := storage.NewStore(client, "test", "https://test.ninja", "assets") + + client.On("Object", "test", "valid/content.json"). + Return(bytes.NewReader([]byte(fixContentWithLinksJSON())), nil) + + apiSpec, exists, err := service.Content("valid") + + require.NoError(t, err) + assert.True(t, exists) + assert.Equal(t, fixContentWithLinks(), apiSpec) + }) + + t.Run("Valid object", func(t *testing.T) { + client := storage.NewMockClient() + service := storage.NewStore(client, "test", "https://test.ninja", "assets") + + expected := &storage.Content{ + Raw: map[string]interface{}{}, + } + + client.On("Object", "test", "valid/content.json"). + Return(bytes.NewReader([]byte("{}")), nil) + + apiSpec, exists, err := service.Content("valid") + + require.NoError(t, err) + assert.True(t, exists) + assert.Equal(t, expected, apiSpec) + }) +} + +func fixContentWithLinksJSON() string { + return `{ + "displayName": "Example Docs", + "id": "example-docs", + "type": "Service Class", + "description": "Foo bar baz", + "docs": [ + { + "title": "With placeholder", + "type": "doctype1", + "source": "" + }, + { + "title": "With dot", + "type": "doctype2", + "source": "" + }, + { + "title": "Without dot", + "type": "doctype3", + "source": "" + }, + { + "title": "Mixed", + "type": "doctype3", + "source": "" + }, + { + "title": "Mixed multiple", + "type": "doctype3", + "source": "" + } + ] +}` +} + +func fixContentWithLinks() *storage.Content { + return &storage.Content{ + Raw: map[string]interface{}{ + "displayName": "Example Docs", + "id": "example-docs", + "type": "Service Class", + "description": "Foo bar baz", + "docs": []interface{}{ + map[string]interface{}{ + "title": "With placeholder", + "type": "doctype1", + "source": "", + }, + map[string]interface{}{ + "title": "With dot", + "type": "doctype2", + "source": "", + }, + map[string]interface{}{ + "title": "Without dot", + "type": "doctype3", + "source": "", + }, + map[string]interface{}{ + "title": "Mixed", + "type": "doctype3", + "source": "", + }, + map[string]interface{}{ + "title": "Mixed multiple", + "type": "doctype3", + "source": "", + }, + }, + }, + Data: storage.ContentData{ + Description: "Foo bar baz", + DisplayName: "Example Docs", + Type: "Service Class", + Docs: []storage.Document{ + { + Order: "", + Source: "", + Title: "With placeholder", + Type: "doctype1", + Internal: false, + }, + { + Order: "", + Source: "", + Title: "With dot", + Type: "doctype2", + Internal: false, + }, + { + Order: "", + Source: "", + Title: "Without dot", + Type: "doctype3", + Internal: false, + }, + { + Order: "", + Source: "", + Title: "Mixed", + Type: "doctype3", + Internal: false, + }, + { + Order: "", + Source: "", + Title: "Mixed multiple", + Type: "doctype3", + Internal: false, + }, + }, + ID: "example-docs", + }, + } +} diff --git a/components/ui-api-layer/internal/domain/content/storage/types.go b/components/ui-api-layer/internal/domain/content/storage/types.go new file mode 100644 index 000000000000..999a748472f3 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/storage/types.go @@ -0,0 +1,90 @@ +package storage + +import ( + "encoding/json" +) + +type Content struct { + Raw map[string]interface{} + Data ContentData +} + +type ContentData struct { + Description string `json:"description"` + DisplayName string `json:"displayName"` + Docs []Document `json:"docs"` + ID string `json:"id"` + Type string `json:"type"` +} + +type Document struct { + Order string `json:"order"` + Source string `json:"source"` + Title string `json:"title"` + Type string `json:"type"` + Internal bool `json:"internal,omitempty"` +} + +func (o *Content) UnmarshalJSON(jsonData []byte) error { + var raw map[string]interface{} + err := json.Unmarshal(jsonData, &raw) + if err != nil { + return err + } + + var data ContentData + err = json.Unmarshal(jsonData, &data) + if err != nil { + return err + } + + o.Raw = raw + o.Data = data + + return nil +} + +type ApiSpec struct { + Raw map[string]interface{} +} + +func (o *ApiSpec) UnmarshalJSON(jsonData []byte) error { + var raw map[string]interface{} + err := json.Unmarshal(jsonData, &raw) + if err != nil { + return err + } + + o.Raw = raw + + return nil +} + +type AsyncApiSpec struct { + Raw map[string]interface{} + Data AsyncApiSpecData +} + +type AsyncApiSpecData struct { + AsyncAPI string + Topics map[string]interface{} +} + +func (o *AsyncApiSpec) UnmarshalJSON(jsonData []byte) error { + var raw map[string]interface{} + err := json.Unmarshal(jsonData, &raw) + if err != nil { + return err + } + + var data AsyncApiSpecData + err = json.Unmarshal(jsonData, &data) + if err != nil { + return err + } + + o.Raw = raw + o.Data = data + + return nil +} diff --git a/components/ui-api-layer/internal/domain/content/topics_converter.go b/components/ui-api-layer/internal/domain/content/topics_converter.go new file mode 100644 index 000000000000..d7da9fce414b --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/topics_converter.go @@ -0,0 +1,79 @@ +package content + +import ( + "fmt" + + "strings" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + funk "github.com/thoas/go-funk" +) + +type topicsConverter struct{} + +func (c *topicsConverter) ToGQL(in []gqlschema.TopicEntry) *gqlschema.JSON { + if in == nil { + return nil + } + + result := make(gqlschema.JSON) + + result["topics"] = in + + return &result +} + +func (c *topicsConverter) getUniqueTypes(documents []storage.Document) []string { + r := funk.Map(documents, func(x storage.Document) string { + return x.Type + }) + + return funk.UniqString(r.([]string)) +} + +func (r *topicsConverter) getAnchor(input string) string { + anchor := strings.TrimSpace(input) + anchor = strings.Replace(input, " ", "-", -1) + + return strings.ToLower(anchor) +} + +func (c *topicsConverter) ExtractSection(documents []storage.Document, internal bool) ([]gqlschema.Section, error) { + var topics []gqlschema.Section + types := c.getUniqueTypes(documents) + + for _, t := range types { + entry := gqlschema.Section{TopicType: t} + + doc, ok := funk.Filter(documents, func(x storage.Document) bool { + if internal == true { + return x.Type == t + } else { + return x.Type == t && x.Internal == false + } + + }).([]storage.Document) + + if !ok { + return []gqlschema.Section{}, fmt.Errorf("while converting object from interface to []Docs") + } + + //if there is only one document of certain type, title equals to the title of this docuemnt, otherwise - we go deeper + if len(doc) == 1 { + tit := gqlschema.Title{Name: doc[0].Title, Anchor: c.getAnchor(doc[0].Title)} + entry.Titles = append(entry.Titles, tit) + } else { + tit := gqlschema.Title{Name: t, Anchor: c.getAnchor(t)} + + for _, d := range doc { + titChild := gqlschema.Title{Name: d.Title, Anchor: c.getAnchor(d.Title)} + tit.Titles = append(tit.Titles, titChild) + } + entry.Titles = append(entry.Titles, tit) + } + topics = append(topics, entry) + } + + return topics, nil +} diff --git a/components/ui-api-layer/internal/domain/content/topics_converter_test.go b/components/ui-api-layer/internal/domain/content/topics_converter_test.go new file mode 100644 index 000000000000..47692f86aabf --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/topics_converter_test.go @@ -0,0 +1,156 @@ +package content + +import ( + "testing" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" +) + +func TestTopicsConverter_ToQGL(t *testing.T) { + t.Run("Success", func(t *testing.T) { + + topics := []gqlschema.Section{ + { + Titles: []gqlschema.Title{ + { + Name: "Title1", + Anchor: "title1", + Titles: []gqlschema.Title{ + { + Name: "Title2", + Anchor: "title2", + }, + }, + }, + }, + TopicType: "test1", + }, + } + + topOutput := []gqlschema.TopicEntry{ + { + ContentType: "test1", + ID: "id1", + Sections: topics, + }, + } + + expectedResult := &gqlschema.JSON{ + "topics": []gqlschema.TopicEntry{ + { + ContentType: "test1", + ID: "id1", + Sections: topics, + }, + }, + } + + converter := &topicsConverter{} + + result := converter.ToGQL(topOutput) + assert.Equal(t, expectedResult, result) + }) +} + +func TestTopicsConverter_ExtractSection(t *testing.T) { + t.Run("Topics with internal false", func(t *testing.T) { + + expectedResult := []gqlschema.Section{ + {TopicType: "test1", Titles: []gqlschema.Title{ + {Name: "test1", Titles: []gqlschema.Title{ + {Name: "Test1", Anchor: "test1"}, + {Name: "Test2", Anchor: "test2"}, + {Name: "Test3", Anchor: "test3"}, + }, Anchor: "test1"}, + }}, + {TopicType: "test2", Titles: []gqlschema.Title{ + {Name: "Alone1", Anchor: "alone1"}}, + }, + } + + docs := []storage.Document{{Title: "Test1", Type: "test1"}, {Title: "Test2", Type: "test1"}, {Title: "Test3", Type: "test1"}, {Title: "Alone1", Type: "test2"}} + + converter := &topicsConverter{} + + result, err := converter.ExtractSection(docs, false) + assert.Nil(t, err) + + assert.Equal(t, expectedResult, result) + }) + + t.Run("Topics with external true", func(t *testing.T) { + expectedResultInternalFalse := []gqlschema.Section{ + { + TopicType: "internalTest1", + Titles: []gqlschema.Title{ + { + Name: "internalTest1", + Titles: []gqlschema.Title{ + {Name: "Test1", Anchor: "test1"}, + {Name: "Test2", Anchor: "test2"}, + {Name: "Test3", Anchor: "test3"}, + {Name: "Test4", Anchor: "test4"}, + }, + Anchor: "internaltest1", + }, + }, + }, + } + + expectedResultInternalTrue := []gqlschema.Section{ + { + TopicType: "internalTest1", + Titles: []gqlschema.Title{ + { + Name: "internalTest1", + Anchor: "internaltest1", + Titles: []gqlschema.Title{ + { + Name: "Test3", Anchor: "test3"}, + { + Name: "Test4", + Anchor: "test4", + }, + }, + }, + }, + }, + } + + d1 := storage.Document{Title: "Test1", Type: "internalTest1", Internal: true} + d2 := storage.Document{Title: "Test2", Type: "internalTest1", Internal: true} + d3 := storage.Document{Title: "Test3", Type: "internalTest1"} + d4 := storage.Document{Title: "Test4", Type: "internalTest1"} + + docs := []storage.Document{d1, d2, d3, d4} + + converter := &topicsConverter{} + + result, err := converter.ExtractSection(docs, true) + + assert.Nil(t, err) + assert.Equal(t, expectedResultInternalFalse, result) + + result, err = converter.ExtractSection(docs, false) + + assert.Nil(t, err) + assert.Equal(t, expectedResultInternalTrue, result) + + }) + + t.Run("Empty docs input", func(t *testing.T) { + + expectedResult := []gqlschema.Section(nil) + var docs []storage.Document + + converter := &topicsConverter{} + result, err := converter.ExtractSection(docs, true) + + assert.Nil(t, err) + assert.Equal(t, expectedResult, result) + + }) + +} diff --git a/components/ui-api-layer/internal/domain/content/topics_resolver.go b/components/ui-api-layer/internal/domain/content/topics_resolver.go new file mode 100644 index 000000000000..e6d0a9159834 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/topics_resolver.go @@ -0,0 +1,60 @@ +package content + +import ( + "context" + + "github.com/golang/glog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/pkg/errors" +) + +type topicsResolver struct { + contentGetter contentGetter + converter topicsConverterInterface +} + +func newTopicsResolver(contentGetter contentGetter) *topicsResolver { + return &topicsResolver{ + contentGetter: contentGetter, + converter: &topicsConverter{}, + } +} + +func (r *topicsResolver) TopicsQuery(ctx context.Context, topics []gqlschema.InputTopic, internal *bool) ([]gqlschema.TopicEntry, error) { + + var tOutput []gqlschema.TopicEntry + var includeInternal bool + + if internal == nil { + includeInternal = false + } else { + includeInternal = *internal + } + + for _, t := range topics { + contentType := t.Type + id := t.ID + + topic := gqlschema.TopicEntry{ContentType: contentType, ID: id} + item, err := r.contentGetter.Find(contentType, id) + if err != nil { + glog.Error(errors.Wrapf(err, "while gathering content for type `%s` with id `%s`", contentType, id)) + return nil, r.genericError() + } + if item != nil { + topic.Sections, err = r.converter.ExtractSection(item.Data.Docs, includeInternal) + if err != nil { + glog.Error(errors.Wrapf(err, "while extracting topics for type `%s` with id `%s`", contentType, id)) + return nil, r.genericError() + } + + tOutput = append(tOutput, topic) + } + } + + return tOutput, nil +} + +func (r *topicsResolver) genericError() error { + return errors.New("Cannot get Topics") +} diff --git a/components/ui-api-layer/internal/domain/content/topics_resolver_test.go b/components/ui-api-layer/internal/domain/content/topics_resolver_test.go new file mode 100644 index 000000000000..e6945fecbd48 --- /dev/null +++ b/components/ui-api-layer/internal/domain/content/topics_resolver_test.go @@ -0,0 +1,178 @@ +package content_test + +import ( + "errors" + "testing" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/automock" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTopicsResolver_TopicsQuery(t *testing.T) { + + t.Run("Success with internal true", func(t *testing.T) { + cnt := &storage.Content{ + Raw: map[string]interface{}{ + "Description": "data", + "DisplayName": "data", + "Docs": []map[string]interface{}{ + {"Order": "", + "Source": "", + "Title": "", + "Type": "", + "Internal": true, + }, + }, + "ID": "data", + "Internal": true, + }, + Data: storage.ContentData{ + Description: "data", + DisplayName: "data", + Docs: []storage.Document{ + { + Order: "", + Source: "", + Title: "", + Type: "", + Internal: true, + }, + }, + ID: "data", + }, + } + + expectedResult := []gqlschema.TopicEntry{ + {ContentType: "test1", ID: "test1", Sections: []gqlschema.Section{ + {Titles: nil, TopicType: "Test"}, + }}, + {ContentType: "test2", ID: "test2", Sections: []gqlschema.Section{ + {Titles: nil, TopicType: "Test"}, + }}, + {ContentType: "test3", ID: "test3", Sections: []gqlschema.Section{ + {Titles: nil, TopicType: "Test"}, + }}, + } + + getter := automock.NewContentGetter() + getter.On("Find", "test1", "test1").Return(cnt, nil) + getter.On("Find", "test2", "test2").Return(cnt, nil) + getter.On("Find", "test3", "test3").Return(cnt, nil) + + converter := automock.NewMockTopicsConverter() + converter.On("ExtractSection", []storage.Document{{Order: "", Source: "", Title: "", Type: "", Internal: true}}, true).Return([]gqlschema.Section{ + { + Titles: nil, + TopicType: "Test", + }, + }, nil).Times(3) + + defer converter.AssertExpectations(t) + + resolver := content.NewTopicsResolver(getter) + resolver.SetTopicsConverter(converter) + + internal := true + + inputTopic := []gqlschema.InputTopic{{ID: "test1", Type: "test1"}, {ID: "test2", Type: "test2"}, {ID: "test3", Type: "test3"}} + + result, err := resolver.TopicsQuery(nil, inputTopic, &internal) + + require.NoError(t, err) + assert.Equal(t, expectedResult, result) + }) + + t.Run("Success with internal false", func(t *testing.T) { + + cnt := &storage.Content{ + Raw: map[string]interface{}{ + "Description": "data", + "DisplayName": "data", + "Docs": []map[string]interface{}{ + {"Order": "", + "Source": "", + "Title": "", + "Type": "", + "Internal": true, + }, + }, + "ID": "data", + "Internal": true, + }, + Data: storage.ContentData{ + Description: "data", + DisplayName: "data", + Docs: []storage.Document{ + { + Order: "", + Source: "", + Title: "", + Type: "", + Internal: true, + }, + }, + ID: "data", + }, + } + + getter := automock.NewContentGetter() + getter.On("Find", "test1", "test1").Return(cnt, nil) + getter.On("Find", "test2", "test2").Return(cnt, nil) + getter.On("Find", "test3", "test3").Return(cnt, nil) + + converter := automock.NewMockTopicsConverter() + converter.On("ExtractSection", []storage.Document{{Order: "", Source: "", Title: "", Type: "", Internal: true}}, false).Return([]gqlschema.Section{ + { + Titles: nil, + TopicType: "Test", + }, + }, nil).Times(3) + + defer converter.AssertExpectations(t) + + resolver := content.NewTopicsResolver(getter) + resolver.SetTopicsConverter(converter) + + inputTopic := []gqlschema.InputTopic{{ID: "test1", Type: "test1"}, {ID: "test2", Type: "test2"}, {ID: "test3", Type: "test3"}} + result, err := resolver.TopicsQuery(nil, inputTopic, nil) + + expectedResult := []gqlschema.TopicEntry{ + {ContentType: "test1", ID: "test1", Sections: []gqlschema.Section{ + {Titles: nil, TopicType: "Test"}, + }}, + {ContentType: "test2", ID: "test2", Sections: []gqlschema.Section{ + {Titles: nil, TopicType: "Test"}, + }}, + {ContentType: "test3", ID: "test3", Sections: []gqlschema.Section{ + {Titles: nil, TopicType: "Test"}, + }}, + } + + require.NoError(t, err) + assert.Equal(t, expectedResult, result) + }) + + t.Run("Error when content is not found", func(t *testing.T) { + + getter := automock.NewContentGetter() + getter.On("Find", "test1", "test1").Return(nil, errors.New("Test")) + + converter := automock.NewMockTopicsConverter() + + defer converter.AssertExpectations(t) + + resolver := content.NewTopicsResolver(getter) + resolver.SetTopicsConverter(converter) + + inputTopic := []gqlschema.InputTopic{{ID: "test1", Type: "test1"}} + result, err := resolver.TopicsQuery(nil, inputTopic, nil) + + require.Error(t, err) + assert.Nil(t, result) + }) + +} diff --git a/components/ui-api-layer/internal/domain/k8s/automock/deployment_lister.go b/components/ui-api-layer/internal/domain/k8s/automock/deployment_lister.go new file mode 100644 index 000000000000..8504d0cb43df --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/automock/deployment_lister.go @@ -0,0 +1,56 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import v1beta2 "k8s.io/api/apps/v1beta2" + +// deploymentLister is an autogenerated mock type for the deploymentLister type +type deploymentLister struct { + mock.Mock +} + +// List provides a mock function with given fields: environment +func (_m *deploymentLister) List(environment string) ([]*v1beta2.Deployment, error) { + ret := _m.Called(environment) + + var r0 []*v1beta2.Deployment + if rf, ok := ret.Get(0).(func(string) []*v1beta2.Deployment); ok { + r0 = rf(environment) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1beta2.Deployment) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(environment) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ListWithoutFunctions provides a mock function with given fields: environment +func (_m *deploymentLister) ListWithoutFunctions(environment string) ([]*v1beta2.Deployment, error) { + ret := _m.Called(environment) + + var r0 []*v1beta2.Deployment + if rf, ok := ret.Get(0).(func(string) []*v1beta2.Deployment); ok { + r0 = rf(environment) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1beta2.Deployment) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(environment) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/k8s/automock/env_lister.go b/components/ui-api-layer/internal/domain/k8s/automock/env_lister.go new file mode 100644 index 000000000000..13a37a884578 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/automock/env_lister.go @@ -0,0 +1,57 @@ +// Code generated by mockery v1.0.0 +package automock + +import gqlschema "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + +import mock "github.com/stretchr/testify/mock" + +// envLister is an autogenerated mock type for the envLister type +type envLister struct { + mock.Mock +} + +// List provides a mock function with given fields: +func (_m *envLister) List() ([]gqlschema.Environment, error) { + ret := _m.Called() + + var r0 []gqlschema.Environment + if rf, ok := ret.Get(0).(func() []gqlschema.Environment); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gqlschema.Environment) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ListForRemoteEnvironment provides a mock function with given fields: reName +func (_m *envLister) ListForRemoteEnvironment(reName string) ([]gqlschema.Environment, error) { + ret := _m.Called(reName) + + var r0 []gqlschema.Environment + if rf, ok := ret.Get(0).(func(string) []gqlschema.Environment); ok { + r0 = rf(reName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gqlschema.Environment) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(reName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/k8s/automock/expect.go b/components/ui-api-layer/internal/domain/k8s/automock/expect.go new file mode 100644 index 000000000000..8fa537999cc1 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/automock/expect.go @@ -0,0 +1,13 @@ +package automock + +import ( + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +) + +func (m *envLister) ExpectOnListAllEnvironments(envs []gqlschema.Environment, err error) { + m.On("List").Return(envs, err) +} + +func (m *envLister) ExpectOnListEnvironmentsForRemoteEnvironment(reName string, envs []gqlschema.Environment, err error) { + m.On("ListForRemoteEnvironment", reName).Return(envs, err) +} diff --git a/components/ui-api-layer/internal/domain/k8s/automock/export.go b/components/ui-api-layer/internal/domain/k8s/automock/export.go new file mode 100644 index 000000000000..eabf27dcb6a8 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/automock/export.go @@ -0,0 +1,13 @@ +package automock + +func NewDeploymentLister() *deploymentLister { + return new(deploymentLister) +} + +func NewEnvLister() *envLister { + return new(envLister) +} + +func NewResourceQuotaLister() *resourceQuotaLister { + return new(resourceQuotaLister) +} diff --git a/components/ui-api-layer/internal/domain/k8s/automock/limit_range_lister.go b/components/ui-api-layer/internal/domain/k8s/automock/limit_range_lister.go new file mode 100644 index 000000000000..fb7c8b6614d0 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/automock/limit_range_lister.go @@ -0,0 +1,33 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import v1 "k8s.io/api/core/v1" + +// limitRangeLister is an autogenerated mock type for the limitRangeLister type +type limitRangeLister struct { + mock.Mock +} + +// List provides a mock function with given fields: env +func (_m *limitRangeLister) List(env string) ([]*v1.LimitRange, error) { + ret := _m.Called(env) + + var r0 []*v1.LimitRange + if rf, ok := ret.Get(0).(func(string) []*v1.LimitRange); ok { + r0 = rf(env) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1.LimitRange) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(env) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/k8s/automock/resource_quota_lister.go b/components/ui-api-layer/internal/domain/k8s/automock/resource_quota_lister.go new file mode 100644 index 000000000000..25b4074d2fe3 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/automock/resource_quota_lister.go @@ -0,0 +1,33 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import v1 "k8s.io/api/core/v1" + +// resourceQuotaLister is an autogenerated mock type for the resourceQuotaLister type +type resourceQuotaLister struct { + mock.Mock +} + +// List provides a mock function with given fields: environment +func (_m *resourceQuotaLister) List(environment string) ([]*v1.ResourceQuota, error) { + ret := _m.Called(environment) + + var r0 []*v1.ResourceQuota + if rf, ok := ret.Get(0).(func(string) []*v1.ResourceQuota); ok { + r0 = rf(environment) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1.ResourceQuota) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(environment) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/k8s/automock/service_binding_getter.go b/components/ui-api-layer/internal/domain/k8s/automock/service_binding_getter.go new file mode 100644 index 000000000000..d9d2deb961a4 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/automock/service_binding_getter.go @@ -0,0 +1,33 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import v1beta1 "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + +// ServiceBindingGetter is an autogenerated mock type for the ServiceBindingGetter type +type ServiceBindingGetter struct { + mock.Mock +} + +// Find provides a mock function with given fields: env, name +func (_m *ServiceBindingGetter) Find(env string, name string) (*v1beta1.ServiceBinding, error) { + ret := _m.Called(env, name) + + var r0 *v1beta1.ServiceBinding + if rf, ok := ret.Get(0).(func(string, string) *v1beta1.ServiceBinding); ok { + r0 = rf(env, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1beta1.ServiceBinding) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(env, name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/k8s/automock/service_binding_usage_lister.go b/components/ui-api-layer/internal/domain/k8s/automock/service_binding_usage_lister.go new file mode 100644 index 000000000000..9064dcb5231d --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/automock/service_binding_usage_lister.go @@ -0,0 +1,33 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import v1alpha1 "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + +// ServiceBindingUsageLister is an autogenerated mock type for the ServiceBindingUsageLister type +type ServiceBindingUsageLister struct { + mock.Mock +} + +// ListForDeployment provides a mock function with given fields: environment, kind, deploymentName +func (_m *ServiceBindingUsageLister) ListForDeployment(environment string, kind string, deploymentName string) ([]*v1alpha1.ServiceBindingUsage, error) { + ret := _m.Called(environment, kind, deploymentName) + + var r0 []*v1alpha1.ServiceBindingUsage + if rf, ok := ret.Get(0).(func(string, string, string) []*v1alpha1.ServiceBindingUsage); ok { + r0 = rf(environment, kind, deploymentName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1alpha1.ServiceBindingUsage) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string, string) error); ok { + r1 = rf(environment, kind, deploymentName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/k8s/deployment_converter.go b/components/ui-api-layer/internal/domain/k8s/deployment_converter.go new file mode 100644 index 000000000000..ac3b92da5b00 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/deployment_converter.go @@ -0,0 +1,81 @@ +package k8s + +import ( + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + api "k8s.io/api/apps/v1beta2" +) + +type deploymentConverter struct{} + +func (c *deploymentConverter) ToGQL(in *api.Deployment) *gqlschema.Deployment { + if in == nil { + return nil + } + + labels := make(gqlschema.JSON) + for k, v := range in.Labels { + labels[k] = v + } + + return &gqlschema.Deployment{ + Name: in.Name, + Environment: in.Namespace, + CreationTimestamp: in.CreationTimestamp.Time, + Labels: labels, + Status: c.toGQLStatus(*in), + Containers: c.toGQLContainers(*in), + } +} + +func (c *deploymentConverter) ToGQLs(in []*api.Deployment) []gqlschema.Deployment { + var result []gqlschema.Deployment + for _, item := range in { + converted := c.ToGQL(item) + + if converted != nil { + result = append(result, *converted) + } + } + + return result +} + +func (c *deploymentConverter) toGQLStatus(in api.Deployment) gqlschema.DeploymentStatus { + var conditions []gqlschema.DeploymentCondition + for _, condition := range in.Status.Conditions { + conditions = append(conditions, c.toGQLCondition(condition)) + } + + return gqlschema.DeploymentStatus{ + AvailableReplicas: int(in.Status.AvailableReplicas), + ReadyReplicas: int(in.Status.ReadyReplicas), + Replicas: int(in.Status.Replicas), + UpdatedReplicas: int(in.Status.UpdatedReplicas), + Conditions: conditions, + } +} + +func (c *deploymentConverter) toGQLCondition(in api.DeploymentCondition) gqlschema.DeploymentCondition { + return gqlschema.DeploymentCondition{ + Reason: in.Reason, + Message: in.Message, + LastUpdateTimestamp: in.LastUpdateTime.Time, + LastTransitionTimestamp: in.LastTransitionTime.Time, + Type: string(in.Type), + Status: string(in.Status), + } +} + +func (c *deploymentConverter) toGQLContainers(in api.Deployment) []gqlschema.Container { + var containers []gqlschema.Container + for _, container := range in.Spec.Template.Spec.Containers { + gqlContainer := gqlschema.Container{ + Name: container.Name, + Image: container.Image, + } + + containers = append(containers, gqlContainer) + } + + return containers +} diff --git a/components/ui-api-layer/internal/domain/k8s/deployment_converter_test.go b/components/ui-api-layer/internal/domain/k8s/deployment_converter_test.go new file mode 100644 index 000000000000..e188d851e208 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/deployment_converter_test.go @@ -0,0 +1,148 @@ +package k8s + +import ( + "testing" + "time" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsApi "k8s.io/api/apps/v1beta2" + coreApi "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestDeploymentConverter_ToGQL(t *testing.T) { + t.Run("All properties are given", func(t *testing.T) { + var zeroTimeStamp time.Time + + deployment := fixDeployment() + + expected := &gqlschema.Deployment{ + Name: "name", + CreationTimestamp: zeroTimeStamp, + Environment: "namespace", + Labels: gqlschema.JSON{"test": "ok", "ok": "test"}, + Status: gqlschema.DeploymentStatus{ + Replicas: 1, + AvailableReplicas: 1, + ReadyReplicas: 1, + UpdatedReplicas: 1, + Conditions: []gqlschema.DeploymentCondition{ + { + Status: "True", + Type: "Available", + Message: "message", + Reason: "reason", + }, + }, + }, + Containers: []gqlschema.Container{ + { + Name: "test", + Image: "image", + }, + }, + } + + converter := &deploymentConverter{} + result := converter.ToGQL(deployment) + + require.NotNil(t, result) + assert.Equal(t, expected, result) + }) + + t.Run("Empty", func(t *testing.T) { + converter := &deploymentConverter{} + converter.ToGQL(&appsApi.Deployment{}) + }) + + t.Run("Nil", func(t *testing.T) { + converter := &deploymentConverter{} + result := converter.ToGQL(nil) + + assert.Nil(t, result) + }) +} + +func TestDeploymentConverter_ToGQLs(t *testing.T) { + t.Run("Success", func(t *testing.T) { + deployments := []*appsApi.Deployment{ + fixDeployment(), + fixDeployment(), + } + + converter := deploymentConverter{} + result := converter.ToGQLs(deployments) + + assert.Len(t, result, 2) + assert.Equal(t, "name", result[0].Name) + }) + + t.Run("Empty", func(t *testing.T) { + var deployments []*appsApi.Deployment + + converter := deploymentConverter{} + result := converter.ToGQLs(deployments) + + assert.Empty(t, result) + }) + + t.Run("With nil", func(t *testing.T) { + deployments := []*appsApi.Deployment{ + nil, + fixDeployment(), + nil, + } + + converter := deploymentConverter{} + result := converter.ToGQLs(deployments) + + assert.Len(t, result, 1) + assert.Equal(t, "name", result[0].Name) + }) +} + +func fixDeployment() *appsApi.Deployment { + var mockTimeStamp v1.Time + + return &appsApi.Deployment{ + ObjectMeta: v1.ObjectMeta{ + Name: "name", + Namespace: "namespace", + CreationTimestamp: mockTimeStamp, + Labels: map[string]string{ + "test": "ok", + "ok": "test", + }, + }, + Status: appsApi.DeploymentStatus{ + UpdatedReplicas: 1, + Replicas: 1, + ReadyReplicas: 1, + AvailableReplicas: 1, + Conditions: []appsApi.DeploymentCondition{ + { + Reason: "reason", + Message: "message", + LastUpdateTime: mockTimeStamp, + LastTransitionTime: mockTimeStamp, + Type: appsApi.DeploymentAvailable, + Status: coreApi.ConditionTrue, + }, + }, + }, + Spec: appsApi.DeploymentSpec{ + Template: coreApi.PodTemplateSpec{ + Spec: coreApi.PodSpec{ + Containers: []coreApi.Container{ + { + Name: "test", + Image: "image", + }, + }, + }, + }, + }, + } +} diff --git a/components/ui-api-layer/internal/domain/k8s/deployment_resolver.go b/components/ui-api-layer/internal/domain/k8s/deployment_resolver.go new file mode 100644 index 000000000000..42ce4d88784f --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/deployment_resolver.go @@ -0,0 +1,88 @@ +package k8s + +import ( + "context" + + "github.com/golang/glog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/pkg/errors" + "k8s.io/api/apps/v1beta2" +) + +type deploymentResolver struct { + deploymentLister deploymentLister + deploymentConverter *deploymentConverter + serviceBindingUsageLister ServiceBindingUsageLister + serviceBindingGetter ServiceBindingGetter +} + +func newDeploymentResolver(deploymentLister deploymentLister, serviceBindingUsageLister ServiceBindingUsageLister, serviceBindingGetter ServiceBindingGetter) *deploymentResolver { + return &deploymentResolver{ + deploymentLister: deploymentLister, + serviceBindingUsageLister: serviceBindingUsageLister, + serviceBindingGetter: serviceBindingGetter, + } +} + +func (r *deploymentResolver) DeploymentsQuery(ctx context.Context, environment string, excludeFunctions *bool) ([]gqlschema.Deployment, error) { + var deployments []*v1beta2.Deployment + var err error + if excludeFunctions == nil || !*excludeFunctions { + deployments, err = r.deploymentLister.List(environment) + } else { + deployments, err = r.deploymentLister.ListWithoutFunctions(environment) + } + + if err != nil { + glog.Error(errors.Wrapf(err, "while listing Deployments in environment `%s`", environment)) + return nil, r.genericError() + } + + return r.deploymentConverter.ToGQLs(deployments), nil +} + +func (r *deploymentResolver) DeploymentBoundServiceInstanceNamesField(ctx context.Context, deployment *gqlschema.Deployment) ([]string, error) { + if deployment == nil { + glog.Error(errors.New("Deployment cannot be empty in order to resolve ServiceInstanceNames for Deployment")) + return nil, r.serviceInstanceNamesError() + } + + kind := "deployment" + if _, exists := deployment.Labels["function"]; exists { + kind = "function" + } + + usages, err := r.serviceBindingUsageLister.ListForDeployment(deployment.Environment, kind, deployment.Name) + if err != nil { + glog.Error(errors.Wrapf(err, "while listing ServiceBindingUsages for Deployment in environment `%s`, name `%s` and kind `%s`", deployment.Environment, deployment.Name, kind)) + return nil, r.serviceInstanceNamesError() + } + + instanceNames := make(map[string]struct{}) + for _, usage := range usages { + binding, err := r.serviceBindingGetter.Find(deployment.Environment, usage.Spec.ServiceBindingRef.Name) + if err != nil { + glog.Error(errors.Wrapf(err, "while gathering ServiceBinding for environment `%s` environment with name `%s`", deployment.Environment, usage.Spec.ServiceBindingRef.Name)) + return nil, r.serviceInstanceNamesError() + } + + if binding != nil { + instanceNames[binding.Spec.ServiceInstanceRef.Name] = struct{}{} + } + } + + result := make([]string, 0, len(instanceNames)) + for name := range instanceNames { + result = append(result, name) + } + + return result, nil +} + +func (r *deploymentResolver) genericError() error { + return errors.New("Cannot get Deployment") +} + +func (r *deploymentResolver) serviceInstanceNamesError() error { + return errors.New("Cannot list ServiceInstance names") +} diff --git a/components/ui-api-layer/internal/domain/k8s/deployment_resolver_test.go b/components/ui-api-layer/internal/domain/k8s/deployment_resolver_test.go new file mode 100644 index 000000000000..0f3e0f8122ad --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/deployment_resolver_test.go @@ -0,0 +1,322 @@ +package k8s_test + +import ( + "testing" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "k8s.io/api/apps/v1beta2" + + "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/k8s" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/k8s/automock" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +) + +func TestDeploymentResolver_DeploymentsQuery(t *testing.T) { + environment := "test" + + t.Run("Success with default", func(t *testing.T) { + deployment := fixDeployment("test", environment, "function") + deployments := []*v1beta2.Deployment{deployment, deployment} + + expected := gqlschema.Deployment{ + Name: "test", + Environment: environment, + Labels: gqlschema.JSON{ + "function": "", + }, + } + + svc := automock.NewDeploymentLister() + svc.On("List", environment).Return(deployments, nil).Once() + svc.On("ListWithoutFunctions", mock.Anything, mock.Anything).Return(deployments, nil).Once() + resolver := k8s.NewDeploymentResolver(svc, nil, nil) + + result, err := resolver.DeploymentsQuery(nil, environment, nil) + + require.NoError(t, err) + assert.Equal(t, []gqlschema.Deployment{expected, expected}, result) + svc.AssertNotCalled(t, "ListWithoutFunctions", mock.Anything, mock.Anything) + }) + + t.Run("Success with functions", func(t *testing.T) { + deployment := fixDeployment("test", environment, "deployment") + deployments := []*v1beta2.Deployment{deployment, deployment} + + expected := gqlschema.Deployment{ + Name: "test", + Environment: environment, + Labels: gqlschema.JSON{ + "deployment": "", + }, + } + + svc := automock.NewDeploymentLister() + svc.On("List", environment).Return(deployments, nil).Once() + svc.On("ListWithoutFunctions", mock.Anything, mock.Anything).Return(deployments, nil).Once() + resolver := k8s.NewDeploymentResolver(svc, nil, nil) + + result, err := resolver.DeploymentsQuery(nil, environment, getBoolPointer(false)) + + require.NoError(t, err) + assert.Equal(t, []gqlschema.Deployment{expected, expected}, result) + svc.AssertNotCalled(t, "ListWithoutFunctions", mock.Anything, mock.Anything) + }) + + t.Run("Success without functions", func(t *testing.T) { + deployment := fixDeployment("test", environment, "function") + deployments := []*v1beta2.Deployment{deployment, deployment} + + expected := gqlschema.Deployment{ + Name: "test", + Environment: environment, + Labels: gqlschema.JSON{ + "function": "", + }, + } + + svc := automock.NewDeploymentLister() + svc.On("List", mock.Anything, mock.Anything).Return(deployments, nil).Once() + svc.On("ListWithoutFunctions", environment).Return(deployments, nil).Once() + resolver := k8s.NewDeploymentResolver(svc, nil, nil) + + result, err := resolver.DeploymentsQuery(nil, environment, getBoolPointer(true)) + + require.NoError(t, err) + assert.Equal(t, []gqlschema.Deployment{expected, expected}, result) + svc.AssertNotCalled(t, "List", mock.Anything, mock.Anything) + }) + + t.Run("Not found with functions", func(t *testing.T) { + svc := automock.NewDeploymentLister() + svc.On("List", environment).Return([]*v1beta2.Deployment{}, nil).Once() + svc.On("ListWithoutFunctions", mock.Anything, mock.Anything).Return([]*v1beta2.Deployment{}, nil).Once() + resolver := k8s.NewDeploymentResolver(svc, nil, nil) + + result, err := resolver.DeploymentsQuery(nil, environment, getBoolPointer(false)) + + require.NoError(t, err) + assert.Empty(t, result) + svc.AssertNotCalled(t, "ListWithoutFunctions", mock.Anything, mock.Anything) + }) + + t.Run("Not found without functions", func(t *testing.T) { + svc := automock.NewDeploymentLister() + svc.On("List", mock.Anything, mock.Anything).Return([]*v1beta2.Deployment{}, nil).Once() + svc.On("ListWithoutFunctions", environment).Return([]*v1beta2.Deployment{}, nil).Once() + resolver := k8s.NewDeploymentResolver(svc, nil, nil) + + result, err := resolver.DeploymentsQuery(nil, environment, getBoolPointer(true)) + + require.NoError(t, err) + assert.Empty(t, result) + svc.AssertNotCalled(t, "List", mock.Anything, mock.Anything) + }) + + t.Run("Error with functions", func(t *testing.T) { + svc := automock.NewDeploymentLister() + svc.On("List", environment).Return(nil, errors.New("test")).Once() + svc.On("ListWithoutFunctions", mock.Anything, mock.Anything).Return(nil, errors.New("test")).Once() + resolver := k8s.NewDeploymentResolver(svc, nil, nil) + + _, err := resolver.DeploymentsQuery(nil, environment, getBoolPointer(false)) + + require.Error(t, err) + svc.AssertNotCalled(t, "ListWithoutFunctions", mock.Anything, mock.Anything) + }) + + t.Run("Error without functions", func(t *testing.T) { + svc := automock.NewDeploymentLister() + svc.On("List", mock.Anything, mock.Anything).Return(nil, errors.New("test")).Once() + svc.On("ListWithoutFunctions", environment).Return(nil, errors.New("test")).Once() + resolver := k8s.NewDeploymentResolver(svc, nil, nil) + + _, err := resolver.DeploymentsQuery(nil, environment, getBoolPointer(true)) + + require.Error(t, err) + svc.AssertNotCalled(t, "List", mock.Anything, mock.Anything) + }) +} + +func TestDeploymentResolver_DeploymentBoundServiceInstanceNamesField(t *testing.T) { + environment := "test" + + t.Run("Success for deployment", func(t *testing.T) { + deployment := &gqlschema.Deployment{ + Name: "test", + Environment: environment, + Labels: gqlschema.JSON{}, + } + + usage := &v1alpha1.ServiceBindingUsage{ + Spec: v1alpha1.ServiceBindingUsageSpec{ + ServiceBindingRef: v1alpha1.LocalReferenceByName{ + Name: "test", + }, + }, + } + + binding := &v1beta1.ServiceBinding{ + Spec: v1beta1.ServiceBindingSpec{ + ServiceInstanceRef: v1beta1.LocalObjectReference{ + Name: "instance", + }, + }, + } + + lister := new(automock.ServiceBindingUsageLister) + lister.On("ListForDeployment", deployment.Environment, "deployment", deployment.Name).Return([]*v1alpha1.ServiceBindingUsage{usage}, nil) + getter := new(automock.ServiceBindingGetter) + getter.On("Find", deployment.Environment, usage.Spec.ServiceBindingRef.Name).Return(binding, nil) + resolver := k8s.NewDeploymentResolver(nil, lister, getter) + + result, err := resolver.DeploymentBoundServiceInstanceNamesField(nil, deployment) + require.NoError(t, err) + assert.Equal(t, []string{ + "instance", + }, result) + }) + + t.Run("Success for function", func(t *testing.T) { + deployment := &gqlschema.Deployment{ + Name: "test", + Environment: environment, + Labels: gqlschema.JSON{ + "function": "", + }, + } + + usage := &v1alpha1.ServiceBindingUsage{ + Spec: v1alpha1.ServiceBindingUsageSpec{ + ServiceBindingRef: v1alpha1.LocalReferenceByName{ + Name: "test", + }, + }, + } + + binding := &v1beta1.ServiceBinding{ + Spec: v1beta1.ServiceBindingSpec{ + ServiceInstanceRef: v1beta1.LocalObjectReference{ + Name: "instance", + }, + }, + } + + lister := new(automock.ServiceBindingUsageLister) + lister.On("ListForDeployment", deployment.Environment, "function", deployment.Name).Return([]*v1alpha1.ServiceBindingUsage{usage}, nil) + getter := new(automock.ServiceBindingGetter) + getter.On("Find", deployment.Environment, usage.Spec.ServiceBindingRef.Name).Return(binding, nil) + resolver := k8s.NewDeploymentResolver(nil, lister, getter) + + result, err := resolver.DeploymentBoundServiceInstanceNamesField(nil, deployment) + require.NoError(t, err) + assert.Equal(t, []string{ + "instance", + }, result) + }) + + t.Run("No usages", func(t *testing.T) { + deployment := &gqlschema.Deployment{ + Name: "test", + Environment: environment, + Labels: gqlschema.JSON{ + "function": "", + }, + } + + lister := new(automock.ServiceBindingUsageLister) + lister.On("ListForDeployment", deployment.Environment, "function", deployment.Name).Return([]*v1alpha1.ServiceBindingUsage{}, nil) + resolver := k8s.NewDeploymentResolver(nil, lister, nil) + + result, err := resolver.DeploymentBoundServiceInstanceNamesField(nil, deployment) + require.NoError(t, err) + assert.Empty(t, result) + }) + + t.Run("No binding", func(t *testing.T) { + deployment := &gqlschema.Deployment{ + Name: "test", + Environment: environment, + Labels: gqlschema.JSON{ + "function": "", + }, + } + + usage := &v1alpha1.ServiceBindingUsage{ + Spec: v1alpha1.ServiceBindingUsageSpec{ + ServiceBindingRef: v1alpha1.LocalReferenceByName{ + Name: "test", + }, + }, + } + + lister := new(automock.ServiceBindingUsageLister) + lister.On("ListForDeployment", deployment.Environment, "function", deployment.Name).Return([]*v1alpha1.ServiceBindingUsage{usage}, nil) + getter := new(automock.ServiceBindingGetter) + getter.On("Find", deployment.Environment, usage.Spec.ServiceBindingRef.Name).Return(nil, nil) + resolver := k8s.NewDeploymentResolver(nil, lister, getter) + + result, err := resolver.DeploymentBoundServiceInstanceNamesField(nil, deployment) + require.NoError(t, err) + assert.Empty(t, result) + }) + + t.Run("Error when deployment not provided", func(t *testing.T) { + resolver := k8s.NewDeploymentResolver(nil, nil, nil) + + _, err := resolver.DeploymentBoundServiceInstanceNamesField(nil, nil) + require.Error(t, err) + }) + + t.Run("Error while listing usages", func(t *testing.T) { + deployment := &gqlschema.Deployment{ + Name: "test", + Environment: environment, + Labels: gqlschema.JSON{ + "function": "", + }, + } + + lister := new(automock.ServiceBindingUsageLister) + lister.On("ListForDeployment", deployment.Environment, "function", deployment.Name).Return([]*v1alpha1.ServiceBindingUsage{}, errors.New("trolololo")) + resolver := k8s.NewDeploymentResolver(nil, lister, nil) + + _, err := resolver.DeploymentBoundServiceInstanceNamesField(nil, deployment) + require.Error(t, err) + }) + + t.Run("Error while getting binding", func(t *testing.T) { + deployment := &gqlschema.Deployment{ + Name: "test", + Environment: environment, + Labels: gqlschema.JSON{ + "function": "", + }, + } + + usage := &v1alpha1.ServiceBindingUsage{ + Spec: v1alpha1.ServiceBindingUsageSpec{ + ServiceBindingRef: v1alpha1.LocalReferenceByName{ + Name: "test", + }, + }, + } + + lister := new(automock.ServiceBindingUsageLister) + lister.On("ListForDeployment", deployment.Environment, "function", deployment.Name).Return([]*v1alpha1.ServiceBindingUsage{usage}, nil) + getter := new(automock.ServiceBindingGetter) + getter.On("Find", deployment.Environment, usage.Spec.ServiceBindingRef.Name).Return(nil, errors.New("trolololo")) + resolver := k8s.NewDeploymentResolver(nil, lister, getter) + + _, err := resolver.DeploymentBoundServiceInstanceNamesField(nil, deployment) + require.Error(t, err) + }) +} + +func getBoolPointer(value bool) *bool { + return &value +} diff --git a/components/ui-api-layer/internal/domain/k8s/deployment_service.go b/components/ui-api-layer/internal/domain/k8s/deployment_service.go new file mode 100644 index 000000000000..88ff50cb0b36 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/deployment_service.go @@ -0,0 +1,76 @@ +package k8s + +import ( + "fmt" + + "github.com/pkg/errors" + api "k8s.io/api/apps/v1beta2" + "k8s.io/client-go/tools/cache" +) + +type deploymentService struct { + informer cache.SharedIndexInformer +} + +func newDeploymentService(informer cache.SharedIndexInformer) *deploymentService { + svc := &deploymentService{ + informer: informer, + } + + informer.AddIndexers(cache.Indexers{ + "functionFilter": func(obj interface{}) ([]string, error) { + deployment, err := svc.toDeployment(obj) + if err != nil { + return nil, errors.Wrapf(err, "while indexing by `functionFilter`") + } + + _, isFunction := deployment.Labels["function"] + key := fmt.Sprintf("%s/%t", deployment.Namespace, isFunction) + return []string{key}, nil + }, + }) + + return svc +} + +func (svc *deploymentService) List(environment string) ([]*api.Deployment, error) { + items, err := svc.informer.GetIndexer().ByIndex("namespace", environment) + if err != nil { + return nil, err + } + + return svc.toDeployments(items) +} + +func (svc *deploymentService) ListWithoutFunctions(environment string) ([]*api.Deployment, error) { + key := fmt.Sprintf("%s/false", environment) + items, err := svc.informer.GetIndexer().ByIndex("functionFilter", key) + if err != nil { + return nil, err + } + + return svc.toDeployments(items) +} + +func (svc *deploymentService) toDeployments(items []interface{}) ([]*api.Deployment, error) { + var deployments []*api.Deployment + for _, item := range items { + deployment, err := svc.toDeployment(item) + if err != nil { + return nil, err + } + + deployments = append(deployments, deployment) + } + + return deployments, nil +} + +func (svc *deploymentService) toDeployment(item interface{}) (*api.Deployment, error) { + deployment, ok := item.(*api.Deployment) + if !ok { + return nil, fmt.Errorf("incorrect item type: %T, should be: *Deployment", item) + } + + return deployment, nil +} diff --git a/components/ui-api-layer/internal/domain/k8s/deployment_service_test.go b/components/ui-api-layer/internal/domain/k8s/deployment_service_test.go new file mode 100644 index 000000000000..e9610f684585 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/deployment_service_test.go @@ -0,0 +1,103 @@ +package k8s_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/api/apps/v1beta2" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/tools/cache" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/k8s" + testingUtils "github.com/kyma-project/kyma/components/ui-api-layer/internal/testing" +) + +func TestDeploymentService_List(t *testing.T) { + t.Run("Success", func(t *testing.T) { + deployment1 := fixDeployment("one", "env1", "deployment") + deployment2 := fixDeployment("two", "env1", "function") + deployment3 := fixDeployment("three", "env2", "deployment") + deployment4 := fixDeployment("four", "env2", "function") + + informer := fixDeploymentInformer(deployment1, deployment2, deployment3, deployment4) + svc := k8s.NewDeploymentService(informer) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + + result, err := svc.List("env1") + + require.NoError(t, err) + assert.Equal(t, []*v1beta2.Deployment{ + deployment1, deployment2, + }, result) + }) + + t.Run("Not found", func(t *testing.T) { + var expected []*v1beta2.Deployment + + informer := fixDeploymentInformer() + svc := k8s.NewDeploymentService(informer) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + + result, err := svc.List("env1") + + require.NoError(t, err) + assert.Equal(t, expected, result) + }) +} + +func TestDeploymentService_ListWithoutFunctions(t *testing.T) { + t.Run("Success", func(t *testing.T) { + deployment1 := fixDeployment("one", "env1", "deployment") + deployment2 := fixDeployment("two", "env1", "function") + deployment3 := fixDeployment("three", "env2", "deployment") + deployment4 := fixDeployment("four", "env2", "function") + + informer := fixDeploymentInformer(deployment1, deployment2, deployment3, deployment4) + svc := k8s.NewDeploymentService(informer) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + + result, err := svc.ListWithoutFunctions("env1") + + require.NoError(t, err) + assert.Equal(t, []*v1beta2.Deployment{ + deployment1, + }, result) + }) + + t.Run("Not found", func(t *testing.T) { + var expected []*v1beta2.Deployment + + informer := fixDeploymentInformer() + svc := k8s.NewDeploymentService(informer) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + + result, err := svc.ListWithoutFunctions("env1") + + require.NoError(t, err) + assert.Equal(t, expected, result) + }) +} + +func fixDeployment(name, environment, kind string) *v1beta2.Deployment { + return &v1beta2.Deployment{ + ObjectMeta: v1.ObjectMeta{ + Name: name, + Namespace: environment, + Labels: map[string]string{ + kind: "", + }, + }, + } +} + +func fixDeploymentInformer(objects ...runtime.Object) cache.SharedIndexInformer { + client := fake.NewSimpleClientset(objects...) + informerFactory := informers.NewSharedInformerFactory(client, 0) + + return informerFactory.Apps().V1beta2().Deployments().Informer() +} diff --git a/components/ui-api-layer/internal/domain/k8s/environment_resolver.go b/components/ui-api-layer/internal/domain/k8s/environment_resolver.go new file mode 100644 index 000000000000..6a4a47866ff4 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/environment_resolver.go @@ -0,0 +1,43 @@ +package k8s + +import ( + "context" + + "github.com/golang/glog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/pkg/errors" +) + +//go:generate mockery -name=envLister -output=automock -outpkg=automock -case=underscore +type envLister interface { + List() ([]gqlschema.Environment, error) + ListForRemoteEnvironment(reName string) ([]gqlschema.Environment, error) +} + +type environmentResolver struct { + envLister envLister +} + +func newEnvironmentResolver(envLister envLister) *environmentResolver { + return &environmentResolver{ + envLister: envLister, + } +} + +func (r *environmentResolver) EnvironmentsQuery(ctx context.Context, remoteEnvironment *string) ([]gqlschema.Environment, error) { + var err error + var envs []gqlschema.Environment + + if remoteEnvironment == nil { + envs, err = r.envLister.List() + } else { + envs, err = r.envLister.ListForRemoteEnvironment(*remoteEnvironment) + } + + if err != nil { + glog.Error(errors.Wrap(err, "while resolving environments")) + return nil, errors.New("Cannot list environments") + } + + return envs, nil +} diff --git a/components/ui-api-layer/internal/domain/k8s/environment_service.go b/components/ui-api-layer/internal/domain/k8s/environment_service.go new file mode 100644 index 000000000000..4a97bde96aee --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/environment_service.go @@ -0,0 +1,75 @@ +package k8s + +import ( + "github.com/pkg/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + corev1 "k8s.io/client-go/kubernetes/typed/core/v1" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +) + +const envLabelSelector = "env=true" + +type environmentService struct { + reLister RemoteEnvironmentLister + nsInterface corev1.NamespaceInterface +} + +func newEnvironmentService(nsInterface corev1.NamespaceInterface, reLister RemoteEnvironmentLister) *environmentService { + return &environmentService{ + nsInterface: nsInterface, + reLister: reLister, + } +} + +func (svc *environmentService) List() ([]gqlschema.Environment, error) { + list, err := svc.nsInterface.List(metav1.ListOptions{ + LabelSelector: envLabelSelector, // namespaces with label env=true are treated as environments + }) + if err != nil { + return []gqlschema.Environment{}, errors.Wrap(err, "while listing environment mappings") + } + + result := make([]gqlschema.Environment, 0) + for _, ns := range list.Items { + res, err := svc.reLister.ListInEnvironment(ns.Name) + if err != nil { + return []gqlschema.Environment{}, errors.Wrap(err, "while listing remote envs for env") + } + reNames := make([]string, 0) + for _, re := range res { + reNames = append(reNames, re.Name) + } + + result = append(result, gqlschema.Environment{ + Name: ns.Name, + RemoteEnvironments: reNames, + }) + } + + return result, nil +} + +func (svc *environmentService) ListForRemoteEnvironment(reName string) ([]gqlschema.Environment, error) { + namespaces, err := svc.reLister.ListNamespacesFor(reName) + if err != nil { + return []gqlschema.Environment{}, errors.Wrap(err, "while listing namespaces") + } + + result := make([]gqlschema.Environment, 0) + for _, ns := range namespaces { + res, err := svc.reLister.ListInEnvironment(ns) + if err != nil { + return []gqlschema.Environment{}, errors.Wrap(err, "while listing remote envs") + } + remoteEnvNames := make([]string, 0) + for _, re := range res { + remoteEnvNames = append(remoteEnvNames, re.Name) + } + result = append(result, gqlschema.Environment{ + Name: ns, + RemoteEnvironments: remoteEnvNames, + }) + } + return result, nil +} diff --git a/components/ui-api-layer/internal/domain/k8s/export_test.go b/components/ui-api-layer/internal/domain/k8s/export_test.go new file mode 100644 index 000000000000..e715f1ad74c7 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/export_test.go @@ -0,0 +1,26 @@ +package k8s + +import ( + "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/tools/cache" +) + +// Deployment + +func NewDeploymentService(informer cache.SharedIndexInformer) *deploymentService { + return newDeploymentService(informer) +} + +func NewDeploymentResolver(service deploymentLister, serviceBindingUsageLister ServiceBindingUsageLister, serviceBindingGetter ServiceBindingGetter) *deploymentResolver { + return newDeploymentResolver(service, serviceBindingUsageLister, serviceBindingGetter) +} + +// Secret + +func NewSecretResolver(secretGetter v1.SecretsGetter) *secretResolver { + return newSecretResolver(secretGetter) +} + +func NewResourceQuotaService(informer cache.SharedIndexInformer) *resourceQuotaService { + return newResourceQuotaService(informer) +} diff --git a/components/ui-api-layer/internal/domain/k8s/interfaces.go b/components/ui-api-layer/internal/domain/k8s/interfaces.go new file mode 100644 index 000000000000..ce3b7bac0a8e --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/interfaces.go @@ -0,0 +1,29 @@ +package k8s + +import ( + bindingApi "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + usageApi "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + api "k8s.io/api/apps/v1beta2" + "k8s.io/api/core/v1" +) + +//go:generate mockery -name=deploymentLister -output=automock -outpkg=automock -case=underscore +type deploymentLister interface { + List(environment string) ([]*api.Deployment, error) + ListWithoutFunctions(environment string) ([]*api.Deployment, error) +} + +//go:generate mockery -name=resourceQuotaLister -output=automock -outpkg=automock -case=underscore +type resourceQuotaLister interface { + List(environment string) ([]*v1.ResourceQuota, error) +} + +//go:generate mockery -name=ServiceBindingUsageLister -output=automock -outpkg=automock -case=underscore +type ServiceBindingUsageLister interface { + ListForDeployment(environment, kind, deploymentName string) ([]*usageApi.ServiceBindingUsage, error) +} + +//go:generate mockery -name=ServiceBindingGetter -output=automock -outpkg=automock -case=underscore +type ServiceBindingGetter interface { + Find(env string, name string) (*bindingApi.ServiceBinding, error) +} diff --git a/components/ui-api-layer/internal/domain/k8s/k8s.go b/components/ui-api-layer/internal/domain/k8s/k8s.go new file mode 100644 index 000000000000..b290ddddccdd --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/k8s.go @@ -0,0 +1,60 @@ +package k8s + +import ( + "time" + + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/pkg/errors" + "k8s.io/client-go/informers" + k8sClientset "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/rest" +) + +type RemoteEnvironmentLister interface { + ListInEnvironment(environment string) ([]*v1alpha1.RemoteEnvironment, error) + ListNamespacesFor(reName string) ([]string, error) +} + +type Resolver struct { + *environmentResolver + *secretResolver + *deploymentResolver + *resourceQuotaResolver + *limitRangeResolver + + informerFactory informers.SharedInformerFactory +} + +func New(restConfig *rest.Config, remoteEnvironmentLister RemoteEnvironmentLister, informerResyncPeriod time.Duration, serviceBindingUsageLister ServiceBindingUsageLister, serviceBindingGetter ServiceBindingGetter) (*Resolver, error) { + client, err := v1.NewForConfig(restConfig) + if err != nil { + return nil, errors.Wrap(err, "while creating K8S Client") + } + + clientset, err := k8sClientset.NewForConfig(restConfig) + if err != nil { + return nil, errors.Wrap(err, "while creating K8S Client") + } + + informerFactory := informers.NewSharedInformerFactory(clientset, informerResyncPeriod) + + environmentService := newEnvironmentService(client.Namespaces(), remoteEnvironmentLister) + deploymentService := newDeploymentService(informerFactory.Apps().V1beta2().Deployments().Informer()) + limitRangeService := newLimitRangeService(informerFactory.Core().V1().LimitRanges().Informer()) + resourceQuotaService := newResourceQuotaService(informerFactory.Core().V1().ResourceQuotas().Informer()) + + return &Resolver{ + environmentResolver: newEnvironmentResolver(environmentService), + secretResolver: newSecretResolver(client), + deploymentResolver: newDeploymentResolver(deploymentService, serviceBindingUsageLister, serviceBindingGetter), + limitRangeResolver: newLimitRangeResolver(limitRangeService), + resourceQuotaResolver: newResourceQuotaResolver(resourceQuotaService), + informerFactory: informerFactory, + }, nil +} + +func (r *Resolver) WaitForCacheSync(stopCh <-chan struct{}) { + r.informerFactory.Start(stopCh) + r.informerFactory.WaitForCacheSync(stopCh) +} diff --git a/components/ui-api-layer/internal/domain/k8s/limitrange_converter.go b/components/ui-api-layer/internal/domain/k8s/limitrange_converter.go new file mode 100644 index 000000000000..9656283f55d9 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/limitrange_converter.go @@ -0,0 +1,62 @@ +package k8s + +import ( + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "k8s.io/api/core/v1" +) + +type limitRangeConverter struct{} + +func (lr *limitRangeConverter) ToGQL(in *v1.LimitRange) *gqlschema.LimitRange { + if in == nil { + return nil + } + out := &gqlschema.LimitRange{ + Name: in.Name, + Limits: make([]gqlschema.LimitRangeItem, 0, len(in.Spec.Limits)), + } + + for _, limitRange := range in.Spec.Limits { + out.Limits = append(out.Limits, lr.limitsToGQL(limitRange)) + } + + return out +} + +func (lr *limitRangeConverter) ToGQLs(in []*v1.LimitRange) []gqlschema.LimitRange { + if in == nil { + return nil + } + result := make([]gqlschema.LimitRange, 0) + for _, limitRange := range in { + if lr := lr.ToGQL(limitRange); lr != nil { + result = append(result, *lr) + } + } + return result +} + +func (lr *limitRangeConverter) limitsToGQL(item v1.LimitRangeItem) gqlschema.LimitRangeItem { + return gqlschema.LimitRangeItem{ + LimitType: gqlschema.LimitType(item.Type), + DefaultRequest: lr.extractResourceValues(item.DefaultRequest), + Default: lr.extractResourceValues(item.Default), + Max: lr.extractResourceValues(item.Max), + } +} + +func (lr *limitRangeConverter) extractResourceValues(item v1.ResourceList) gqlschema.ResourceType { + rt := gqlschema.ResourceType{} + if item, ok := item[v1.ResourceCPU]; ok { + rt.Cpu = lr.stringPtr(item.String()) + } + if item, ok := item[v1.ResourceMemory]; ok { + rt.Memory = lr.stringPtr(item.String()) + } + + return rt +} + +func (*limitRangeConverter) stringPtr(str string) *string { + return &str +} diff --git a/components/ui-api-layer/internal/domain/k8s/limitrange_converter_test.go b/components/ui-api-layer/internal/domain/k8s/limitrange_converter_test.go new file mode 100644 index 000000000000..ef1e6da92126 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/limitrange_converter_test.go @@ -0,0 +1,153 @@ +package k8s + +import ( + "testing" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestLimitRangeConverter_ToGQL(t *testing.T) { + for tn, tc := range map[string]struct { + given *v1.LimitRange + expected *gqlschema.LimitRange + }{ + "empty": { + given: &v1.LimitRange{ + ObjectMeta: metav1.ObjectMeta{ + Name: fixLimitRangeName(), + Namespace: fixLimitRangeNamespace(), + }, + }, + expected: &gqlschema.LimitRange{ + Name: fixLimitRangeName(), + Limits: make([]gqlschema.LimitRangeItem, 0), + }, + }, + "full": { + given: fixLimitRange(), + expected: fixGQLLimitRange(), + }, + "nil": { + given: &v1.LimitRange{ + ObjectMeta: metav1.ObjectMeta{ + Name: fixLimitRangeName(), + Namespace: fixLimitRangeNamespace(), + }, + Spec: v1.LimitRangeSpec{ + Limits: []v1.LimitRangeItem{ + { + Max: v1.ResourceList{}, + }, + }, + }, + }, + expected: &gqlschema.LimitRange{ + Name: fixLimitRangeName(), + Limits: []gqlschema.LimitRangeItem{ + { + Max: gqlschema.ResourceType{ + Memory: nil, + Cpu: nil, + }, + }, + }, + }, + }, + } { + t.Run(tn, func(t *testing.T) { + // GIVEN + conv := limitRangeConverter{} + + // WHEN + gql := conv.ToGQL(tc.given) + + // THEN + assert.Equal(t, tc.expected, gql) + }) + } + +} + +func TestLimitRangeConverter_ToGQLs(t *testing.T) { + // GIVEN + conv := limitRangeConverter{} + + // WHEN + gql := conv.ToGQLs(fixLimitRanges()) + + // THEN + assert.Equal(t, []gqlschema.LimitRange{ + *fixGQLLimitRange(), + }, gql) +} + +func fixLimitRange() *v1.LimitRange { + return &v1.LimitRange{ + ObjectMeta: metav1.ObjectMeta{ + Name: fixLimitRangeName(), + Namespace: fixLimitRangeNamespace(), + }, + Spec: v1.LimitRangeSpec{ + Limits: []v1.LimitRangeItem{ + { + Max: v1.ResourceList{ + v1.ResourceMemory: resource.MustParse(*fixLimits()[0].Max.Memory), + v1.ResourceCPU: resource.MustParse(*fixLimits()[0].Max.Cpu), + }, + Default: v1.ResourceList{ + v1.ResourceMemory: resource.MustParse(*fixLimits()[0].Default.Memory), + v1.ResourceCPU: resource.MustParse(*fixLimits()[0].Default.Cpu), + }, + DefaultRequest: v1.ResourceList{ + v1.ResourceMemory: resource.MustParse(*fixLimits()[0].DefaultRequest.Memory), + v1.ResourceCPU: resource.MustParse(*fixLimits()[0].DefaultRequest.Cpu), + }, + }, + }, + }, + } +} + +func fixLimitRanges() []*v1.LimitRange { + return []*v1.LimitRange{ + fixLimitRange(), + } +} + +func fixGQLLimitRange() *gqlschema.LimitRange { + return &gqlschema.LimitRange{ + Name: fixLimitRangeName(), + Limits: fixLimits(), + } +} + +func fixLimitRangeName() string { + return "kyma-default" +} + +func fixLimitRangeNamespace() string { + return "kyma-integration" +} + +func fixLimits() []gqlschema.LimitRangeItem { + return []gqlschema.LimitRangeItem{ + { + Max: gqlschema.ResourceType{ + Memory: ptrStr("120Mi"), + Cpu: ptrStr("100m"), + }, + Default: gqlschema.ResourceType{ + Memory: ptrStr("120Mi"), + Cpu: ptrStr("10"), + }, + DefaultRequest: gqlschema.ResourceType{ + Memory: ptrStr("120Mi"), + Cpu: ptrStr("1200m"), + }, + }, + } +} diff --git a/components/ui-api-layer/internal/domain/k8s/limitrange_resolver.go b/components/ui-api-layer/internal/domain/k8s/limitrange_resolver.go new file mode 100644 index 000000000000..ab8cb9d5182b --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/limitrange_resolver.go @@ -0,0 +1,35 @@ +package k8s + +import ( + "context" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/pkg/errors" + "k8s.io/api/core/v1" +) + +//go:generate mockery -name=limitRangeLister -output=automock -outpkg=automock -case=underscore +type limitRangeLister interface { + List(env string) ([]*v1.LimitRange, error) +} + +func newLimitRangeResolver(svc *limitRangeService) *limitRangeResolver { + return &limitRangeResolver{ + lister: svc, + converter: limitRangeConverter{}, + } +} + +type limitRangeResolver struct { + converter limitRangeConverter + lister limitRangeLister +} + +func (lr *limitRangeResolver) LimitRangesQuery(ctx context.Context, env string) ([]gqlschema.LimitRange, error) { + limitRange, err := lr.lister.List(env) + if err != nil { + return nil, errors.Wrapf(err, "cannot get limit range from ns: %s", env) + } + + return lr.converter.ToGQLs(limitRange), nil +} diff --git a/components/ui-api-layer/internal/domain/k8s/limitrange_resolver_test.go b/components/ui-api-layer/internal/domain/k8s/limitrange_resolver_test.go new file mode 100644 index 000000000000..47d21f26197d --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/limitrange_resolver_test.go @@ -0,0 +1,29 @@ +package k8s + +import ( + "context" + "testing" + "time" + + testingUtils "github.com/kyma-project/kyma/components/ui-api-layer/internal/testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLimitRangeResolver_LimitRangeQuery(t *testing.T) { + // GIVEN + informer := fixLimitRangeInformer(fixLimitRange()) + + svc := newLimitRangeService(informer) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + + resolver := newLimitRangeResolver(svc) + + // WHEN + result, err := resolver.LimitRangesQuery(context.Background(), fixLimitRangeNamespace()) + + // THEN + require.NoError(t, err) + assert.Contains(t, result, *fixGQLLimitRange()) + assert.Len(t, result, 1) +} diff --git a/components/ui-api-layer/internal/domain/k8s/limitrange_service.go b/components/ui-api-layer/internal/domain/k8s/limitrange_service.go new file mode 100644 index 000000000000..7f70cdcfbd75 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/limitrange_service.go @@ -0,0 +1,35 @@ +package k8s + +import ( + "github.com/pkg/errors" + "k8s.io/api/core/v1" + "k8s.io/client-go/tools/cache" +) + +func newLimitRangeService(informer cache.SharedIndexInformer) *limitRangeService { + return &limitRangeService{ + informer: informer, + } +} + +type limitRangeService struct { + informer cache.SharedIndexInformer +} + +func (svc *limitRangeService) List(env string) ([]*v1.LimitRange, error) { + items, err := svc.informer.GetIndexer().ByIndex(cache.NamespaceIndex, env) + if err != nil { + return []*v1.LimitRange{}, errors.Wrapf(err, "cannot list limit ranges from ns: %s", env) + } + + var result []*v1.LimitRange + for _, item := range items { + lr, ok := item.(*v1.LimitRange) + if !ok { + return nil, errors.Errorf("unexpected item type: %T, should be *LimitRange", item) + } + result = append(result, lr) + } + + return result, nil +} diff --git a/components/ui-api-layer/internal/domain/k8s/limitrange_service_test.go b/components/ui-api-layer/internal/domain/k8s/limitrange_service_test.go new file mode 100644 index 000000000000..59eaa5d85e4b --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/limitrange_service_test.go @@ -0,0 +1,50 @@ +package k8s + +import ( + "testing" + "time" + + testingUtils "github.com/kyma-project/kyma/components/ui-api-layer/internal/testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/tools/cache" +) + +func TestLimitRangeService_List(t *testing.T) { + // GIVEN + informer := fixLimitRangeInformer(fixLimitRange()) + svc := newLimitRangeService(informer) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + + // WHEN + result, err := svc.List(fixLimitRangeNamespace()) + + // THEN + require.NoError(t, err) + assert.Len(t, result, 1) + assert.Contains(t, result, fixLimitRange()) +} + +func TestLimitRangeService_List_NotFound(t *testing.T) { + // GIVEN + informer := fixLimitRangeInformer() + svc := newLimitRangeService(informer) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + + // WHEN + result, err := svc.List("env") + + // THEN + require.NoError(t, err) + assert.Len(t, result, 0) +} + +func fixLimitRangeInformer(objects ...runtime.Object) cache.SharedIndexInformer { + client := fake.NewSimpleClientset(objects...) + informerFactory := informers.NewSharedInformerFactory(client, 0) + + return informerFactory.Core().V1().LimitRanges().Informer() +} diff --git a/components/ui-api-layer/internal/domain/k8s/resourcequota_converter.go b/components/ui-api-layer/internal/domain/k8s/resourcequota_converter.go new file mode 100644 index 000000000000..e90d47508c8a --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/resourcequota_converter.go @@ -0,0 +1,48 @@ +package k8s + +import ( + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "k8s.io/api/core/v1" +) + +type resourceQuotaConverter struct{} + +func (c *resourceQuotaConverter) ToGQL(in *v1.ResourceQuota) *gqlschema.ResourceQuota { + if in == nil { + return nil + } + + out := &gqlschema.ResourceQuota{ + Name: in.Name, + Pods: c.extractValue(in, v1.ResourcePods), + Limits: gqlschema.ResourceValues{ + Memory: c.extractValue(in, v1.ResourceLimitsMemory), + Cpu: c.extractValue(in, v1.ResourceLimitsCPU), + }, + Requests: gqlschema.ResourceValues{ + Memory: c.extractValue(in, v1.ResourceRequestsMemory), + Cpu: c.extractValue(in, v1.ResourceRequestsCPU), + }, + } + return out +} + +func (c *resourceQuotaConverter) extractValue(in *v1.ResourceQuota, resourceName v1.ResourceName) *string { + val, exists := in.Spec.Hard[resourceName] + if !exists { + return nil + } + formattedVal := val.String() + return &formattedVal +} + +func (c *resourceQuotaConverter) ToGQLs(in []*v1.ResourceQuota) []gqlschema.ResourceQuota { + result := make([]gqlschema.ResourceQuota, 0) + for _, rq := range in { + converted := c.ToGQL(rq) + if converted != nil { + result = append(result, *converted) + } + } + return result +} diff --git a/components/ui-api-layer/internal/domain/k8s/resourcequota_converter_test.go b/components/ui-api-layer/internal/domain/k8s/resourcequota_converter_test.go new file mode 100644 index 000000000000..a1538a0d8126 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/resourcequota_converter_test.go @@ -0,0 +1,95 @@ +package k8s + +import ( + "testing" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestResourceQuotaConverter_ToGQLs(t *testing.T) { + // GIVEN + converter := &resourceQuotaConverter{} + + // WHEN + result := converter.ToGQLs([]*v1.ResourceQuota{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "mem-default", + Namespace: "production", + }, + }, + }) + + // THEN + assert.Equal(t, []gqlschema.ResourceQuota{ + {Name: "mem-default"}, + }, result) +} + +func TestResourceQuotaConverter_ToGQL(t *testing.T) { + for tn, tc := range map[string]struct { + given *v1.ResourceQuota + expected *gqlschema.ResourceQuota + }{ + "empty": { + given: &v1.ResourceQuota{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mem-default", + Namespace: "production", + }, + }, + expected: &gqlschema.ResourceQuota{ + Name: "mem-default", + }, + }, + "full": { + given: &v1.ResourceQuota{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mem-default", + Namespace: "production", + }, + Spec: v1.ResourceQuotaSpec{ + Hard: v1.ResourceList{ + v1.ResourcePods: resource.MustParse("10"), + v1.ResourceLimitsCPU: resource.MustParse("900m"), + v1.ResourceLimitsMemory: resource.MustParse("1Gi"), + v1.ResourceRequestsCPU: resource.MustParse("500m"), + v1.ResourceRequestsMemory: resource.MustParse("512Mi"), + }, + }, + }, + expected: &gqlschema.ResourceQuota{ + Name: "mem-default", + Pods: ptrStr("10"), + Limits: gqlschema.ResourceValues{ + Cpu: ptrStr("900m"), + Memory: ptrStr("1Gi"), + }, + Requests: gqlschema.ResourceValues{ + Cpu: ptrStr("500m"), + Memory: ptrStr("512Mi"), + }, + }, + }, + } { + t.Run(tn, func(t *testing.T) { + // GIVEN + converter := &resourceQuotaConverter{} + + // WHEN + result := converter.ToGQL(tc.given) + + // THEN + assert.Equal(t, tc.expected, result) + }) + + } +} + +func ptrStr(str string) *string { + return &str +} diff --git a/components/ui-api-layer/internal/domain/k8s/resourcequota_resolver.go b/components/ui-api-layer/internal/domain/k8s/resourcequota_resolver.go new file mode 100644 index 000000000000..82ab8068678e --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/resourcequota_resolver.go @@ -0,0 +1,32 @@ +package k8s + +import ( + "context" + + "github.com/golang/glog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/pkg/errors" +) + +func newResourceQuotaResolver(resourceQuotaLister resourceQuotaLister) *resourceQuotaResolver { + return &resourceQuotaResolver{ + converter: &resourceQuotaConverter{}, + lister: resourceQuotaLister, + } +} + +type resourceQuotaResolver struct { + lister resourceQuotaLister + converter *resourceQuotaConverter +} + +func (r *resourceQuotaResolver) ResourceQuotasQuery(ctx context.Context, environment string) ([]gqlschema.ResourceQuota, error) { + items, err := r.lister.List(environment) + if err != nil { + glog.Error( + errors.Wrapf(err, "while listing resource quotas [environment: %s]", environment)) + return nil, errors.New("cannot get resource quotas") + } + + return r.converter.ToGQLs(items), nil +} diff --git a/components/ui-api-layer/internal/domain/k8s/resourcequota_resolver_test.go b/components/ui-api-layer/internal/domain/k8s/resourcequota_resolver_test.go new file mode 100644 index 000000000000..8c219b74bb41 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/resourcequota_resolver_test.go @@ -0,0 +1,38 @@ +package k8s + +import ( + "context" + "testing" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/k8s/automock" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestResourceQuotaResolver_ResourceQuotasQuery(t *testing.T) { + // GIVEN + env := "production" + lister := automock.NewResourceQuotaLister() + lister.On("List", env).Return([]*v1.ResourceQuota{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "mem-default", + Namespace: "production", + }, + }, + }, nil) + defer lister.AssertExpectations(t) + + resolver := newResourceQuotaResolver(lister) + + // WHEN + result, err := resolver.ResourceQuotasQuery(context.Background(), env) + + // THEN + require.NoError(t, err) + assert.Len(t, result, 1) + assert.Equal(t, gqlschema.ResourceQuota{Name: "mem-default"}, result[0]) +} diff --git a/components/ui-api-layer/internal/domain/k8s/resourcequota_service.go b/components/ui-api-layer/internal/domain/k8s/resourcequota_service.go new file mode 100644 index 000000000000..2204cd227a00 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/resourcequota_service.go @@ -0,0 +1,36 @@ +package k8s + +import ( + "fmt" + + "k8s.io/api/core/v1" + "k8s.io/client-go/tools/cache" +) + +type resourceQuotaService struct { + informer cache.SharedIndexInformer +} + +func newResourceQuotaService(informer cache.SharedIndexInformer) *resourceQuotaService { + return &resourceQuotaService{ + informer: informer, + } +} + +func (svc *resourceQuotaService) List(environment string) ([]*v1.ResourceQuota, error) { + items, err := svc.informer.GetIndexer().ByIndex(cache.NamespaceIndex, environment) + if err != nil { + return []*v1.ResourceQuota{}, err + } + + var result []*v1.ResourceQuota + for _, item := range items { + rq, ok := item.(*v1.ResourceQuota) + if !ok { + return nil, fmt.Errorf("unexpected item type: %T, should be *ResourceQuota", item) + } + result = append(result, rq) + } + + return result, nil +} diff --git a/components/ui-api-layer/internal/domain/k8s/resourcequota_service_test.go b/components/ui-api-layer/internal/domain/k8s/resourcequota_service_test.go new file mode 100644 index 000000000000..505c34e16b2a --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/resourcequota_service_test.go @@ -0,0 +1,53 @@ +package k8s_test + +import ( + "testing" + "time" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/k8s" + testingUtils "github.com/kyma-project/kyma/components/ui-api-layer/internal/testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/tools/cache" +) + +func TestResourceQuotaResolver_ListSuccess(t *testing.T) { + // GIVEN + rq1 := fixResourceQuota("rq1", "prod") + rq2 := fixResourceQuota("rq2", "prod") + rqQa := fixResourceQuota("rq", "qa") + informer := fixResourceQuotaInformer(rq1, rq2, rqQa) + + svc := k8s.NewResourceQuotaService(informer) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + + // WHEN + result, err := svc.List("prod") + + // THEN + require.NoError(t, err) + assert.Contains(t, result, rq1) + assert.Contains(t, result, rq2) + assert.Len(t, result, 2) +} + +func fixResourceQuotaInformer(objects ...runtime.Object) cache.SharedIndexInformer { + client := fake.NewSimpleClientset(objects...) + informerFactory := informers.NewSharedInformerFactory(client, 0) + + return informerFactory.Core().V1().ResourceQuotas().Informer() +} + +func fixResourceQuota(name, environment string) *v1.ResourceQuota { + return &v1.ResourceQuota{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: environment, + }, + } +} diff --git a/components/ui-api-layer/internal/domain/k8s/secret_converter.go b/components/ui-api-layer/internal/domain/k8s/secret_converter.go new file mode 100644 index 000000000000..c72cd7044368 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/secret_converter.go @@ -0,0 +1,24 @@ +package k8s + +import ( + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "k8s.io/api/core/v1" +) + +type secretConverter struct{} + +func (*secretConverter) ToGQL(in *v1.Secret) *gqlschema.Secret { + if in == nil { + return nil + } + + out := &gqlschema.Secret{ + Name: in.Name, + Environment: in.Namespace, + } + out.Data = make(gqlschema.JSON) + for k, v := range in.Data { + out.Data[k] = string(v) + } + return out +} diff --git a/components/ui-api-layer/internal/domain/k8s/secret_converter_test.go b/components/ui-api-layer/internal/domain/k8s/secret_converter_test.go new file mode 100644 index 000000000000..9787d43b0ac6 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/secret_converter_test.go @@ -0,0 +1,44 @@ +package k8s + +import ( + "testing" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestSecretConverter_ToGQL(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // GIVEN + givenSecret := v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-secret", + Namespace: "production", + }, + Data: map[string][]byte{ + "password": []byte("secret"), + }, + } + sut := secretConverter{} + // WHEN + actualQL := sut.ToGQL(&givenSecret) + // THEN + assert.Equal(t, "my-secret", actualQL.Name) + assert.Equal(t, "production", actualQL.Environment) + assert.Equal(t, gqlschema.JSON{"password": "secret"}, actualQL.Data) + }) + + t.Run("Empty", func(t *testing.T) { + converter := secretConverter{} + converter.ToGQL(&v1.Secret{}) + }) + + t.Run("Nil", func(t *testing.T) { + converter := secretConverter{} + result := converter.ToGQL(nil) + + assert.Nil(t, result) + }) +} diff --git a/components/ui-api-layer/internal/domain/k8s/secret_resolver.go b/components/ui-api-layer/internal/domain/k8s/secret_resolver.go new file mode 100644 index 000000000000..4e58186c0df9 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/secret_resolver.go @@ -0,0 +1,39 @@ +package k8s + +import ( + "context" + + "github.com/golang/glog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/pkg/errors" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/typed/core/v1" +) + +func newSecretResolver(secretGetter v1.SecretsGetter) *secretResolver { + return &secretResolver{ + converter: secretConverter{}, + secretGetter: secretGetter, + } +} + +type secretResolver struct { + secretGetter v1.SecretsGetter + converter secretConverter +} + +func (r *secretResolver) SecretQuery(ctx context.Context, name, env string) (*gqlschema.Secret, error) { + secret, err := r.secretGetter.Secrets(env).Get(name, metav1.GetOptions{}) + switch { + case apierrors.IsNotFound(err): + return nil, nil + case err != nil: + glog.Error( + errors.Wrapf(err, "while getting secret [name: %s, environment: %s]", name, env)) + return nil, errors.New("cannot get Secret") + } + + return r.converter.ToGQL(secret), nil + +} diff --git a/components/ui-api-layer/internal/domain/k8s/secret_resolver_test.go b/components/ui-api-layer/internal/domain/k8s/secret_resolver_test.go new file mode 100644 index 000000000000..2f7c13f44eb9 --- /dev/null +++ b/components/ui-api-layer/internal/domain/k8s/secret_resolver_test.go @@ -0,0 +1,60 @@ +package k8s_test + +import ( + "context" + "errors" + "testing" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/k8s" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + k8sTesting "k8s.io/client-go/testing" +) + +func TestSecretResolver(t *testing.T) { + // GIVEN + fakeClientSet := fake.NewSimpleClientset( + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-secret", + Namespace: "production", + }, + }) + resolver := k8s.NewSecretResolver(fakeClientSet.CoreV1()) + // WHEN + actualSecret, err := resolver.SecretQuery(context.Background(), "my-secret", "production") + // THEN + require.NoError(t, err) + assert.Equal(t, "my-secret", actualSecret.Name) + assert.Equal(t, "production", actualSecret.Environment) +} + +func TestSecretResolverOnNotFound(t *testing.T) { + // GIVEN + fakeClientSet := fake.NewSimpleClientset() + resolver := k8s.NewSecretResolver(fakeClientSet.CoreV1()) + // WHEN + secret, err := resolver.SecretQuery(context.Background(), "my-secret", "production") + // THEN + assert.NoError(t, err) + assert.Nil(t, secret) +} + +func TestSecretResolverOnError(t *testing.T) { + // GIVEN + fakeClientSet := fake.NewSimpleClientset() + fakeClientSet.PrependReactor("get", "secrets", failingReactor) + resolver := k8s.NewSecretResolver(fakeClientSet.CoreV1()) + // WHEN + _, err := resolver.SecretQuery(context.Background(), "my-secret", "production") + // THEN + assert.EqualError(t, err, "cannot get Secret") +} + +func failingReactor(action k8sTesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, errors.New("custom error") +} diff --git a/components/ui-api-layer/internal/domain/kubeless/automock/export.go b/components/ui-api-layer/internal/domain/kubeless/automock/export.go new file mode 100644 index 000000000000..b9ba79607bd5 --- /dev/null +++ b/components/ui-api-layer/internal/domain/kubeless/automock/export.go @@ -0,0 +1,5 @@ +package automock + +func NewFunctionLister() *functionLister { + return new(functionLister) +} diff --git a/components/ui-api-layer/internal/domain/kubeless/automock/function_lister.go b/components/ui-api-layer/internal/domain/kubeless/automock/function_lister.go new file mode 100644 index 000000000000..d71ae0bc1e58 --- /dev/null +++ b/components/ui-api-layer/internal/domain/kubeless/automock/function_lister.go @@ -0,0 +1,34 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import pager "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" +import v1beta1 "github.com/kubeless/kubeless/pkg/apis/kubeless/v1beta1" + +// functionLister is an autogenerated mock type for the functionLister type +type functionLister struct { + mock.Mock +} + +// List provides a mock function with given fields: environment, pagingParams +func (_m *functionLister) List(environment string, pagingParams pager.PagingParams) ([]*v1beta1.Function, error) { + ret := _m.Called(environment, pagingParams) + + var r0 []*v1beta1.Function + if rf, ok := ret.Get(0).(func(string, pager.PagingParams) []*v1beta1.Function); ok { + r0 = rf(environment, pagingParams) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1beta1.Function) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, pager.PagingParams) error); ok { + r1 = rf(environment, pagingParams) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/kubeless/export_test.go b/components/ui-api-layer/internal/domain/kubeless/export_test.go new file mode 100644 index 000000000000..c8729b020793 --- /dev/null +++ b/components/ui-api-layer/internal/domain/kubeless/export_test.go @@ -0,0 +1,11 @@ +package kubeless + +import "k8s.io/client-go/tools/cache" + +func NewFunctionService(informer cache.SharedIndexInformer) *functionService { + return newFunctionService(informer) +} + +func NewFunctionResolver(functionSvc functionLister) *functionResolver { + return newFunctionResolver(functionSvc) +} diff --git a/components/ui-api-layer/internal/domain/kubeless/function_converter.go b/components/ui-api-layer/internal/domain/kubeless/function_converter.go new file mode 100644 index 000000000000..abb4719047b1 --- /dev/null +++ b/components/ui-api-layer/internal/domain/kubeless/function_converter.go @@ -0,0 +1,39 @@ +package kubeless + +import ( + "github.com/kubeless/kubeless/pkg/apis/kubeless/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +) + +type functionConverter struct{} + +func (c *functionConverter) ToGQL(in *v1beta1.Function) *gqlschema.Function { + if in == nil { + return nil + } + + labels := make(gqlschema.JSON) + for k, v := range in.Labels { + labels[k] = v + } + + return &gqlschema.Function{ + Name: in.Name, + Trigger: in.Spec.Type, + CreationTimestamp: in.CreationTimestamp.Time, + Labels: labels, + Environment: in.Namespace, + } +} + +func (c *functionConverter) ToGQLs(in []*v1beta1.Function) []gqlschema.Function { + var result []gqlschema.Function + for _, item := range in { + converted := c.ToGQL(item) + if converted != nil { + result = append(result, *converted) + } + } + + return result +} diff --git a/components/ui-api-layer/internal/domain/kubeless/function_converter_test.go b/components/ui-api-layer/internal/domain/kubeless/function_converter_test.go new file mode 100644 index 000000000000..db8f040ef6b8 --- /dev/null +++ b/components/ui-api-layer/internal/domain/kubeless/function_converter_test.go @@ -0,0 +1,103 @@ +package kubeless + +import ( + "testing" + "time" + + "github.com/kubeless/kubeless/pkg/apis/kubeless/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestFunctionConverter_ToGQL(t *testing.T) { + t.Run("All properties are given", func(t *testing.T) { + var zeroTimeStamp time.Time + function := fixFunction() + + expected := &gqlschema.Function{ + Name: "test", + Labels: gqlschema.JSON{"test": "ok", "ok": "test"}, + CreationTimestamp: zeroTimeStamp, + Trigger: "nope", + Environment: "env", + } + + converter := functionConverter{} + result := converter.ToGQL(function) + + assert.Equal(t, expected, result) + }) + + t.Run("Empty", func(t *testing.T) { + converter := functionConverter{} + result := converter.ToGQL(&v1beta1.Function{}) + + require.NotNil(t, result) + }) + + t.Run("Nil", func(t *testing.T) { + converter := functionConverter{} + result := converter.ToGQL(nil) + + require.Nil(t, result) + }) +} + +func TestFunctionConverter_ToGQLs(t *testing.T) { + t.Run("Success", func(t *testing.T) { + functions := []*v1beta1.Function{ + fixFunction(), + fixFunction(), + } + + converter := functionConverter{} + result := converter.ToGQLs(functions) + + assert.Len(t, result, 2) + assert.Equal(t, "test", result[0].Name) + }) + + t.Run("Empty", func(t *testing.T) { + var functions []*v1beta1.Function + + converter := functionConverter{} + result := converter.ToGQLs(functions) + + assert.Empty(t, result) + }) + + t.Run("With nil", func(t *testing.T) { + functions := []*v1beta1.Function{ + nil, + fixFunction(), + nil, + } + + converter := functionConverter{} + result := converter.ToGQLs(functions) + + assert.Len(t, result, 1) + assert.Equal(t, "test", result[0].Name) + }) +} + +func fixFunction() *v1beta1.Function { + var mockTimeStamp v1.Time + + return &v1beta1.Function{ + ObjectMeta: v1.ObjectMeta{ + Name: "test", + CreationTimestamp: mockTimeStamp, + Labels: map[string]string{ + "test": "ok", + "ok": "test", + }, + Namespace: "env", + }, + Spec: v1beta1.FunctionSpec{ + Type: "nope", + }, + } +} diff --git a/components/ui-api-layer/internal/domain/kubeless/function_resolver.go b/components/ui-api-layer/internal/domain/kubeless/function_resolver.go new file mode 100644 index 000000000000..cadef2e10f8f --- /dev/null +++ b/components/ui-api-layer/internal/domain/kubeless/function_resolver.go @@ -0,0 +1,37 @@ +package kubeless + +import ( + "context" + "fmt" + + "github.com/golang/glog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + "github.com/pkg/errors" +) + +type functionResolver struct { + functionLister functionLister + functionConverter *functionConverter +} + +func newFunctionResolver(functionLister functionLister) *functionResolver { + return &functionResolver{ + functionLister: functionLister, + functionConverter: &functionConverter{}, + } +} + +func (r *functionResolver) FunctionsQuery(ctx context.Context, environment string, first *int, offset *int) ([]gqlschema.Function, error) { + externalErr := fmt.Errorf("Cannot query functions in environment `%s`", environment) + functions, err := r.functionLister.List(environment, pager.PagingParams{ + First: first, + Offset: offset, + }) + if err != nil { + glog.Error(errors.Wrapf(err, "while listing Functions for environment %s", environment)) + return nil, externalErr + } + + return r.functionConverter.ToGQLs(functions), nil +} diff --git a/components/ui-api-layer/internal/domain/kubeless/function_resolver_test.go b/components/ui-api-layer/internal/domain/kubeless/function_resolver_test.go new file mode 100644 index 000000000000..29dce6eeec99 --- /dev/null +++ b/components/ui-api-layer/internal/domain/kubeless/function_resolver_test.go @@ -0,0 +1,71 @@ +package kubeless_test + +import ( + "testing" + + "github.com/kubeless/kubeless/pkg/apis/kubeless/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/kubeless" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/kubeless/automock" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestFunctionResolver_FunctionsQuery(t *testing.T) { + environment := "test" + pagingParams := pager.PagingParams{} + + t.Run("Success", func(t *testing.T) { + function := &v1beta1.Function{ + ObjectMeta: v1.ObjectMeta{ + Name: "test", + }, + } + functions := []*v1beta1.Function{ + function, function, + } + + expected := gqlschema.Function{ + Name: "test", + Labels: gqlschema.JSON{}, + } + + svc := automock.NewFunctionLister() + svc.On("List", environment, pagingParams).Return(functions, nil).Once() + + resolver := kubeless.NewFunctionResolver(svc) + + result, err := resolver.FunctionsQuery(nil, environment, nil, nil) + + require.NoError(t, err) + assert.Equal(t, []gqlschema.Function{expected, expected}, result) + + }) + + t.Run("Not found", func(t *testing.T) { + var functions []*v1beta1.Function + var expected []gqlschema.Function + + svc := automock.NewFunctionLister() + svc.On("List", environment, pagingParams).Return(functions, nil).Once() + + resolver := kubeless.NewFunctionResolver(svc) + + result, err := resolver.FunctionsQuery(nil, environment, nil, nil) + require.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("Error", func(t *testing.T) { + svc := automock.NewFunctionLister() + svc.On("List", environment, pagingParams).Return(nil, errors.New("test")).Once() + + resolver := kubeless.NewFunctionResolver(svc) + + _, err := resolver.FunctionsQuery(nil, environment, nil, nil) + require.Error(t, err) + }) +} diff --git a/components/ui-api-layer/internal/domain/kubeless/function_service.go b/components/ui-api-layer/internal/domain/kubeless/function_service.go new file mode 100644 index 000000000000..4a671e0f5a65 --- /dev/null +++ b/components/ui-api-layer/internal/domain/kubeless/function_service.go @@ -0,0 +1,39 @@ +package kubeless + +import ( + "fmt" + + "github.com/kubeless/kubeless/pkg/apis/kubeless/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + "k8s.io/client-go/tools/cache" +) + +type functionService struct { + informer cache.SharedIndexInformer +} + +func newFunctionService(informer cache.SharedIndexInformer) *functionService { + return &functionService{ + informer: informer, + } + +} + +func (svc *functionService) List(environment string, pagingParams pager.PagingParams) ([]*v1beta1.Function, error) { + items, err := pager.FromIndexer(svc.informer.GetIndexer(), "namespace", environment).Limit(pagingParams) + if err != nil { + return nil, err + } + + var functions []*v1beta1.Function + for _, item := range items { + function, ok := item.(*v1beta1.Function) + if !ok { + return nil, fmt.Errorf("Incorrect item type: %T, should be: *Function", item) + } + + functions = append(functions, function) + } + + return functions, nil +} diff --git a/components/ui-api-layer/internal/domain/kubeless/function_service_test.go b/components/ui-api-layer/internal/domain/kubeless/function_service_test.go new file mode 100644 index 000000000000..e23575d2a61f --- /dev/null +++ b/components/ui-api-layer/internal/domain/kubeless/function_service_test.go @@ -0,0 +1,67 @@ +package kubeless_test + +import ( + "testing" + "time" + + "github.com/kubeless/kubeless/pkg/apis/kubeless/v1beta1" + "github.com/kubeless/kubeless/pkg/client/clientset/versioned/fake" + "github.com/kubeless/kubeless/pkg/client/informers/externalversions" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/kubeless" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + testingUtils "github.com/kyma-project/kyma/components/ui-api-layer/internal/testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/cache" +) + +func TestFunctionService_List(t *testing.T) { + t.Run("Success", func(t *testing.T) { + function1 := fixFunction("f1", "env1") + function2 := fixFunction("f2", "env1") + function3 := fixFunction("f3", "env2") + + informer := fixInformer(function1, function2, function3) + + svc := kubeless.NewFunctionService(informer) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + + result, err := svc.List("env1", pager.PagingParams{}) + + require.NoError(t, err) + assert.Equal(t, []*v1beta1.Function{ + function1, function2, + }, result) + }) + + t.Run("Not found", func(t *testing.T) { + informer := fixInformer() + var expected []*v1beta1.Function + + svc := kubeless.NewFunctionService(informer) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + + result, err := svc.List("env1", pager.PagingParams{}) + + require.NoError(t, err) + assert.Equal(t, expected, result) + }) +} + +func fixFunction(name, environment string) *v1beta1.Function { + return &v1beta1.Function{ + ObjectMeta: v1.ObjectMeta{ + Name: name, + Namespace: environment, + }, + } +} + +func fixInformer(objects ...runtime.Object) cache.SharedIndexInformer { + client := fake.NewSimpleClientset(objects...) + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + + return informerFactory.Kubeless().V1beta1().Functions().Informer() +} diff --git a/components/ui-api-layer/internal/domain/kubeless/interfaces.go b/components/ui-api-layer/internal/domain/kubeless/interfaces.go new file mode 100644 index 000000000000..3882fde3f2d3 --- /dev/null +++ b/components/ui-api-layer/internal/domain/kubeless/interfaces.go @@ -0,0 +1,11 @@ +package kubeless + +import ( + "github.com/kubeless/kubeless/pkg/apis/kubeless/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" +) + +//go:generate mockery -name=functionLister -output=automock -outpkg=automock -case=underscore +type functionLister interface { + List(environment string, pagingParams pager.PagingParams) ([]*v1beta1.Function, error) +} diff --git a/components/ui-api-layer/internal/domain/kubeless/kubeless.go b/components/ui-api-layer/internal/domain/kubeless/kubeless.go new file mode 100644 index 000000000000..69b1527b9704 --- /dev/null +++ b/components/ui-api-layer/internal/domain/kubeless/kubeless.go @@ -0,0 +1,36 @@ +package kubeless + +import ( + "time" + + "github.com/kubeless/kubeless/pkg/client/clientset/versioned" + "github.com/kubeless/kubeless/pkg/client/informers/externalversions" + "github.com/pkg/errors" + "k8s.io/client-go/rest" +) + +type Resolver struct { + *functionResolver + + informerFactory externalversions.SharedInformerFactory +} + +func New(restConfig *rest.Config, informerResyncPeriod time.Duration) (*Resolver, error) { + clientset, err := versioned.NewForConfig(restConfig) + if err != nil { + return nil, errors.Wrapf(err, "while initializing Clientset") + } + + informerFactory := externalversions.NewSharedInformerFactory(clientset, informerResyncPeriod) + functionService := newFunctionService(informerFactory.Kubeless().V1beta1().Functions().Informer()) + + return &Resolver{ + informerFactory: informerFactory, + functionResolver: newFunctionResolver(functionService), + }, nil +} + +func (r *Resolver) WaitForCacheSync(stopCh <-chan struct{}) { + r.informerFactory.Start(stopCh) + r.informerFactory.WaitForCacheSync(stopCh) +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/automock/async_api_spec_getter.go b/components/ui-api-layer/internal/domain/remoteenvironment/automock/async_api_spec_getter.go new file mode 100644 index 000000000000..b0ca6bcafa5b --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/automock/async_api_spec_getter.go @@ -0,0 +1,34 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" + +import storage "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + +// AsyncApiSpecGetter is an autogenerated mock type for the AsyncApiSpecGetter type +type AsyncApiSpecGetter struct { + mock.Mock +} + +// Find provides a mock function with given fields: kind, id +func (_m *AsyncApiSpecGetter) Find(kind string, id string) (*storage.AsyncApiSpec, error) { + ret := _m.Called(kind, id) + + var r0 *storage.AsyncApiSpec + if rf, ok := ret.Get(0).(func(string, string) *storage.AsyncApiSpec); ok { + r0 = rf(kind, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*storage.AsyncApiSpec) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(kind, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/automock/event_activation_lister.go b/components/ui-api-layer/internal/domain/remoteenvironment/automock/event_activation_lister.go new file mode 100644 index 000000000000..3a9932332f32 --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/automock/event_activation_lister.go @@ -0,0 +1,34 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" + +import v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + +// eventActivationLister is an autogenerated mock type for the eventActivationLister type +type eventActivationLister struct { + mock.Mock +} + +// List provides a mock function with given fields: environment +func (_m *eventActivationLister) List(environment string) ([]*v1alpha1.EventActivation, error) { + ret := _m.Called(environment) + + var r0 []*v1alpha1.EventActivation + if rf, ok := ret.Get(0).(func(string) []*v1alpha1.EventActivation); ok { + r0 = rf(environment) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1alpha1.EventActivation) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(environment) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/automock/export.go b/components/ui-api-layer/internal/domain/remoteenvironment/automock/export.go new file mode 100644 index 000000000000..f5264db69011 --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/automock/export.go @@ -0,0 +1,15 @@ +package automock + +// EventActivation + +func NewEventActivationLister() *eventActivationLister { + return new(eventActivationLister) +} + +func NewReSvc() *reSvc { + return new(reSvc) +} + +func NewStatusGetter() *statusGetter { + return new(statusGetter) +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/automock/re_svc.go b/components/ui-api-layer/internal/domain/remoteenvironment/automock/re_svc.go new file mode 100644 index 000000000000..048a337756b6 --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/automock/re_svc.go @@ -0,0 +1,162 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import pager "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + +import v1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + +// reSvc is an autogenerated mock type for the reSvc type +type reSvc struct { + mock.Mock +} + +// Disable provides a mock function with given fields: namespace, name +func (_m *reSvc) Disable(namespace string, name string) error { + ret := _m.Called(namespace, name) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(namespace, name) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Enable provides a mock function with given fields: namespace, name +func (_m *reSvc) Enable(namespace string, name string) (*v1alpha1.EnvironmentMapping, error) { + ret := _m.Called(namespace, name) + + var r0 *v1alpha1.EnvironmentMapping + if rf, ok := ret.Get(0).(func(string, string) *v1alpha1.EnvironmentMapping); ok { + r0 = rf(namespace, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1alpha1.EnvironmentMapping) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(namespace, name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Find provides a mock function with given fields: name +func (_m *reSvc) Find(name string) (*v1alpha1.RemoteEnvironment, error) { + ret := _m.Called(name) + + var r0 *v1alpha1.RemoteEnvironment + if rf, ok := ret.Get(0).(func(string) *v1alpha1.RemoteEnvironment); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1alpha1.RemoteEnvironment) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetConnectionUrl provides a mock function with given fields: remoteEnvironment +func (_m *reSvc) GetConnectionUrl(remoteEnvironment string) (string, error) { + ret := _m.Called(remoteEnvironment) + + var r0 string + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(remoteEnvironment) + } else { + r0 = ret.Get(0).(string) + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(remoteEnvironment) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// List provides a mock function with given fields: params +func (_m *reSvc) List(params pager.PagingParams) ([]*v1alpha1.RemoteEnvironment, error) { + ret := _m.Called(params) + + var r0 []*v1alpha1.RemoteEnvironment + if rf, ok := ret.Get(0).(func(pager.PagingParams) []*v1alpha1.RemoteEnvironment); ok { + r0 = rf(params) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1alpha1.RemoteEnvironment) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(pager.PagingParams) error); ok { + r1 = rf(params) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ListInEnvironment provides a mock function with given fields: environment +func (_m *reSvc) ListInEnvironment(environment string) ([]*v1alpha1.RemoteEnvironment, error) { + ret := _m.Called(environment) + + var r0 []*v1alpha1.RemoteEnvironment + if rf, ok := ret.Get(0).(func(string) []*v1alpha1.RemoteEnvironment); ok { + r0 = rf(environment) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1alpha1.RemoteEnvironment) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(environment) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ListNamespacesFor provides a mock function with given fields: reName +func (_m *reSvc) ListNamespacesFor(reName string) ([]string, error) { + ret := _m.Called(reName) + + var r0 []string + if rf, ok := ret.Get(0).(func(string) []string); ok { + r0 = rf(reName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(reName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/automock/status_getter.go b/components/ui-api-layer/internal/domain/remoteenvironment/automock/status_getter.go new file mode 100644 index 000000000000..56ed18e4e793 --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/automock/status_getter.go @@ -0,0 +1,24 @@ +// Code generated by mockery v1.0.0 +package automock + +import gateway "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/remoteenvironment/gateway" +import mock "github.com/stretchr/testify/mock" + +// statusGetter is an autogenerated mock type for the statusGetter type +type statusGetter struct { + mock.Mock +} + +// GetStatus provides a mock function with given fields: reName +func (_m *statusGetter) GetStatus(reName string) gateway.Status { + ret := _m.Called(reName) + + var r0 gateway.Status + if rf, ok := ret.Get(0).(func(string) gateway.Status); ok { + r0 = rf(reName) + } else { + r0 = ret.Get(0).(gateway.Status) + } + + return r0 +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_converter.go b/components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_converter.go new file mode 100644 index 000000000000..00913f403c41 --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_converter.go @@ -0,0 +1,110 @@ +package remoteenvironment + +import ( + "strings" + + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +) + +type eventActivationConverter struct{} + +func (c *eventActivationConverter) ToGQL(in *v1alpha1.EventActivation) *gqlschema.EventActivation { + if in == nil { + return nil + } + + return &gqlschema.EventActivation{ + Name: in.Name, + DisplayName: in.Spec.DisplayName, + Source: c.toGQLSource(*in), + } +} + +func (c *eventActivationConverter) ToGQLs(in []*v1alpha1.EventActivation) []gqlschema.EventActivation { + var result []gqlschema.EventActivation + for _, item := range in { + converted := c.ToGQL(item) + if converted != nil { + result = append(result, *converted) + } + } + + return result +} + +func (c *eventActivationConverter) toGQLSource(in v1alpha1.EventActivation) gqlschema.EventActivationSource { + return gqlschema.EventActivationSource{ + Environment: in.Spec.Source.Environment, + Namespace: in.Spec.Source.Namespace, + Type: in.Spec.Source.Type, + } +} + +func (c *eventActivationConverter) ToGQLEvents(in *storage.AsyncApiSpec) []gqlschema.EventActivationEvent { + if in == nil { + return []gqlschema.EventActivationEvent{} + } + + var events []gqlschema.EventActivationEvent + for k, topic := range in.Data.Topics { + if !c.isSubscribeEvent(topic) { + continue + } + + eventType, version := c.getEventVersionedType(k) + events = append(events, gqlschema.EventActivationEvent{ + EventType: eventType, + Version: version, + Description: c.getSummary(topic), + }) + } + + return events +} + +func (c *eventActivationConverter) getEventVersionedType(in string) (string, string) { + lastDotIndex := strings.LastIndex(in, ".") + if lastDotIndex < 0 { + return in, "" + } + + eventType := in[:lastDotIndex] + version := in[(lastDotIndex + 1):] + + return eventType, version +} + +func (c *eventActivationConverter) isSubscribeEvent(in interface{}) bool { + _, exists := c.convertToMap(in)["subscribe"] + return exists +} + +func (c *eventActivationConverter) getSummary(in interface{}) string { + subscribe, exists := c.convertToMap(in)["subscribe"] + if !exists { + return "" + } + + summary, exists := c.convertToMap(subscribe)["summary"] + if !exists { + return "" + } + + result, ok := summary.(string) + if !ok { + return "" + } + + return result +} + +func (c *eventActivationConverter) convertToMap(in interface{}) map[string]interface{} { + result, ok := in.(map[string]interface{}) + if !ok { + return map[string]interface{}{} + } + + return result +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_converter_test.go b/components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_converter_test.go new file mode 100644 index 000000000000..d64945c5e852 --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_converter_test.go @@ -0,0 +1,190 @@ +package remoteenvironment + +import ( + "testing" + + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestEventActivationConverter_ToGQL(t *testing.T) { + t.Run("Success", func(t *testing.T) { + converter := &eventActivationConverter{} + + result := converter.ToGQL(fixEventActivation()) + + assert.Equal(t, &gqlschema.EventActivation{ + Name: "name", + DisplayName: "test", + Source: gqlschema.EventActivationSource{ + Type: "nope", + Namespace: "nms", + Environment: "env", + }, + }, result) + }) + + t.Run("Nil", func(t *testing.T) { + converter := &eventActivationConverter{} + + result := converter.ToGQL(nil) + + assert.Nil(t, result) + }) + + t.Run("Empty", func(t *testing.T) { + converter := &eventActivationConverter{} + converter.ToGQL(&v1alpha1.EventActivation{}) + }) +} + +func TestEventActivationConverter_ToGQLs(t *testing.T) { + t.Run("Success", func(t *testing.T) { + eventActivations := []*v1alpha1.EventActivation{ + fixEventActivation(), + fixEventActivation(), + } + + converter := eventActivationConverter{} + result := converter.ToGQLs(eventActivations) + + assert.Len(t, result, 2) + assert.Equal(t, "name", result[0].Name) + }) + + t.Run("Empty", func(t *testing.T) { + var eventActivations []*v1alpha1.EventActivation + + converter := eventActivationConverter{} + result := converter.ToGQLs(eventActivations) + + assert.Empty(t, result) + }) + + t.Run("With nil", func(t *testing.T) { + eventActivations := []*v1alpha1.EventActivation{ + nil, + fixEventActivation(), + nil, + } + + converter := eventActivationConverter{} + result := converter.ToGQLs(eventActivations) + + assert.Len(t, result, 1) + assert.Equal(t, "name", result[0].Name) + }) +} + +func TestEventActivationConverter_ToGQLEvents(t *testing.T) { + t.Run("Success", func(t *testing.T) { + converter := &eventActivationConverter{} + + result := converter.ToGQLEvents(fixAsyncApiSpec()) + + assert.Len(t, result, 2) + assert.Contains(t, result, gqlschema.EventActivationEvent{ + EventType: "sell", + Version: "v1", + Description: "desc", + }) + assert.Contains(t, result, gqlschema.EventActivationEvent{ + EventType: "sell", + Version: "v2", + Description: "desc", + }) + }) + + t.Run("Nil", func(t *testing.T) { + converter := &eventActivationConverter{} + + result := converter.ToGQL(nil) + + assert.Empty(t, result) + }) + + t.Run("Empty", func(t *testing.T) { + converter := &eventActivationConverter{} + + result := converter.ToGQL(&v1alpha1.EventActivation{}) + + assert.Empty(t, result) + }) + + t.Run("Without topics", func(t *testing.T) { + converter := &eventActivationConverter{} + asyncApi := fixAsyncApiSpec() + asyncApi.Data.Topics = map[string]interface{}{} + + result := converter.ToGQLEvents(asyncApi) + + assert.Empty(t, result) + }) + + t.Run("Topics without version", func(t *testing.T) { + converter := &eventActivationConverter{} + + result := converter.ToGQLEvents(fixAsyncApiSpecWithoutVersion()) + + assert.Len(t, result, 1) + assert.Contains(t, result, gqlschema.EventActivationEvent{ + EventType: "sell", + Version: "", + Description: "desc", + }) + }) +} + +func fixEventActivation() *v1alpha1.EventActivation { + return &v1alpha1.EventActivation{ + Spec: v1alpha1.EventActivationSpec{ + DisplayName: "test", + Source: v1alpha1.Source{ + Environment: "env", + Namespace: "nms", + Type: "nope", + }, + }, + ObjectMeta: v1.ObjectMeta{ + Name: "name", + }, + } +} + +func fixAsyncApiSpec() *storage.AsyncApiSpec { + return &storage.AsyncApiSpec{ + Data: storage.AsyncApiSpecData{ + AsyncAPI: "1.0.0", + Topics: map[string]interface{}{ + "sell.v1": map[string]interface{}{ + "subscribe": map[string]interface{}{ + "summary": "desc", + }, + }, + "sell.v2": map[string]interface{}{ + "subscribe": map[string]interface{}{ + "summary": "desc", + }, + }, + }, + }, + } +} + +func fixAsyncApiSpecWithoutVersion() *storage.AsyncApiSpec { + return &storage.AsyncApiSpec{ + Data: storage.AsyncApiSpecData{ + AsyncAPI: "1.0.0", + Topics: map[string]interface{}{ + "sell": map[string]interface{}{ + "subscribe": map[string]interface{}{ + "summary": "desc", + }, + }, + }, + }, + } +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_resolver.go b/components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_resolver.go new file mode 100644 index 000000000000..433a26fe7bd6 --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_resolver.go @@ -0,0 +1,65 @@ +package remoteenvironment + +import ( + "context" + + "github.com/golang/glog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/pkg/errors" +) + +type eventActivationResolver struct { + service eventActivationLister + converter *eventActivationConverter + asyncApiSpecGetter AsyncApiSpecGetter +} + +func newEventActivationResolver(service eventActivationLister, asyncApiSpecGetter AsyncApiSpecGetter) *eventActivationResolver { + return &eventActivationResolver{ + service: service, + converter: &eventActivationConverter{}, + asyncApiSpecGetter: asyncApiSpecGetter, + } +} + +func (r *eventActivationResolver) EventActivationsQuery(ctx context.Context, environment string) ([]gqlschema.EventActivation, error) { + items, err := r.service.List(environment) + if err != nil { + glog.Error(errors.Wrapf(err, "while listing eventActivations in `%s` environment", environment)) + return nil, err + } + + return r.converter.ToGQLs(items), nil +} + +func (r *eventActivationResolver) EventActivationEventsField(ctx context.Context, eventActivation *gqlschema.EventActivation) ([]gqlschema.EventActivationEvent, error) { + if eventActivation == nil { + glog.Errorf("EventActivation cannot be empty in order to resolve events field") + return nil, r.eventsError() + } + + asyncApiSpec, err := r.asyncApiSpecGetter.Find("service-class", eventActivation.Name) + if err != nil { + glog.Error(errors.Wrapf(err, "while gathering events for EventActivation %s", eventActivation.Name)) + return nil, r.eventsError() + } + + if asyncApiSpec == nil { + return []gqlschema.EventActivationEvent{}, nil + } + + if asyncApiSpec.Data.AsyncAPI != "1.0.0" { + glog.Errorf("not supported version `%s` of asyncApiSpec", asyncApiSpec.Data.AsyncAPI) + return nil, r.eventsError() + } + + return r.converter.ToGQLEvents(asyncApiSpec), nil +} + +func (r *eventActivationResolver) genericError() error { + return errors.New("Cannot get EventActivation") +} + +func (r *eventActivationResolver) eventsError() error { + return errors.New("Cannot list Events") +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_resolver_test.go b/components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_resolver_test.go new file mode 100644 index 000000000000..02d8157ccd90 --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_resolver_test.go @@ -0,0 +1,162 @@ +package remoteenvironment_test + +import ( + "testing" + + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/remoteenvironment" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/remoteenvironment/automock" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEventActivationResolver_EventActivationsQuery(t *testing.T) { + t.Run("Success", func(t *testing.T) { + eventActivation1 := fixEventActivation("test", "event1") + eventActivation2 := fixEventActivation("test", "event2") + + svc := automock.NewEventActivationLister() + svc.On("List", "test").Return([]*v1alpha1.EventActivation{ + eventActivation1, + eventActivation2, + }, nil) + defer svc.AssertExpectations(t) + + resolver := remoteenvironment.NewEventActivationResolver(svc, nil) + result, err := resolver.EventActivationsQuery(nil, "test") + + require.NoError(t, err) + assert.Len(t, result, 2) + assert.Contains(t, result, *fixGQLEventActivation("test", "event1")) + assert.Contains(t, result, *fixGQLEventActivation("test", "event2")) + }) + + t.Run("Not found", func(t *testing.T) { + svc := automock.NewEventActivationLister() + svc.On("List", "test").Return([]*v1alpha1.EventActivation{}, nil) + defer svc.AssertExpectations(t) + + resolver := remoteenvironment.NewEventActivationResolver(svc, nil) + result, err := resolver.EventActivationsQuery(nil, "test") + + require.NoError(t, err) + assert.Empty(t, result) + }) + + t.Run("Error", func(t *testing.T) { + svc := automock.NewEventActivationLister() + svc.On("List", "test").Return(nil, errors.New("trol")) + defer svc.AssertExpectations(t) + + resolver := remoteenvironment.NewEventActivationResolver(svc, nil) + _, err := resolver.EventActivationsQuery(nil, "test") + + require.Error(t, err) + }) +} + +func TestEventActivationResolver_EventActivationEventsField(t *testing.T) { + t.Run("Success", func(t *testing.T) { + asyncApiSpec := &storage.AsyncApiSpec{ + Data: storage.AsyncApiSpecData{ + AsyncAPI: "1.0.0", + Topics: map[string]interface{}{ + "sell.v1": map[string]interface{}{ + "subscribe": map[string]interface{}{ + "summary": "desc", + }, + }, + "sell.v2": map[string]interface{}{ + "subscribe": map[string]interface{}{ + "summary": "desc", + }, + }, + }, + }, + } + + getter := new(automock.AsyncApiSpecGetter) + getter.On("Find", "service-class", "test").Return(asyncApiSpec, nil) + defer getter.AssertExpectations(t) + + resolver := remoteenvironment.NewEventActivationResolver(nil, getter) + result, err := resolver.EventActivationEventsField(nil, fixGQLEventActivation("env", "test")) + + require.NoError(t, err) + assert.Len(t, result, 2) + assert.Contains(t, result, *fixGQLEventActivationEvent("sell", "v1", "desc")) + assert.Contains(t, result, *fixGQLEventActivationEvent("sell", "v2", "desc")) + }) + + t.Run("Not found", func(t *testing.T) { + getter := new(automock.AsyncApiSpecGetter) + getter.On("Find", "service-class", "test").Return(nil, nil) + defer getter.AssertExpectations(t) + + resolver := remoteenvironment.NewEventActivationResolver(nil, getter) + result, err := resolver.EventActivationEventsField(nil, fixGQLEventActivation("env", "test")) + + require.NoError(t, err) + assert.Empty(t, result) + }) + + t.Run("Invalid version", func(t *testing.T) { + asyncApiSpec := &storage.AsyncApiSpec{ + Data: storage.AsyncApiSpecData{ + AsyncAPI: "1.0.1", + }, + } + + getter := new(automock.AsyncApiSpecGetter) + getter.On("Find", "service-class", "test").Return(asyncApiSpec, nil) + defer getter.AssertExpectations(t) + + resolver := remoteenvironment.NewEventActivationResolver(nil, getter) + _, err := resolver.EventActivationEventsField(nil, fixGQLEventActivation("env", "test")) + + require.Error(t, err) + }) + + t.Run("Nil", func(t *testing.T) { + getter := new(automock.AsyncApiSpecGetter) + + resolver := remoteenvironment.NewEventActivationResolver(nil, getter) + _, err := resolver.EventActivationEventsField(nil, nil) + + require.Error(t, err) + }) + + t.Run("Error", func(t *testing.T) { + getter := new(automock.AsyncApiSpecGetter) + getter.On("Find", "service-class", "test").Return(nil, errors.New("nope")) + defer getter.AssertExpectations(t) + + resolver := remoteenvironment.NewEventActivationResolver(nil, getter) + _, err := resolver.EventActivationEventsField(nil, fixGQLEventActivation("env", "test")) + + require.Error(t, err) + }) +} + +func fixGQLEventActivation(environment, name string) *gqlschema.EventActivation { + return &gqlschema.EventActivation{ + Name: name, + DisplayName: "aha!", + Source: gqlschema.EventActivationSource{ + Namespace: "com.sap.test", + Type: "taaa", + Environment: environment, + }, + } +} + +func fixGQLEventActivationEvent(eventType, version, desc string) *gqlschema.EventActivationEvent { + return &gqlschema.EventActivationEvent{ + EventType: eventType, + Version: version, + Description: desc, + } +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_service.go b/components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_service.go new file mode 100644 index 000000000000..9bef2836e708 --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_service.go @@ -0,0 +1,50 @@ +package remoteenvironment + +import ( + "fmt" + + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "k8s.io/client-go/tools/cache" +) + +type eventActivationService struct { + informer cache.SharedIndexInformer +} + +func newEventActivationService(informer cache.SharedIndexInformer) *eventActivationService { + return &eventActivationService{ + informer: informer, + } +} + +func (svc *eventActivationService) List(environment string) ([]*v1alpha1.EventActivation, error) { + items, err := svc.informer.GetIndexer().ByIndex("namespace", environment) + if err != nil { + return nil, err + } + + return svc.toEventActivations(items) +} + +func (svc *eventActivationService) toEventActivation(item interface{}) (*v1alpha1.EventActivation, error) { + eventActivation, ok := item.(*v1alpha1.EventActivation) + if !ok { + return nil, fmt.Errorf("incorrect item type: %T, should be: *EventActivation", item) + } + + return eventActivation, nil +} + +func (svc *eventActivationService) toEventActivations(items []interface{}) ([]*v1alpha1.EventActivation, error) { + var eventActivations []*v1alpha1.EventActivation + for _, item := range items { + eventActivation, err := svc.toEventActivation(item) + if err != nil { + return nil, err + } + + eventActivations = append(eventActivations, eventActivation) + } + + return eventActivations, nil +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_service_test.go b/components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_service_test.go new file mode 100644 index 000000000000..bc7f3fb63608 --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/eventactivation_service_test.go @@ -0,0 +1,69 @@ +package remoteenvironment_test + +import ( + "testing" + "time" + + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/fake" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/informers/externalversions" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/remoteenvironment" + testingUtils "github.com/kyma-project/kyma/components/ui-api-layer/internal/testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/cache" +) + +func TestEventActivationService_List(t *testing.T) { + t.Run("Success", func(t *testing.T) { + eventActivation1 := fixEventActivation("test", "test1") + eventActivation2 := fixEventActivation("test", "test2") + eventActivation3 := fixEventActivation("nope", "test3") + + informer := fixEventActivationInformer(eventActivation1, eventActivation2, eventActivation3) + svc := remoteenvironment.NewEventActivationService(informer) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + + items, err := svc.List("test") + + require.NoError(t, err) + assert.Len(t, items, 2) + assert.ElementsMatch(t, items, []*v1alpha1.EventActivation{eventActivation1, eventActivation2}) + }) + + t.Run("Not found", func(t *testing.T) { + informer := fixEventActivationInformer() + svc := remoteenvironment.NewEventActivationService(informer) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + + items, err := svc.List("test") + + require.NoError(t, err) + assert.Len(t, items, 0) + }) +} + +func fixEventActivation(environment, name string) *v1alpha1.EventActivation { + return &v1alpha1.EventActivation{ + ObjectMeta: v1.ObjectMeta{ + Name: name, + Namespace: environment, + }, + Spec: v1alpha1.EventActivationSpec{ + Source: v1alpha1.Source{ + Environment: environment, + Namespace: "com.sap.test", + Type: "taaa", + }, + DisplayName: "aha!", + }, + } +} + +func fixEventActivationInformer(objects ...runtime.Object) cache.SharedIndexInformer { + client := fake.NewSimpleClientset(objects...) + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + return informerFactory.Remoteenvironment().V1alpha1().EventActivations().Informer() +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/export_test.go b/components/ui-api-layer/internal/domain/remoteenvironment/export_test.go new file mode 100644 index 000000000000..c3f54f6575fd --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/export_test.go @@ -0,0 +1,19 @@ +package remoteenvironment + +import ( + remoteenvironmentv1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1" + reMappinglister "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1" + "k8s.io/client-go/tools/cache" +) + +func NewRemoteEnvironmentService(client remoteenvironmentv1alpha1.RemoteenvironmentV1alpha1Interface, config Config, mappingInformer cache.SharedIndexInformer, mappingLister reMappinglister.EnvironmentMappingLister, reInformer cache.SharedIndexInformer) (*remoteEnvironmentService, error) { + return newRemoteEnvironmentService(client, config, mappingInformer, mappingLister, reInformer) +} + +func NewEventActivationService(informer cache.SharedIndexInformer) *eventActivationService { + return newEventActivationService(informer) +} + +func NewEventActivationResolver(service eventActivationLister, asyncApiSpecGetter AsyncApiSpecGetter) *eventActivationResolver { + return newEventActivationResolver(service, asyncApiSpecGetter) +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/gateway/automock/expect.go b/components/ui-api-layer/internal/domain/remoteenvironment/gateway/automock/expect.go new file mode 100644 index 000000000000..22561cb4c90d --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/gateway/automock/expect.go @@ -0,0 +1,9 @@ +package automock + +import ( + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/remoteenvironment/gateway" +) + +func (gl *gatewayServiceLister) ReturnOnGetGatewayServices(result []gateway.ServiceData) { + gl.On("ListGatewayServices").Return(result) +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/gateway/automock/export.go b/components/ui-api-layer/internal/domain/remoteenvironment/gateway/automock/export.go new file mode 100644 index 000000000000..e6c70b9b330c --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/gateway/automock/export.go @@ -0,0 +1,5 @@ +package automock + +func NewGatewayServiceLister() *gatewayServiceLister { + return new(gatewayServiceLister) +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/gateway/automock/gateway_service_lister.go b/components/ui-api-layer/internal/domain/remoteenvironment/gateway/automock/gateway_service_lister.go new file mode 100644 index 000000000000..3d050a7c768d --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/gateway/automock/gateway_service_lister.go @@ -0,0 +1,26 @@ +// Code generated by mockery v1.0.0 +package automock + +import gateway "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/remoteenvironment/gateway" +import mock "github.com/stretchr/testify/mock" + +// gatewayServiceLister is an autogenerated mock type for the gatewayServiceLister type +type gatewayServiceLister struct { + mock.Mock +} + +// ListGatewayServices provides a mock function with given fields: +func (_m *gatewayServiceLister) ListGatewayServices() []gateway.ServiceData { + ret := _m.Called() + + var r0 []gateway.ServiceData + if rf, ok := ret.Get(0).(func() []gateway.ServiceData); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gateway.ServiceData) + } + } + + return r0 +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/gateway/config.go b/components/ui-api-layer/internal/domain/remoteenvironment/gateway/config.go new file mode 100644 index 000000000000..35602e51a246 --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/gateway/config.go @@ -0,0 +1,9 @@ +package gateway + +import "time" + +type Config struct { + StatusRefreshPeriod time.Duration `envconfig:"default=15s"` + StatusCallTimeout time.Duration `envconfig:"default=500ms"` + IntegrationNamespace string +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/gateway/export_test.go b/components/ui-api-layer/internal/domain/remoteenvironment/gateway/export_test.go new file mode 100644 index 000000000000..93b383ffd333 --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/gateway/export_test.go @@ -0,0 +1,15 @@ +package gateway + +import ( + "time" + + corev1 "k8s.io/client-go/kubernetes/typed/core/v1" +) + +func NewStatusWatcher(gatewayServiceLister gatewayServiceLister, httpTimeout time.Duration) *gatewayStatusWatcher { + return newStatusWatcher(gatewayServiceLister, httpTimeout) +} + +func NewProvider(corev1Interface corev1.CoreV1Interface, integrationNamespace string, informerResyncPeriod time.Duration) *provider { + return newProvider(corev1Interface, integrationNamespace, informerResyncPeriod) +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/gateway/provider.go b/components/ui-api-layer/internal/domain/remoteenvironment/gateway/provider.go new file mode 100644 index 000000000000..7357cb8f1232 --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/gateway/provider.go @@ -0,0 +1,122 @@ +package gateway + +import ( + "fmt" + "time" + + "github.com/golang/glog" + "github.com/pkg/errors" + apiv1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/watch" + corev1 "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/tools/cache" +) + +const ( + remoteEnvironmentLabelName = "remoteEnvironment" + externalAPIPortName = "ext-api-port" +) + +/* +The contract, which describes, how to find service for given remote environment: + +1. K8s service is labelled with key remoteEnvironment, value is the name of the remote environment +2. K8s Service contains one port with name “ext-api-port” and this port is used for status check. +3. K8s Service is in the ysf-integration namespace (this can be changed in ui-api-layer chart configuration) +4. The endpoint is /v1/health, and we are expecting HTTP 200, any other status code means service is not healthy. +*/ +type provider struct { + informer cache.SharedIndexInformer +} + +type ServiceData struct { + // Host is a host, which is used to do HTTP call for status check, for example ec-default.production.svc.cluster.local:8080 + Host string + + // RemoteEnvironmentName is the name of Remote Environment, taken from the remoteEnvironment label value + RemoteEnvironmentName string +} + +func newProvider(corev1Interface corev1.CoreV1Interface, integrationNamespace string, informerResyncPeriod time.Duration) *provider { + svcInterface := corev1Interface.Services(integrationNamespace) + + svcInformer := cache.NewSharedIndexInformer(&cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + return svcInterface.List(options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + return svcInterface.Watch(options) + }, + }, &apiv1.Service{}, + informerResyncPeriod, + cache.Indexers{}, + ) + + return &provider{ + informer: svcInformer, + } +} + +func (p *provider) ListGatewayServices() []ServiceData { + objects := p.informer.GetStore().List() + + result := make([]ServiceData, 0) + for _, obj := range objects { + svc, ok := obj.(*apiv1.Service) + if !ok { + continue + } + + reName, foundReName := p.extractRemoteEnvironmentName(svc) + if foundReName { + h, err := p.host(svc) + if err != nil { + glog.Errorf("Could not find correct port in remote environment service %s", svc.Name) + } + result = append(result, ServiceData{ + Host: h, + RemoteEnvironmentName: reName, + }) + } + } + return result +} + +func (p *provider) WaitForCacheSync(stopCh <-chan struct{}) { + defer utilruntime.HandleCrash() + + go p.informer.Run(stopCh) + if !cache.WaitForCacheSync(stopCh, p.informer.HasSynced) { + glog.Error("Timeout occurred on waiting for gateway api service caches to sync.") + return + } +} + +func (p *provider) extractRemoteEnvironmentName(obj *apiv1.Service) (string, bool) { + for k, v := range obj.Labels { + if k == remoteEnvironmentLabelName && v != "" { + return v, true + } + } + return "", false +} + +func (p *provider) servicePort(obj *apiv1.Service) (int32, error) { + for _, port := range obj.Spec.Ports { + if port.Name == externalAPIPortName { + return port.Port, nil + } + } + return 0, fmt.Errorf("Could not find port with name %s", externalAPIPortName) +} + +func (p *provider) host(obj *apiv1.Service) (string, error) { + port, err := p.servicePort(obj) + if err != nil { + return "", errors.Wrap(err, "while creating host") + } + return fmt.Sprintf("%s.%s.svc.cluster.local:%d", obj.Name, obj.Namespace, port), nil +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/gateway/provider_test.go b/components/ui-api-layer/internal/domain/remoteenvironment/gateway/provider_test.go new file mode 100644 index 000000000000..79185d4f346e --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/gateway/provider_test.go @@ -0,0 +1,96 @@ +package gateway_test + +import ( + "testing" + "time" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/remoteenvironment/gateway" + "github.com/stretchr/testify/assert" + apiv1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/client-go/kubernetes/fake" +) + +func TestGatewayServiceProvider(t *testing.T) { + // GIVEN + fakeClientSet := fake.NewSimpleClientset( + fixService("other1", "ysf-integration"), + fixReService("prod", "ysf-integration", "ec-prod"), + fixReService("stage", "ysf-integration", "ec-stage"), + fixService("other1", "ysf-system"), + fixReService("invalid-ec", "ysf-system", "ec-invalid"), + fixPod(), + ) + core := fakeClientSet.CoreV1() + svc := gateway.NewProvider(core, "ysf-integration", time.Hour) + stopCh := make(chan struct{}) + svc.WaitForCacheSync(stopCh) + + // WHEN + items := svc.ListGatewayServices() + + // THEN + assert.Len(t, items, 2) + assert.Contains(t, items, gateway.ServiceData{ + Host: "prod.ysf-integration.svc.cluster.local:80", + RemoteEnvironmentName: "ec-prod", + }) + assert.Contains(t, items, gateway.ServiceData{ + Host: "stage.ysf-integration.svc.cluster.local:80", + RemoteEnvironmentName: "ec-stage", + }) +} + +func fixPod() *apiv1.Pod { + return &apiv1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy-pod", + Namespace: "ysf-integration", + }, + Spec: apiv1.PodSpec{}, + } +} + +func fixService(name, namespace string) *apiv1.Service { + return &apiv1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: apiv1.ServiceSpec{ + Selector: map[string]string{"app": "svc"}, + Ports: []apiv1.ServicePort{ + { + Name: "http", + Protocol: "TCP", + Port: 80, + TargetPort: intstr.IntOrString{Type: intstr.Int, IntVal: 8080}, + }, + }, + }, + } +} + +func fixReService(name, namespace, reName string) *apiv1.Service { + return &apiv1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: map[string]string{ + "remoteEnvironment": reName, + }, + }, + Spec: apiv1.ServiceSpec{ + Selector: map[string]string{"app": "svc"}, + Ports: []apiv1.ServicePort{ + { + Name: "ext-api-port", + Protocol: "TCP", + Port: 80, + TargetPort: intstr.IntOrString{Type: intstr.Int, IntVal: 8080}, + }, + }, + }, + } +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/gateway/service.go b/components/ui-api-layer/internal/domain/remoteenvironment/gateway/service.go new file mode 100644 index 000000000000..15515a5a46db --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/gateway/service.go @@ -0,0 +1,45 @@ +package gateway + +import ( + "time" + + "github.com/kyma-project/kyma/components/ui-api-layer/pkg/executor" + "github.com/pkg/errors" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +// Service gives a functionality to provide gateway status. It hides implementation details +// and exports necessary methods. +type Service struct { + provider *provider + statusWatcher *gatewayStatusWatcher + + cfg Config +} + +func New(restConfig *rest.Config, reCfg Config, informerResyncPeriod time.Duration) (*Service, error) { + k8sClient, err := kubernetes.NewForConfig(restConfig) + if err != nil { + return nil, errors.Wrap(err, "while initializing Clientset") + } + gatewaySvcProvider := newProvider(k8sClient.CoreV1(), reCfg.IntegrationNamespace, informerResyncPeriod) + watcher := newStatusWatcher(gatewaySvcProvider, reCfg.StatusCallTimeout) + + return &Service{ + provider: gatewaySvcProvider, + statusWatcher: watcher, + cfg: reCfg, + }, nil +} + +func (svc *Service) Start(stopCh <-chan struct{}) { + svc.provider.WaitForCacheSync(stopCh) + executor.NewPeriodic(svc.cfg.StatusRefreshPeriod, func(stopCh <-chan struct{}) { + svc.statusWatcher.Refresh(stopCh) + }).Run(stopCh) +} + +func (svc *Service) GetStatus(reName string) Status { + return svc.statusWatcher.GetStatus(reName) +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/gateway/status_watcher.go b/components/ui-api-layer/internal/domain/remoteenvironment/gateway/status_watcher.go new file mode 100644 index 000000000000..7b23515a8a3b --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/gateway/status_watcher.go @@ -0,0 +1,114 @@ +package gateway + +import ( + "fmt" + "net/http" + "sync" + "time" + + "github.com/golang/glog" + "github.com/kyma-project/kyma/components/ui-api-layer/pkg/iosafety" +) + +type Status string + +const ( + StatusNotServing Status = "NotServing" + StatusServing Status = "Serving" + StatusNotConfigured Status = "GatewayNotConfigured" +) + +//go:generate mockery -name=gatewayServiceLister -output=automock -outpkg=automock -case=underscore +type gatewayServiceLister interface { + ListGatewayServices() []ServiceData +} + +type gatewayStatusWatcher struct { + gatewayServiceLister gatewayServiceLister + healthiness map[string]bool + + mu sync.RWMutex + httpTimeout time.Duration + + httpClient *http.Client +} + +func newStatusWatcher(gatewayServiceLister gatewayServiceLister, httpTimeout time.Duration) *gatewayStatusWatcher { + httpClient := &http.Client{ + Timeout: httpTimeout, + } + return &gatewayStatusWatcher{ + gatewayServiceLister: gatewayServiceLister, + httpTimeout: httpTimeout, + healthiness: map[string]bool{}, + httpClient: httpClient, + } +} + +// GetStatus returns status of the remote environment +func (s *gatewayStatusWatcher) GetStatus(reName string) Status { + s.mu.RLock() + defer s.mu.RUnlock() + + healthy, exists := s.healthiness[reName] + if !exists { + return StatusNotConfigured + } + + if !healthy { + return StatusNotServing + } + + return StatusServing +} + +func (s *gatewayStatusWatcher) Refresh(stopCh <-chan struct{}) { + items := s.gatewayServiceLister.ListGatewayServices() + + localHealthiness := map[string]bool{} + for _, item := range items { + select { + case <-stopCh: + return + default: + } + + healthy, err := s.isHealthy(fmt.Sprintf("http://%s/v1/health", item.Host)) + if err != nil { + glog.Warningf("Remote Environment %s health check failed (%s), error: %s", + item.RemoteEnvironmentName, item.Host, err.Error()) + } + localHealthiness[item.RemoteEnvironmentName] = healthy + } + + s.swapStatusData(localHealthiness) +} + +func (s *gatewayStatusWatcher) swapStatusData(localStatusMap map[string]bool) { + s.mu.Lock() + defer s.mu.Unlock() + + s.healthiness = localStatusMap +} + +func (s *gatewayStatusWatcher) isHealthy(url string) (bool, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return false, err + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return false, err + } + defer func() { + _ = iosafety.DrainReader(resp.Body) + resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("expect HTTP status code %d got HTTP status code %d", http.StatusOK, resp.StatusCode) + } + + return true, nil +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/gateway/status_watcher_test.go b/components/ui-api-layer/internal/domain/remoteenvironment/gateway/status_watcher_test.go new file mode 100644 index 000000000000..d06b4bac3243 --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/gateway/status_watcher_test.go @@ -0,0 +1,72 @@ +package gateway_test + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/remoteenvironment/gateway" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/remoteenvironment/gateway/automock" +) + +func TestGatewayStatusWatcher_GetStatusNotServing(t *testing.T) { + // GIVEN + gtwLister := automock.NewGatewayServiceLister() + gtwLister.ReturnOnGetGatewayServices([]gateway.ServiceData{ + {Host: "ec-prod.production.svc.cluster.local:8080", RemoteEnvironmentName: "ec-prod"}, + }) + // the host is not existing, set http timeout to very short period to not wait too much + svc := gateway.NewStatusWatcher(gtwLister, time.Millisecond) + stopCh := make(chan struct{}) + + // WHEN + svc.Refresh(stopCh) + + // THEN + assert.Equal(t, svc.GetStatus("ec-prod"), gateway.StatusNotServing) +} + +func TestGatewayStatusWatcher_GetStatusServing(t *testing.T) { + // GIVEN + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + u, err := url.Parse(ts.URL) + require.NoError(t, err) + gtwLister := automock.NewGatewayServiceLister() + gtwLister.ReturnOnGetGatewayServices([]gateway.ServiceData{ + {Host: u.Host, RemoteEnvironmentName: "ec-prod"}, + }) + + svc := gateway.NewStatusWatcher(gtwLister, 200*time.Millisecond) + stopCh := make(chan struct{}) + + // WHEN + svc.Refresh(stopCh) + + // THEN + assert.Equal(t, svc.GetStatus("ec-prod"), gateway.StatusServing) +} + +func TestGatewayStatusWatcher_GetStatusNotConfigured(t *testing.T) { + // GIVEN + gtwLister := automock.NewGatewayServiceLister() + gtwLister.ReturnOnGetGatewayServices([]gateway.ServiceData{ + {Host: "ec-prod.production.svc.cluster.local:8080", RemoteEnvironmentName: "ec-prod"}, + }) + // the host is not existing, set http timeout to very short period to not wait too much + svc := gateway.NewStatusWatcher(gtwLister, time.Millisecond) + stopCh := make(chan struct{}) + + // WHEN + svc.Refresh(stopCh) + + // THEN + assert.Equal(t, svc.GetStatus("not-existing"), gateway.StatusNotConfigured) +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/interfaces.go b/components/ui-api-layer/internal/domain/remoteenvironment/interfaces.go new file mode 100644 index 000000000000..d0ba2f56eaa7 --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/interfaces.go @@ -0,0 +1,18 @@ +package remoteenvironment + +import ( + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" +) + +// EventActivation + +//go:generate mockery -name=eventActivationLister -output=automock -outpkg=automock -case=underscore +type eventActivationLister interface { + List(environment string) ([]*v1alpha1.EventActivation, error) +} + +//go:generate mockery -name=AsyncApiSpecGetter -output=automock -outpkg=automock -case=underscore +type AsyncApiSpecGetter interface { + Find(kind, id string) (*storage.AsyncApiSpec, error) +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/re.go b/components/ui-api-layer/internal/domain/remoteenvironment/re.go new file mode 100644 index 000000000000..e9b7e59f4372 --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/re.go @@ -0,0 +1,83 @@ +package remoteenvironment + +import ( + "time" + + "github.com/pkg/errors" + "k8s.io/client-go/rest" + + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/informers/externalversions" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/remoteenvironment/gateway" +) + +type Config struct { + Gateway gateway.Config + Connector ConnectorSvcCfg +} + +type ConnectorSvcCfg struct { + URL string + HTTPCallTimeout time.Duration `envconfig:"default=500ms"` +} + +type Container struct { + Resolver *Resolver + RELister RemoteEnvironmentLister +} + +type Resolver struct { + *remoteEnvironmentResolver + *eventActivationResolver + + informerFactory externalversions.SharedInformerFactory + gatewayService *gateway.Service +} + +type RemoteEnvironmentLister interface { + ListInEnvironment(environment string) ([]*v1alpha1.RemoteEnvironment, error) + ListNamespacesFor(reName string) ([]string, error) +} + +func New(restConfig *rest.Config, reCfg Config, informerResyncPeriod time.Duration, asyncApiSpecGetter AsyncApiSpecGetter) (*Container, error) { + client, err := versioned.NewForConfig(restConfig) + if err != nil { + return nil, errors.Wrap(err, "while initializing Clientset") + } + + informerFactory := externalversions.NewSharedInformerFactory(client, informerResyncPeriod) + remoteEnvironmentGroup := informerFactory.Remoteenvironment().V1alpha1() + + envMappingInformer := remoteEnvironmentGroup.EnvironmentMappings().Informer() + envMappingLister := remoteEnvironmentGroup.EnvironmentMappings().Lister() + remoteEnvInformer := remoteEnvironmentGroup.RemoteEnvironments().Informer() + + service, err := newRemoteEnvironmentService(client.RemoteenvironmentV1alpha1(), reCfg, envMappingInformer, envMappingLister, remoteEnvInformer) + if err != nil { + return nil, errors.Wrap(err, "while creating remote environment service") + } + + gatewayService, err := gateway.New(restConfig, reCfg.Gateway, informerResyncPeriod) + if err != nil { + return nil, errors.Wrap(err, "while creating gateway service") + } + + eventActivationService := newEventActivationService(remoteEnvironmentGroup.EventActivations().Informer()) + return &Container{ + Resolver: &Resolver{ + remoteEnvironmentResolver: NewRemoteEnvironmentResolver(service, gatewayService), + gatewayService: gatewayService, + eventActivationResolver: newEventActivationResolver(eventActivationService, asyncApiSpecGetter), + + informerFactory: informerFactory, + }, + RELister: service, + }, nil +} + +func (r *Resolver) WaitForCacheSync(stopCh <-chan struct{}) { + r.informerFactory.Start(stopCh) + r.gatewayService.Start(stopCh) + r.informerFactory.WaitForCacheSync(stopCh) +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/re_converter.go b/components/ui-api-layer/internal/domain/remoteenvironment/re_converter.go new file mode 100644 index 000000000000..3bb4d3a3427f --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/re_converter.go @@ -0,0 +1,66 @@ +package remoteenvironment + +import ( + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +) + +type remoteEnvironmentConverter struct{} + +func (c *remoteEnvironmentConverter) ToGQL(in *v1alpha1.RemoteEnvironment) gqlschema.RemoteEnvironment { + if in == nil { + return gqlschema.RemoteEnvironment{} + } + + var reServices []gqlschema.RemoteEnvironmentService + + for _, svc := range in.Spec.Services { + dmSvc := gqlschema.RemoteEnvironmentService{ + ID: svc.ID, + DisplayName: svc.DisplayName, + LongDescription: svc.LongDescription, + ProviderDisplayName: svc.ProviderDisplayName, + Tags: svc.Tags, + Entries: c.mapEntriesCRToDTO(svc.Entries), + } + + reServices = append(reServices, dmSvc) + } + + dto := gqlschema.RemoteEnvironment{ + Name: in.Name, + Description: in.Spec.Description, + Source: gqlschema.RemoteEnvironmentSource{ + Type: in.Spec.Source.Type, + Namespace: in.Spec.Source.Namespace, + Environment: in.Spec.Source.Environment, + }, + Services: reServices, + } + + return dto +} + +func (mapper *remoteEnvironmentConverter) mapEntriesCRToDTO(entries []v1alpha1.Entry) []gqlschema.RemoteEnvironmentEntry { + dtos := make([]gqlschema.RemoteEnvironmentEntry, 0, len(entries)) + for _, entry := range entries { + switch entry.Type { + case "API": + dtos = append(dtos, gqlschema.RemoteEnvironmentEntry{ + Type: entry.Type, + AccessLabel: mapper.ptrString(entry.AccessLabel), + GatewayUrl: mapper.ptrString(entry.GatewayUrl), + }) + case "Events": + dtos = append(dtos, gqlschema.RemoteEnvironmentEntry{ + Type: entry.Type, + }) + } + } + return dtos +} + +// ptrString returns a pointer to the string value passed in. +func (*remoteEnvironmentConverter) ptrString(v string) *string { + return &v +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/re_converter_test.go b/components/ui-api-layer/internal/domain/remoteenvironment/re_converter_test.go new file mode 100644 index 000000000000..dcb97dead402 --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/re_converter_test.go @@ -0,0 +1,87 @@ +package remoteenvironment + +import ( + "testing" + + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestRemoteEnvironmentConverter_ToGQL(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // given + fix := v1alpha1.RemoteEnvironment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "re", + }, + Spec: v1alpha1.RemoteEnvironmentSpec{ + Source: v1alpha1.Source{ + Environment: "production", + Type: "commerce", + Namespace: "local.kyma.commerce", + }, + Description: "EC description", + Services: []v1alpha1.Service{ + { + ID: "123", + DisplayName: "name", + Tags: []string{"tag1", "tag2"}, + LongDescription: "desc", + ProviderDisplayName: "name", + Entries: []v1alpha1.Entry{ + { + Type: "API", + GatewayUrl: "url", + AccessLabel: "label", + }, + { + Type: "Event", + }, + }, + }, + }, + }, + } + + converter := &remoteEnvironmentConverter{} + + // when + dto := converter.ToGQL(&fix) + + // then + assert.Equal(t, dto.Description, fix.Spec.Description) + assert.Equal(t, dto.Name, fix.Name) + + assert.Equal(t, dto.Source.Type, fix.Spec.Source.Type) + assert.Equal(t, dto.Source.Environment, fix.Spec.Source.Environment) + assert.Equal(t, dto.Source.Namespace, fix.Spec.Source.Namespace) + + require.Len(t, dto.Services, 1) + assert.Equal(t, dto.Services[0].ID, fix.Spec.Services[0].ID) + assert.Equal(t, dto.Services[0].Tags, fix.Spec.Services[0].Tags) + assert.Equal(t, dto.Services[0].DisplayName, fix.Spec.Services[0].DisplayName) + assert.Equal(t, dto.Services[0].LongDescription, fix.Spec.Services[0].LongDescription) + assert.Equal(t, dto.Services[0].ProviderDisplayName, fix.Spec.Services[0].ProviderDisplayName) + + require.Len(t, dto.Services[0].Entries, 1) + assert.Equal(t, dto.Services[0].Entries[0].Type, fix.Spec.Services[0].Entries[0].Type) + assert.Equal(t, dto.Services[0].Entries[0].AccessLabel, &fix.Spec.Services[0].Entries[0].AccessLabel) + assert.Equal(t, dto.Services[0].Entries[0].GatewayUrl, &fix.Spec.Services[0].Entries[0].GatewayUrl) + }) + + t.Run("Empty", func(t *testing.T) { + converter := &remoteEnvironmentConverter{} + result := converter.ToGQL(&v1alpha1.RemoteEnvironment{}) + + assert.Empty(t, result) + }) + + t.Run("Nil", func(t *testing.T) { + converter := &remoteEnvironmentConverter{} + result := converter.ToGQL(nil) + + assert.Empty(t, result) + }) +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/re_resolver.go b/components/ui-api-layer/internal/domain/remoteenvironment/re_resolver.go new file mode 100644 index 000000000000..5a331cf6bdaa --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/re_resolver.go @@ -0,0 +1,167 @@ +package remoteenvironment + +import ( + "context" + "fmt" + + "github.com/golang/glog" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/remoteenvironment/gateway" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + "github.com/pkg/errors" +) + +//go:generate mockery -name=reSvc -output=automock -outpkg=automock -case=underscore +type reSvc interface { + ListInEnvironment(environment string) ([]*v1alpha1.RemoteEnvironment, error) + ListNamespacesFor(reName string) ([]string, error) + Find(name string) (*v1alpha1.RemoteEnvironment, error) + List(params pager.PagingParams) ([]*v1alpha1.RemoteEnvironment, error) + Disable(namespace, name string) error + Enable(namespace, name string) (*v1alpha1.EnvironmentMapping, error) + GetConnectionUrl(remoteEnvironment string) (string, error) +} + +//go:generate mockery -name=statusGetter -output=automock -outpkg=automock -case=underscore +type statusGetter interface { + GetStatus(reName string) gateway.Status +} + +type remoteEnvironmentResolver struct { + reSvc reSvc + reConverter remoteEnvironmentConverter + statusGetter statusGetter +} + +func NewRemoteEnvironmentResolver(reSvc reSvc, statusGetter statusGetter) *remoteEnvironmentResolver { + return &remoteEnvironmentResolver{ + reSvc: reSvc, + statusGetter: statusGetter, + reConverter: remoteEnvironmentConverter{}, + } +} + +func (r *remoteEnvironmentResolver) RemoteEnvironmentQuery(ctx context.Context, name string) (*gqlschema.RemoteEnvironment, error) { + externalErr := fmt.Errorf("Couldn't query RemoteEnvironment with name %s", name) + remoteEnvironment, err := r.reSvc.Find(name) + + if err != nil { + glog.Error(errors.Wrapf(err, "while getting RemoteEnvironment")) + return nil, externalErr + } + if remoteEnvironment == nil { + return nil, nil + } + + re := r.reConverter.ToGQL(remoteEnvironment) + return &re, nil +} + +func (r *remoteEnvironmentResolver) RemoteEnvironmentsQuery(ctx context.Context, environment *string, first *int, offset *int) ([]gqlschema.RemoteEnvironment, error) { + var items []*v1alpha1.RemoteEnvironment + var err error + + logAndReturnCannotProcessErr := func(err error) ([]gqlschema.RemoteEnvironment, error) { + glog.Errorf(err.Error()) + // Returning only general message all details are logged and not exposed to the end user. + // This resolver returns remote environments list, so when there are no entries we are returning empty slice. + // Because of that we do not have to support here error with type NotFound, Conflict etc. + return []gqlschema.RemoteEnvironment{}, fmt.Errorf("cannot process 'RemoteEnvironment' item") + } + + if environment == nil { // retrieve all + items, err = r.reSvc.List(pager.PagingParams{First: first, Offset: offset}) + if err != nil { + return logAndReturnCannotProcessErr(errors.Wrap(err, "while listing all remote environments")) + } + } else { // retrieve only for given environment + // TODO: Add support for paging. + items, err = r.reSvc.ListInEnvironment(*environment) + if err != nil { + return logAndReturnCannotProcessErr(errors.Wrapf(err, "while listing remote environments for environment %v", environment)) + } + } + + res := make([]gqlschema.RemoteEnvironment, 0) + for _, item := range items { + res = append(res, r.reConverter.ToGQL(item)) + } + + return res, nil +} + +func (r *remoteEnvironmentResolver) ConnectorServiceQuery(ctx context.Context, remoteEnvironment string) (gqlschema.ConnectorService, error) { + url, err := r.reSvc.GetConnectionUrl(remoteEnvironment) + if err != nil { + return gqlschema.ConnectorService{}, errors.Wrapf(err, "while getting Connection Url") + } + + dto := gqlschema.ConnectorService{ + Url: url, + } + + return dto, nil +} + +func (r *remoteEnvironmentResolver) EnableRemoteEnvironmentMutation(ctx context.Context, remoteEnvironment string, environment string) (*gqlschema.EnvironmentMapping, error) { + em, err := r.reSvc.Enable(environment, remoteEnvironment) + + if err != nil { + return nil, errors.Wrapf(err, "while enabling RemoteEnvironment") + } + + environmentMapping := &gqlschema.EnvironmentMapping{ + Environment: em.Namespace, + RemoteEnvironment: em.Name, + } + + return environmentMapping, nil +} + +func (r *remoteEnvironmentResolver) DisableRemoteEnvironmentMutation(ctx context.Context, remoteEnvironment string, environment string) (*gqlschema.EnvironmentMapping, error) { + err := r.reSvc.Disable(environment, remoteEnvironment) + + if err != nil { + return nil, errors.Wrapf(err, "while disabling RemoteEnvironment") + } + + environmentMapping := &gqlschema.EnvironmentMapping{ + Environment: environment, + RemoteEnvironment: remoteEnvironment, + } + + return environmentMapping, nil +} + +func (r *remoteEnvironmentResolver) RemoteEnvironmentEnabledInEnvironmentsField(ctx context.Context, obj *gqlschema.RemoteEnvironment) ([]string, error) { + logAndReturnCannotProcessErr := func(err error) ([]string, error) { + // Returning only general message all details are logged and not exposed to the end user. + glog.Errorf(err.Error()) + return []string{}, fmt.Errorf("cannot process 'EnabledInEnvironments' field") + } + + if obj == nil { + return logAndReturnCannotProcessErr(fmt.Errorf("while resolving 'EnabledInEnvironments' field obj is empty")) + } + + items, err := r.reSvc.ListNamespacesFor(obj.Name) + if err != nil { + return logAndReturnCannotProcessErr(errors.Wrapf(err, "while listing namespaces for remote environment %q", obj.Name)) + } + return items, nil +} + +func (r *remoteEnvironmentResolver) RemoteEnvironmentStatusField(ctx context.Context, re *gqlschema.RemoteEnvironment) (gqlschema.RemoteEnvironmentStatus, error) { + status := r.statusGetter.GetStatus(re.Name) + switch status { + case gateway.StatusServing: + return gqlschema.RemoteEnvironmentStatusServing, nil + case gateway.StatusNotServing: + return gqlschema.RemoteEnvironmentStatusNotServing, nil + case gateway.StatusNotConfigured: + return gqlschema.RemoteEnvironmentStatusGatewayNotConfigured, nil + default: + return gqlschema.RemoteEnvironmentStatus(""), fmt.Errorf("uknown remote environment status %s", status) + } +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/re_resolver_test.go b/components/ui-api-layer/internal/domain/remoteenvironment/re_resolver_test.go new file mode 100644 index 000000000000..139ba4d67ca4 --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/re_resolver_test.go @@ -0,0 +1,111 @@ +package remoteenvironment_test + +import ( + "context" + "testing" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/remoteenvironment" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/remoteenvironment/automock" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/remoteenvironment/gateway" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRemoteEnvironmentStatusSuccess(t *testing.T) { + for tn, tc := range map[string]struct { + givenStatus gateway.Status + expectedStatus gqlschema.RemoteEnvironmentStatus + }{ + "serving": { + givenStatus: gateway.StatusServing, + expectedStatus: gqlschema.RemoteEnvironmentStatusServing, + }, + "not serving": { + givenStatus: gateway.StatusNotServing, + expectedStatus: gqlschema.RemoteEnvironmentStatusNotServing, + }, + "not configured": { + givenStatus: gateway.StatusNotConfigured, + expectedStatus: gqlschema.RemoteEnvironmentStatusGatewayNotConfigured, + }, + } { + t.Run(tn, func(t *testing.T) { + // given + statusGetterStub := automock.NewStatusGetter() + statusGetterStub.On("GetStatus", "ec-prod").Return(tc.givenStatus, nil) + resolver := remoteenvironment.NewRemoteEnvironmentResolver(nil, statusGetterStub) + + // when + status, err := resolver.RemoteEnvironmentStatusField(context.Background(), &gqlschema.RemoteEnvironment{ + Name: "ec-prod", + }) + + // then + require.NoError(t, err) + assert.Equal(t, tc.expectedStatus, status) + }) + } +} + +func TestRemoteEnvironmentStatusFail(t *testing.T) { + // given + statusGetterStub := automock.NewStatusGetter() + statusGetterStub.On("GetStatus", "ec-prod").Return(gateway.Status("fake"), nil) + resolver := remoteenvironment.NewRemoteEnvironmentResolver(nil, statusGetterStub) + + // when + _, err := resolver.RemoteEnvironmentStatusField(context.Background(), &gqlschema.RemoteEnvironment{ + Name: "ec-prod", + }) + + // then + require.Error(t, err) +} + +func TestConnectorServiceQuerySuccess(t *testing.T) { + // given + var ( + fixRemoteEnvName = "env-name" + fixURL = "http://some-url-with-token" + fixGQLObj = gqlschema.ConnectorService{ + Url: "http://some-url-with-token", + } + ) + + serviceMock := automock.NewReSvc() + defer serviceMock.AssertExpectations(t) + serviceMock.On("GetConnectionUrl", fixRemoteEnvName).Return(fixURL, nil) + + resolver := remoteenvironment.NewRemoteEnvironmentResolver(serviceMock, nil) + + // when + gotUrlObj, err := resolver.ConnectorServiceQuery(context.Background(), fixRemoteEnvName) + + // then + require.NoError(t, err) + assert.Equal(t, fixGQLObj, gotUrlObj) +} + +func TestConnectorServiceQueryFail(t *testing.T) { + // given + var ( + fixRemoteEnvName = "" + fixErr = errors.New("something went wrong") + ) + + serviceMock := automock.NewReSvc() + defer serviceMock.AssertExpectations(t) + serviceMock.On("GetConnectionUrl", fixRemoteEnvName).Return("", fixErr) + + resolver := remoteenvironment.NewRemoteEnvironmentResolver(serviceMock, nil) + + // when + gotUrlObj, err := resolver.ConnectorServiceQuery(context.Background(), fixRemoteEnvName) + + // then + require.Error(t, err) + assert.Zero(t, gotUrlObj) + assert.Equal(t, "while getting Connection Url: something went wrong", err.Error()) +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/re_service.go b/components/ui-api-layer/internal/domain/remoteenvironment/re_service.go new file mode 100644 index 000000000000..542b2beabd7b --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/re_service.go @@ -0,0 +1,228 @@ +package remoteenvironment + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + + "github.com/golang/glog" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + remoteenvironmentv1alpha1 "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/typed/remoteenvironment/v1alpha1" + reMappinglister "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/listers/remoteenvironment/v1alpha1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + "github.com/kyma-project/kyma/components/ui-api-layer/pkg/iosafety" + "github.com/pkg/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +const ( + remoteMappingNameIndex = "mapping-name" + // This regex comes from the k8s resource name validation and has been checked against traversal attack + // https://github.com/kubernetes/kubernetes/blob/v1.10.1/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go#L126 + reNameRegex = `^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$` +) + +// remoteEnvironmentService provides listing environments along with remote environments. +// It provides also remote environment enabling/disabling in given namespace. +type remoteEnvironmentService struct { + client remoteenvironmentv1alpha1.RemoteenvironmentV1alpha1Interface + mappingLister reMappinglister.EnvironmentMappingLister + mappingInformer cache.SharedIndexInformer + reInformer cache.SharedIndexInformer + connectorSvcURL string + httpClient *http.Client + reNameRegex *regexp.Regexp +} + +func newRemoteEnvironmentService(client remoteenvironmentv1alpha1.RemoteenvironmentV1alpha1Interface, cfg Config, mappingInformer cache.SharedIndexInformer, mappingLister reMappinglister.EnvironmentMappingLister, reInformer cache.SharedIndexInformer) (*remoteEnvironmentService, error) { + mappingInformer.AddIndexers(cache.Indexers{ + remoteMappingNameIndex: func(obj interface{}) ([]string, error) { + mapping, ok := obj.(*v1alpha1.EnvironmentMapping) + if !ok { + return nil, errors.New("cannot convert item") + } + + return []string{mapping.Name}, nil + }, + }) + + regex, err := regexp.Compile(reNameRegex) + if err != nil { + return nil, errors.Wrapf(err, "while compiling %s regex", reNameRegex) + } + return &remoteEnvironmentService{ + mappingLister: mappingLister, + mappingInformer: mappingInformer, + reInformer: reInformer, + client: client, + connectorSvcURL: cfg.Connector.URL, + httpClient: &http.Client{ + Timeout: cfg.Connector.HTTPCallTimeout, + }, + reNameRegex: regex, + }, nil +} + +func (svc *remoteEnvironmentService) ListNamespacesFor(reName string) ([]string, error) { + mappingObjs, err := svc.mappingInformer.GetIndexer().ByIndex(remoteMappingNameIndex, reName) + if err != nil { + return nil, errors.Wrapf(err, "while listing environment mappings by index %q with key %q", remoteMappingNameIndex, reName) + } + + nsList := make([]string, 0, len(mappingObjs)) + for _, item := range mappingObjs { + reMapping, ok := item.(*v1alpha1.EnvironmentMapping) + if !ok { + return nil, fmt.Errorf("incorrect item type: %T, should be: 'EnvironmentMapping' in version 'v1alpha1'", item) + } + nsList = append(nsList, reMapping.Namespace) + } + + return nsList, nil +} + +func (svc *remoteEnvironmentService) Find(name string) (*v1alpha1.RemoteEnvironment, error) { + remoteEnvironment, exists, err := svc.reInformer.GetStore().GetByKey(name) + + if err != nil || !exists { + return nil, err + } + + re, ok := remoteEnvironment.(*v1alpha1.RemoteEnvironment) + if !ok { + + return nil, fmt.Errorf("Cannot process RemoteEnvironment") + } + + return re, nil +} + +func (svc *remoteEnvironmentService) List(params pager.PagingParams) ([]*v1alpha1.RemoteEnvironment, error) { + reObjs, err := pager.From(svc.reInformer.GetStore()).Limit(params) + if err != nil { + return nil, errors.Wrapf(err, "while listing remote environments with paging params [first: %v] [offset: %v]: %v", params.First, params.Offset) + } + + res := make([]*v1alpha1.RemoteEnvironment, 0, len(reObjs)) + for _, item := range reObjs { + re, ok := item.(*v1alpha1.RemoteEnvironment) + if !ok { + return nil, fmt.Errorf("incorrect item type: %T, should be: 'RemoteEnvironment' in version 'v1alpha1'", item) + } + + res = append(res, re) + } + + return res, nil +} + +func (svc *remoteEnvironmentService) ListInEnvironment(environment string) ([]*v1alpha1.RemoteEnvironment, error) { + mappings, err := svc.mappingLister.EnvironmentMappings(environment).List(labels.Everything()) + if err != nil { + return nil, errors.Wrap(err, "while listing environment mappings") + } + + res := make([]*v1alpha1.RemoteEnvironment, 0) + for _, mapping := range mappings { + // Remote Environment CR is cluster wide so the key is only the name + reObj, exists, err := svc.reInformer.GetIndexer().GetByKey(mapping.Name) + if err != nil { + return nil, errors.Wrapf(err, "while getting remote environment with key %s", mapping.Name) + } + + if !exists { + glog.Warningf("Found environment mapping %q in namespaces %q but remote environment with name %q does not exists", mapping.Name, mapping.Namespace, mapping.Name) + continue + } + + reCR, ok := reObj.(*v1alpha1.RemoteEnvironment) + if !ok { + return nil, fmt.Errorf("incorrect item type: %T, should be: 'RemoteEnvironment' in version 'v1alpha1'", reObj) + } + + //TODO: Write test to make sure that this is a real deep copy + deepCopy := reCR.DeepCopy() + res = append(res, deepCopy) + } + return res, nil +} + +// Enable enables remote environment in given namespace by creating EnvironmentMappinggo +func (svc *remoteEnvironmentService) Enable(namespace, name string) (*v1alpha1.EnvironmentMapping, error) { + em, err := svc.client.EnvironmentMappings(namespace).Create(&v1alpha1.EnvironmentMapping{ + TypeMeta: metav1.TypeMeta{ + Kind: "EnvironmentMapping", + APIVersion: "remoteenvironment.kyma.cx/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + }) + return em, err +} + +// Disable disables remote environment in given namespace by removing EnvironmentMapping +func (svc *remoteEnvironmentService) Disable(namespace, name string) error { + return svc.client.EnvironmentMappings(namespace).Delete(name, &metav1.DeleteOptions{}) +} + +func (svc *remoteEnvironmentService) GetConnectionUrl(remoteEnvironment string) (string, error) { + if ok := svc.reNameRegex.MatchString(remoteEnvironment); !ok { + return "", fmt.Errorf("Remote evironment name %q does not match regex: %s", remoteEnvironment, reNameRegex) + } + reqURL := fmt.Sprintf("%s/v1/remoteenvironments/%s/tokens", svc.connectorSvcURL, remoteEnvironment) + + req, err := http.NewRequest(http.MethodPost, reqURL, nil) + if err != nil { + return "", errors.Wrap(err, "while creating HTTP request") + } + + resp, err := svc.httpClient.Do(req) + if err != nil { + return "", errors.Wrap(err, "while making HTTP call") + } + defer svc.drainAndCloseBody(resp.Body) + + if resp.StatusCode != http.StatusCreated { + cause := svc.extractErrorCause(resp.Body) + return "", errors.Wrapf(cause, "while requesting connection URL obtained unexpected status code %d", resp.StatusCode) + } + + connectorURL, err := svc.extractConnectionURL(resp.Body) + if err != nil { + return "", errors.Wrap(err, "while extracting connection URL from body") + } + + return connectorURL, nil +} + +func (svc *remoteEnvironmentService) extractConnectionURL(body io.ReadCloser) (string, error) { + var dto struct { + URL string `json:"url"` + } + if err := json.NewDecoder(body).Decode(&dto); err != nil { + return "", errors.Wrap(err, "while decoding json") + } + + return dto.URL, nil +} + +func (svc *remoteEnvironmentService) extractErrorCause(body io.ReadCloser) error { + var dto struct { + Error string `json:"error"` + } + if err := json.NewDecoder(body).Decode(&dto); err != nil { + return errors.Wrap(err, "while decoding json to get error msg from body") + } + + return errors.New(dto.Error) +} + +func (svc *remoteEnvironmentService) drainAndCloseBody(body io.ReadCloser) { + _ = iosafety.DrainReader(body) + body.Close() +} diff --git a/components/ui-api-layer/internal/domain/remoteenvironment/re_service_test.go b/components/ui-api-layer/internal/domain/remoteenvironment/re_service_test.go new file mode 100644 index 000000000000..d1fdee77aa67 --- /dev/null +++ b/components/ui-api-layer/internal/domain/remoteenvironment/re_service_test.go @@ -0,0 +1,281 @@ +package remoteenvironment_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/apis/remoteenvironment/v1alpha1" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/clientset/versioned/fake" + "github.com/kyma-project/kyma/components/remote-environment-broker/pkg/client/informers/externalversions" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/remoteenvironment" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + testingUtils "github.com/kyma-project/kyma/components/ui-api-layer/internal/testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/cache" +) + +func TestServiceListNamespacesForRemoteEnvironmentSuccess(t *testing.T) { + // given + const fixMappingName = "test-re" + fixMapping := fixEnvironmentMappingCR(fixMappingName, "production") + + client := fake.NewSimpleClientset(&fixMapping) + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + reSharedInformers := informerFactory.Remoteenvironment().V1alpha1() + emInformer := reSharedInformers.EnvironmentMappings().Informer() + + svc, err := remoteenvironment.NewRemoteEnvironmentService(nil, remoteenvironment.Config{}, emInformer, nil, nil) + require.NoError(t, err) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, emInformer) + + // when + nsList, err := svc.ListNamespacesFor(fixMappingName) + + // then + require.NoError(t, err) + + require.Len(t, nsList, 1) + assert.Equal(t, nsList[0], fixMapping.Namespace) +} + +func TestServiceFindRemoteEnvironmentSuccess(t *testing.T) { + // given + reName := "testExample" + + fixRemoteEnv := fixRemoteEnvironmentCR("testExample") + client := fake.NewSimpleClientset(fixRemoteEnv) + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + reSharedInformers := informerFactory.Remoteenvironment().V1alpha1() + reInformer := reSharedInformers.RemoteEnvironments().Informer() + + svc, err := remoteenvironment.NewRemoteEnvironmentService(nil, remoteenvironment.Config{}, reSharedInformers.EnvironmentMappings().Informer(), nil, reInformer) + require.NoError(t, err) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, reInformer) + + // when + re, err := svc.Find(reName) + + // then + require.NoError(t, err) + assert.Equal(t, fixRemoteEnv, re) +} + +func TestServiceFindRemoteEnvironmentFail(t *testing.T) { + // given + reName := "testExample" + + client := fake.NewSimpleClientset() + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + reSharedInformers := informerFactory.Remoteenvironment().V1alpha1() + reInformer := reSharedInformers.RemoteEnvironments().Informer() + + svc, err := remoteenvironment.NewRemoteEnvironmentService(nil, remoteenvironment.Config{}, reSharedInformers.EnvironmentMappings().Informer(), nil, reInformer) + require.NoError(t, err) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, reInformer) + + // when + re, err := svc.Find(reName) + + // then + require.NoError(t, err) + assert.Nil(t, re) +} + +func TestServiceListAllRemoteEnvironmentsSuccess(t *testing.T) { + // given + fixREA := fixRemoteEnvironmentsCR("re-name-a") + fixREB := fixRemoteEnvironmentsCR("re-name-b") + + client := fake.NewSimpleClientset(&fixREA, &fixREB) + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + reSharedInformers := informerFactory.Remoteenvironment().V1alpha1() + reInformer := reSharedInformers.RemoteEnvironments().Informer() + + svc, err := remoteenvironment.NewRemoteEnvironmentService(nil, remoteenvironment.Config{}, reSharedInformers.EnvironmentMappings().Informer(), nil, reInformer) + require.NoError(t, err) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, reInformer) + + // when + nsList, err := svc.List(pager.PagingParams{}) + + // then + require.NoError(t, err) + + require.Len(t, nsList, 2) + assert.Contains(t, nsList, &fixREB) + assert.Contains(t, nsList, &fixREA) +} + +func TestServiceListRemoteEnvironmentsInEnvironmentSuccess(t *testing.T) { + // given + const fixEnvironment = "prod" + + fixREA := fixRemoteEnvironmentsCR("re-name-a") + fixREB := fixRemoteEnvironmentsCR("re-name-b") + fixMappingREA := fixEnvironmentMappingCR("re-name-a", fixEnvironment) + fixMappingREB := fixEnvironmentMappingCR("re-name-b", fixEnvironment) + + client := fake.NewSimpleClientset(&fixREA, &fixREB, &fixMappingREA, &fixMappingREB) + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + reSharedInformers := informerFactory.Remoteenvironment().V1alpha1() + + reInformer := reSharedInformers.RemoteEnvironments().Informer() + emInformer := reSharedInformers.EnvironmentMappings().Informer() + emLister := reSharedInformers.EnvironmentMappings().Lister() + + svc, err := remoteenvironment.NewRemoteEnvironmentService(nil, remoteenvironment.Config{}, emInformer, emLister, reInformer) + require.NoError(t, err) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, reInformer) + testingUtils.WaitForInformerStartAtMost(t, time.Second, emInformer) + + // when + nsList, err := svc.ListInEnvironment(fixEnvironment) + + // then + require.NoError(t, err) + + require.Len(t, nsList, 2) + assert.Contains(t, nsList, &fixREA) + assert.Contains(t, nsList, &fixREB) +} + +func TestGetConnectionUrlSuccess(t *testing.T) { + // given + testServer := newTestServer(`{"url": "http://gotURL-with-token", "token": "token"}`, http.StatusCreated) + defer testServer.Close() + + config := remoteenvironment.Config{ + Connector: remoteenvironment.ConnectorSvcCfg{ + URL: testServer.URL, + }, + } + + svc, err := remoteenvironment.NewRemoteEnvironmentService(nil, config, newDummyInformer(), nil, nil) + require.NoError(t, err) + // when + gotURL, err := svc.GetConnectionUrl("fixRemoteEnvironmentName") + + // then + require.NoError(t, err) + assert.Equal(t, "http://gotURL-with-token", gotURL) +} + +func TestGetConnectionUrlFailure(t *testing.T) { + t.Run("Should return an error in case of improper remote environment name", func(t *testing.T) { + // given + cfg := remoteenvironment.Config{ + Connector: remoteenvironment.ConnectorSvcCfg{ + URL: "connectorUrl", + }, + } + + svc, err := remoteenvironment.NewRemoteEnvironmentService(nil, cfg, newDummyInformer(), nil, nil) + require.NoError(t, err) + + // when + gotURL, err := svc.GetConnectionUrl("invalid/RemoteEnvironmentName") + + // then + require.Error(t, err) + assert.Empty(t, gotURL) + assert.EqualError(t, err, `Remote evironment name "invalid/RemoteEnvironmentName" does not match regex: ^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$`) + }) + + t.Run("Should return an error in case of 403 status code", func(t *testing.T) { + // given + testServer := newTestServer(`{"code": 403, "error": "Invalid token."}`, http.StatusForbidden) + defer testServer.Close() + + config := remoteenvironment.Config{ + Connector: remoteenvironment.ConnectorSvcCfg{ + URL: testServer.URL, + }, + } + + svc, err := remoteenvironment.NewRemoteEnvironmentService(nil, config, newDummyInformer(), nil, nil) + require.NoError(t, err) + + // when + gotURL, err := svc.GetConnectionUrl("fixRemoteEnvironment") + + // then + require.Error(t, err) + assert.Empty(t, gotURL) + assert.EqualError(t, err, `while requesting connection URL obtained unexpected status code 403: Invalid token.`) + }) + + t.Run("Should return an error in case of invalid json format", func(t *testing.T) { + // given + testServer := newTestServer("something", http.StatusCreated) + defer testServer.Close() + + cfg := remoteenvironment.Config{ + Connector: remoteenvironment.ConnectorSvcCfg{ + URL: testServer.URL, + }, + } + + svc, err := remoteenvironment.NewRemoteEnvironmentService(nil, cfg, newDummyInformer(), nil, nil) + require.NoError(t, err) + + // when + gotURL, err := svc.GetConnectionUrl("fixRemoteEnvironment") + + // then + require.Error(t, err) + assert.Empty(t, gotURL) + assert.EqualError(t, err, `while extracting connection URL from body: while decoding json: invalid character 's' looking for beginning of value`) + }) +} + +func newDummyInformer() cache.SharedIndexInformer { + return cache.NewSharedIndexInformer(&cache.ListWatch{}, nil, 0, cache.Indexers{}) +} + +func newTestServer(data string, statusCode int) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(statusCode) + fmt.Fprintln(w, data) + })) +} + +func fixEnvironmentMappingCR(name, ns string) v1alpha1.EnvironmentMapping { + return v1alpha1.EnvironmentMapping{ + ObjectMeta: v1.ObjectMeta{ + Name: name, + Namespace: ns, + }, + } + +} + +func fixRemoteEnvironmentsCR(name string) v1alpha1.RemoteEnvironment { + return v1alpha1.RemoteEnvironment{ + ObjectMeta: v1.ObjectMeta{ + Name: name, + }, + } +} + +func fixRemoteEnvironmentCR(name string) *v1alpha1.RemoteEnvironment { + return &v1alpha1.RemoteEnvironment{ + ObjectMeta: v1.ObjectMeta{ + Name: name, + }, + } +} diff --git a/components/ui-api-layer/internal/domain/root_resolver.go b/components/ui-api-layer/internal/domain/root_resolver.go new file mode 100644 index 000000000000..025db86c9c33 --- /dev/null +++ b/components/ui-api-layer/internal/domain/root_resolver.go @@ -0,0 +1,293 @@ +package domain + +import ( + "context" + "time" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/apicontroller" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/k8s" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/kubeless" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/remoteenvironment" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/ui" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/pkg/errors" + "k8s.io/client-go/rest" +) + +type RootResolver struct { + k8s *k8s.Resolver + kubeless *kubeless.Resolver + sc *servicecatalog.Resolver + re *remoteenvironment.Resolver + content *content.Resolver + ac *apicontroller.Resolver + idpPreset *ui.Resolver +} + +func New(restConfig *rest.Config, contentCfg content.Config, reCfg remoteenvironment.Config, informerResyncPeriod time.Duration) (*RootResolver, error) { + contentContainer, err := content.New(contentCfg) + if err != nil { + return nil, errors.Wrap(err, "while initializing Content resolver") + } + + scContainer, err := servicecatalog.New(restConfig, informerResyncPeriod, contentContainer.AsyncApiSpecGetter, contentContainer.ApiSpecGetter, contentContainer.ContentGetter) + if err != nil { + return nil, errors.Wrap(err, "while initializing ServiceCatalog container") + } + + reContainer, err := remoteenvironment.New(restConfig, reCfg, informerResyncPeriod, contentContainer.AsyncApiSpecGetter) + if err != nil { + return nil, errors.Wrap(err, "while initializing RemoteEnvironment resolver") + } + + k8sResolver, err := k8s.New(restConfig, reContainer.RELister, informerResyncPeriod, scContainer.ServiceBindingUsageLister, scContainer.ServiceBindingGetter) + if err != nil { + return nil, errors.Wrap(err, "while initializing K8S resolver") + } + + kubelessResolver, err := kubeless.New(restConfig, informerResyncPeriod) + if err != nil { + return nil, errors.Wrap(err, "while initializing Kubeless resolver") + } + + acResolver, err := apicontroller.New(restConfig, informerResyncPeriod) + if err != nil { + return nil, errors.Wrap(err, "while initializing API controller resolver") + } + + idpPresetResolver, err := ui.New(restConfig, informerResyncPeriod) + if err != nil { + return nil, errors.Wrap(err, "while initializing idpPreset resolver") + } + + return &RootResolver{ + k8s: k8sResolver, + kubeless: kubelessResolver, + re: reContainer.Resolver, + sc: scContainer.Resolver, + content: contentContainer.Resolver, + ac: acResolver, + idpPreset: idpPresetResolver, + }, nil +} + +// WaitForCacheSync waits for caches to populate. This is blocking operation. +func (r *RootResolver) WaitForCacheSync(stopCh <-chan struct{}) { + r.re.WaitForCacheSync(stopCh) + r.sc.WaitForCacheSync(stopCh) + r.k8s.WaitForCacheSync(stopCh) + r.kubeless.WaitForCacheSync(stopCh) + r.ac.WaitForCacheSync(stopCh) + r.idpPreset.WaitForCacheSync(stopCh) + r.content.WaitForCacheSync(stopCh) +} + +// K8S + +func (r *RootResolver) Query_environments(ctx context.Context, remoteEnvironment *string) ([]gqlschema.Environment, error) { + return r.k8s.EnvironmentsQuery(ctx, remoteEnvironment) +} + +func (r *RootResolver) Query_deployments(ctx context.Context, environment string, excludeFunctions *bool) ([]gqlschema.Deployment, error) { + return r.k8s.DeploymentsQuery(ctx, environment, excludeFunctions) +} + +func (r *RootResolver) Deployment_boundServiceInstanceNames(ctx context.Context, deployment *gqlschema.Deployment) ([]string, error) { + return r.k8s.DeploymentBoundServiceInstanceNamesField(ctx, deployment) +} + +func (r *RootResolver) Query_limitRanges(ctx context.Context, env string) ([]gqlschema.LimitRange, error) { + return r.k8s.LimitRangesQuery(ctx, env) +} + +func (r *RootResolver) Query_resourceQuotas(ctx context.Context, environment string) ([]gqlschema.ResourceQuota, error) { + return r.k8s.ResourceQuotasQuery(ctx, environment) +} + +// Kubeless + +func (r *RootResolver) Query_functions(ctx context.Context, environment string, first *int, offset *int) ([]gqlschema.Function, error) { + return r.kubeless.FunctionsQuery(ctx, environment, first, offset) +} + +// Service Catalog + +func (r *RootResolver) Mutation_createServiceInstance(ctx context.Context, params gqlschema.ServiceInstanceCreateInput) (*gqlschema.ServiceInstance, error) { + return r.sc.CreateServiceInstanceMutation(ctx, params) +} + +func (r *RootResolver) Subscription_serviceInstanceEvent(ctx context.Context, environment string) (<-chan gqlschema.ServiceInstanceEvent, error) { + return r.sc.ServiceInstanceEventSubscription(ctx, environment) +} + +func (r *RootResolver) Query_serviceInstance(ctx context.Context, name string, environment string) (*gqlschema.ServiceInstance, error) { + return r.sc.ServiceInstanceQuery(ctx, name, environment) +} + +func (r *RootResolver) Query_serviceInstances(ctx context.Context, environment string, first *int, offset *int, status *gqlschema.InstanceStatusType) ([]gqlschema.ServiceInstance, error) { + return r.sc.ServiceInstancesQuery(ctx, environment, first, offset, status) +} + +func (r *RootResolver) Query_serviceClasses(ctx context.Context, first *int, offset *int) ([]gqlschema.ServiceClass, error) { + return r.sc.ServiceClassesQuery(ctx, first, offset) +} + +func (r *RootResolver) Query_serviceClass(ctx context.Context, name string) (*gqlschema.ServiceClass, error) { + return r.sc.ServiceClassQuery(ctx, name) +} + +func (r *RootResolver) Query_serviceBrokers(ctx context.Context, first *int, offset *int) ([]gqlschema.ServiceBroker, error) { + return r.sc.ServiceBrokersQuery(ctx, first, offset) +} + +func (r *RootResolver) Query_serviceBroker(ctx context.Context, name string) (*gqlschema.ServiceBroker, error) { + return r.sc.ServiceBrokerQuery(ctx, name) +} + +func (r *RootResolver) ServiceClass_activated(ctx context.Context, obj *gqlschema.ServiceClass) (bool, error) { + return r.sc.ServiceClassActivatedField(ctx, obj) +} + +func (r *RootResolver) ServiceClass_plans(ctx context.Context, obj *gqlschema.ServiceClass) ([]gqlschema.ServicePlan, error) { + return r.sc.ServiceClassPlansField(ctx, obj) +} + +func (r *RootResolver) ServiceClass_apiSpec(ctx context.Context, obj *gqlschema.ServiceClass) (*gqlschema.JSON, error) { + return r.sc.ServiceClassApiSpecField(ctx, obj) +} + +func (r *RootResolver) ServiceClass_asyncApiSpec(ctx context.Context, obj *gqlschema.ServiceClass) (*gqlschema.JSON, error) { + return r.sc.ServiceClassAsyncApiSpecField(ctx, obj) +} + +func (r *RootResolver) ServiceClass_content(ctx context.Context, obj *gqlschema.ServiceClass) (*gqlschema.JSON, error) { + return r.sc.ServiceClassContentField(ctx, obj) +} + +func (r *RootResolver) ServiceInstance_servicePlan(ctx context.Context, obj *gqlschema.ServiceInstance) (*gqlschema.ServicePlan, error) { + return r.sc.ServiceInstanceServicePlanField(ctx, obj) +} + +func (r *RootResolver) ServiceInstance_serviceClass(ctx context.Context, obj *gqlschema.ServiceInstance) (*gqlschema.ServiceClass, error) { + return r.sc.ServiceInstanceServiceClassField(ctx, obj) +} + +func (r *RootResolver) ServiceInstance_bindable(ctx context.Context, obj *gqlschema.ServiceInstance) (bool, error) { + return r.sc.ServiceInstanceBindableField(ctx, obj) +} + +func (r *RootResolver) ServiceInstance_serviceBindings(ctx context.Context, obj *gqlschema.ServiceInstance) ([]gqlschema.ServiceBinding, error) { + return r.sc.ServiceBindingsToInstanceQuery(ctx, obj.Name, obj.Environment) +} + +func (r *RootResolver) ServiceInstance_serviceBindingUsages(ctx context.Context, obj *gqlschema.ServiceInstance) ([]gqlschema.ServiceBindingUsage, error) { + return r.sc.ServiceBindingUsagesOfInstanceQuery(ctx, obj.Name, obj.Environment) +} + +func (r *RootResolver) Mutation_deleteServiceInstance(ctx context.Context, name string, environment string) (*gqlschema.ServiceInstance, error) { + return r.sc.DeleteServiceInstanceMutation(ctx, name, environment) +} + +func (r *RootResolver) Mutation_createServiceBinding(ctx context.Context, serviceBindingName, serviceInstanceName, env string) (*gqlschema.CreateServiceBindingOutput, error) { + return r.sc.CreateServiceBindingMutation(ctx, serviceBindingName, serviceInstanceName, env) +} + +func (r *RootResolver) Mutation_deleteServiceBinding(ctx context.Context, serviceBindingName string, env string) (*gqlschema.DeleteServiceBindingOutput, error) { + return r.sc.DeleteServiceBindingMutation(ctx, serviceBindingName, env) +} + +func (r *RootResolver) Query_serviceBinding(ctx context.Context, name string, environment string) (*gqlschema.ServiceBinding, error) { + return r.sc.ServiceBindingQuery(ctx, name, environment) +} + +func (r *RootResolver) Mutation_createServiceBindingUsage(ctx context.Context, createServiceBindingUsageInput *gqlschema.CreateServiceBindingUsageInput) (*gqlschema.ServiceBindingUsage, error) { + return r.sc.CreateServiceBindingUsageMutation(ctx, createServiceBindingUsageInput) +} + +func (r *RootResolver) Mutation_deleteServiceBindingUsage(ctx context.Context, serviceBindingUsageName string, env string) (*gqlschema.DeleteServiceBindingUsageOutput, error) { + return r.sc.DeleteServiceBindingUsageMutation(ctx, serviceBindingUsageName, env) +} + +func (r *RootResolver) Query_serviceBindingUsage(ctx context.Context, name, environment string) (*gqlschema.ServiceBindingUsage, error) { + return r.sc.ServiceBindingUsageQuery(ctx, name, environment) +} + +func (r *RootResolver) ServiceBindingUsage_serviceBinding(ctx context.Context, obj *gqlschema.ServiceBindingUsage) (*gqlschema.ServiceBinding, error) { + return r.sc.ServiceBindingQuery(ctx, obj.ServiceBindingName, obj.Environment) +} + +func (r *RootResolver) ServiceBinding_secret(ctx context.Context, serviceBinding *gqlschema.ServiceBinding) (*gqlschema.Secret, error) { + return r.k8s.SecretQuery(ctx, serviceBinding.SecretName, serviceBinding.Environment) +} + +func (r *RootResolver) Query_content(ctx context.Context, contentType, id string) (*gqlschema.JSON, error) { + return r.content.ContentQuery(ctx, contentType, id) +} + +func (r *RootResolver) Query_topics(ctx context.Context, input []gqlschema.InputTopic, internal *bool) ([]gqlschema.TopicEntry, error) { + return r.content.TopicsQuery(ctx, input, internal) +} + +// Remote Environments + +func (r *RootResolver) Mutation_enableRemoteEnvironment(ctx context.Context, remoteEnvironment string, environment string) (*gqlschema.EnvironmentMapping, error) { + return r.re.EnableRemoteEnvironmentMutation(ctx, remoteEnvironment, environment) +} + +func (r *RootResolver) Mutation_disableRemoteEnvironment(ctx context.Context, remoteEnvironment string, environment string) (*gqlschema.EnvironmentMapping, error) { + return r.re.DisableRemoteEnvironmentMutation(ctx, remoteEnvironment, environment) +} + +func (r *RootResolver) Query_remoteEnvironment(ctx context.Context, name string) (*gqlschema.RemoteEnvironment, error) { + return r.re.RemoteEnvironmentQuery(ctx, name) +} + +func (r *RootResolver) Query_remoteEnvironments(ctx context.Context, environment *string, first *int, offset *int) ([]gqlschema.RemoteEnvironment, error) { + return r.re.RemoteEnvironmentsQuery(ctx, environment, first, offset) +} + +func (r *RootResolver) Query_connectorService(ctx context.Context, remoteEnvironment string) (gqlschema.ConnectorService, error) { + return r.re.ConnectorServiceQuery(ctx, remoteEnvironment) +} + +func (r *RootResolver) RemoteEnvironment_enabledInEnvironments(ctx context.Context, obj *gqlschema.RemoteEnvironment) ([]string, error) { + return r.re.RemoteEnvironmentEnabledInEnvironmentsField(ctx, obj) +} + +func (r *RootResolver) RemoteEnvironment_status(ctx context.Context, obj *gqlschema.RemoteEnvironment) (gqlschema.RemoteEnvironmentStatus, error) { + return r.re.RemoteEnvironmentStatusField(ctx, obj) +} + +func (r *RootResolver) Query_eventActivations(ctx context.Context, environment string) ([]gqlschema.EventActivation, error) { + return r.re.EventActivationsQuery(ctx, environment) +} + +func (r *RootResolver) EventActivation_events(ctx context.Context, eventActivation *gqlschema.EventActivation) ([]gqlschema.EventActivationEvent, error) { + return r.re.EventActivationEventsField(ctx, eventActivation) +} + +// API controller + +func (r *RootResolver) Query_apis(ctx context.Context, environment string, serviceName *string, hostname *string) ([]gqlschema.API, error) { + return r.ac.APIsQuery(ctx, environment, serviceName, hostname) +} + +// UI + +func (r *RootResolver) Query_IDPPreset(ctx context.Context, name string) (*gqlschema.IDPPreset, error) { + return r.idpPreset.IDPPresetQuery(ctx, name) +} + +func (r *RootResolver) Query_IDPPresets(ctx context.Context, first *int, offset *int) ([]gqlschema.IDPPreset, error) { + return r.idpPreset.IDPPresetsQuery(ctx, first, offset) +} + +func (r *RootResolver) Mutation_createIDPPreset(ctx context.Context, name string, issuer string, jwksUri string) (*gqlschema.IDPPreset, error) { + return r.idpPreset.CreateIDPPresetMutation(ctx, name, issuer, jwksUri) +} + +func (r *RootResolver) Mutation_deleteIDPPreset(ctx context.Context, name string) (*gqlschema.IDPPreset, error) { + return r.idpPreset.DeleteIDPPresetMutation(ctx, name) +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/automock/api_spec_getter.go b/components/ui-api-layer/internal/domain/servicecatalog/automock/api_spec_getter.go new file mode 100644 index 000000000000..862cb1605e50 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/automock/api_spec_getter.go @@ -0,0 +1,34 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" + +import storage "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + +// ApiSpecGetter is an autogenerated mock type for the ApiSpecGetter type +type ApiSpecGetter struct { + mock.Mock +} + +// Find provides a mock function with given fields: kind, id +func (_m *ApiSpecGetter) Find(kind string, id string) (*storage.ApiSpec, error) { + ret := _m.Called(kind, id) + + var r0 *storage.ApiSpec + if rf, ok := ret.Get(0).(func(string, string) *storage.ApiSpec); ok { + r0 = rf(kind, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*storage.ApiSpec) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(kind, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/automock/async_api_spec_getter.go b/components/ui-api-layer/internal/domain/servicecatalog/automock/async_api_spec_getter.go new file mode 100644 index 000000000000..b0ca6bcafa5b --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/automock/async_api_spec_getter.go @@ -0,0 +1,34 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" + +import storage "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + +// AsyncApiSpecGetter is an autogenerated mock type for the AsyncApiSpecGetter type +type AsyncApiSpecGetter struct { + mock.Mock +} + +// Find provides a mock function with given fields: kind, id +func (_m *AsyncApiSpecGetter) Find(kind string, id string) (*storage.AsyncApiSpec, error) { + ret := _m.Called(kind, id) + + var r0 *storage.AsyncApiSpec + if rf, ok := ret.Get(0).(func(string, string) *storage.AsyncApiSpec); ok { + r0 = rf(kind, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*storage.AsyncApiSpec) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(kind, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/automock/broker_getter.go b/components/ui-api-layer/internal/domain/servicecatalog/automock/broker_getter.go new file mode 100644 index 000000000000..7e06e8568c91 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/automock/broker_getter.go @@ -0,0 +1,34 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" + +import v1beta1 "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + +// brokerGetter is an autogenerated mock type for the brokerGetter type +type brokerGetter struct { + mock.Mock +} + +// Find provides a mock function with given fields: name +func (_m *brokerGetter) Find(name string) (*v1beta1.ClusterServiceBroker, error) { + ret := _m.Called(name) + + var r0 *v1beta1.ClusterServiceBroker + if rf, ok := ret.Get(0).(func(string) *v1beta1.ClusterServiceBroker); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1beta1.ClusterServiceBroker) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/automock/broker_list_getter.go b/components/ui-api-layer/internal/domain/servicecatalog/automock/broker_list_getter.go new file mode 100644 index 000000000000..4efcea056dac --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/automock/broker_list_getter.go @@ -0,0 +1,58 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import pager "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + +import v1beta1 "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + +// brokerListGetter is an autogenerated mock type for the brokerListGetter type +type brokerListGetter struct { + mock.Mock +} + +// Find provides a mock function with given fields: name +func (_m *brokerListGetter) Find(name string) (*v1beta1.ClusterServiceBroker, error) { + ret := _m.Called(name) + + var r0 *v1beta1.ClusterServiceBroker + if rf, ok := ret.Get(0).(func(string) *v1beta1.ClusterServiceBroker); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1beta1.ClusterServiceBroker) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// List provides a mock function with given fields: pagingParams +func (_m *brokerListGetter) List(pagingParams pager.PagingParams) ([]*v1beta1.ClusterServiceBroker, error) { + ret := _m.Called(pagingParams) + + var r0 []*v1beta1.ClusterServiceBroker + if rf, ok := ret.Get(0).(func(pager.PagingParams) []*v1beta1.ClusterServiceBroker); ok { + r0 = rf(pagingParams) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1beta1.ClusterServiceBroker) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(pager.PagingParams) error); ok { + r1 = rf(pagingParams) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/automock/broker_lister.go b/components/ui-api-layer/internal/domain/servicecatalog/automock/broker_lister.go new file mode 100644 index 000000000000..b08cedd244c8 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/automock/broker_lister.go @@ -0,0 +1,35 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import pager "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + +import v1beta1 "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + +// brokerLister is an autogenerated mock type for the brokerLister type +type brokerLister struct { + mock.Mock +} + +// List provides a mock function with given fields: pagingParams +func (_m *brokerLister) List(pagingParams pager.PagingParams) ([]*v1beta1.ClusterServiceBroker, error) { + ret := _m.Called(pagingParams) + + var r0 []*v1beta1.ClusterServiceBroker + if rf, ok := ret.Get(0).(func(pager.PagingParams) []*v1beta1.ClusterServiceBroker); ok { + r0 = rf(pagingParams) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1beta1.ClusterServiceBroker) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(pager.PagingParams) error); ok { + r1 = rf(pagingParams) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/automock/class_getter.go b/components/ui-api-layer/internal/domain/servicecatalog/automock/class_getter.go new file mode 100644 index 000000000000..add6fee80553 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/automock/class_getter.go @@ -0,0 +1,57 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" + +import v1beta1 "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + +// classGetter is an autogenerated mock type for the classGetter type +type classGetter struct { + mock.Mock +} + +// Find provides a mock function with given fields: name +func (_m *classGetter) Find(name string) (*v1beta1.ClusterServiceClass, error) { + ret := _m.Called(name) + + var r0 *v1beta1.ClusterServiceClass + if rf, ok := ret.Get(0).(func(string) *v1beta1.ClusterServiceClass); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1beta1.ClusterServiceClass) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FindByExternalName provides a mock function with given fields: externalName +func (_m *classGetter) FindByExternalName(externalName string) (*v1beta1.ClusterServiceClass, error) { + ret := _m.Called(externalName) + + var r0 *v1beta1.ClusterServiceClass + if rf, ok := ret.Get(0).(func(string) *v1beta1.ClusterServiceClass); ok { + r0 = rf(externalName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1beta1.ClusterServiceClass) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(externalName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/automock/class_instance_lister.go b/components/ui-api-layer/internal/domain/servicecatalog/automock/class_instance_lister.go new file mode 100644 index 000000000000..cf15a2db2a6d --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/automock/class_instance_lister.go @@ -0,0 +1,34 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" + +import v1beta1 "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + +// classInstanceLister is an autogenerated mock type for the classInstanceLister type +type classInstanceLister struct { + mock.Mock +} + +// ListForClass provides a mock function with given fields: className, externalClassName +func (_m *classInstanceLister) ListForClass(className string, externalClassName string) ([]*v1beta1.ServiceInstance, error) { + ret := _m.Called(className, externalClassName) + + var r0 []*v1beta1.ServiceInstance + if rf, ok := ret.Get(0).(func(string, string) []*v1beta1.ServiceInstance); ok { + r0 = rf(className, externalClassName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1beta1.ServiceInstance) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(className, externalClassName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/automock/class_list_getter.go b/components/ui-api-layer/internal/domain/servicecatalog/automock/class_list_getter.go new file mode 100644 index 000000000000..e61d48317daa --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/automock/class_list_getter.go @@ -0,0 +1,81 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import pager "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + +import v1beta1 "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + +// classListGetter is an autogenerated mock type for the classListGetter type +type classListGetter struct { + mock.Mock +} + +// Find provides a mock function with given fields: name +func (_m *classListGetter) Find(name string) (*v1beta1.ClusterServiceClass, error) { + ret := _m.Called(name) + + var r0 *v1beta1.ClusterServiceClass + if rf, ok := ret.Get(0).(func(string) *v1beta1.ClusterServiceClass); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1beta1.ClusterServiceClass) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FindByExternalName provides a mock function with given fields: externalName +func (_m *classListGetter) FindByExternalName(externalName string) (*v1beta1.ClusterServiceClass, error) { + ret := _m.Called(externalName) + + var r0 *v1beta1.ClusterServiceClass + if rf, ok := ret.Get(0).(func(string) *v1beta1.ClusterServiceClass); ok { + r0 = rf(externalName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1beta1.ClusterServiceClass) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(externalName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// List provides a mock function with given fields: pagingParams +func (_m *classListGetter) List(pagingParams pager.PagingParams) ([]*v1beta1.ClusterServiceClass, error) { + ret := _m.Called(pagingParams) + + var r0 []*v1beta1.ClusterServiceClass + if rf, ok := ret.Get(0).(func(pager.PagingParams) []*v1beta1.ClusterServiceClass); ok { + r0 = rf(pagingParams) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1beta1.ClusterServiceClass) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(pager.PagingParams) error); ok { + r1 = rf(pagingParams) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/automock/content_getter.go b/components/ui-api-layer/internal/domain/servicecatalog/automock/content_getter.go new file mode 100644 index 000000000000..3932cd5d8a00 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/automock/content_getter.go @@ -0,0 +1,34 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" + +import storage "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + +// ContentGetter is an autogenerated mock type for the ContentGetter type +type ContentGetter struct { + mock.Mock +} + +// Find provides a mock function with given fields: kind, id +func (_m *ContentGetter) Find(kind string, id string) (*storage.Content, error) { + ret := _m.Called(kind, id) + + var r0 *storage.Content + if rf, ok := ret.Get(0).(func(string, string) *storage.Content); ok { + r0 = rf(kind, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*storage.Content) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(kind, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/automock/export.go b/components/ui-api-layer/internal/domain/servicecatalog/automock/export.go new file mode 100644 index 000000000000..ec1b9cd5d973 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/automock/export.go @@ -0,0 +1,77 @@ +package automock + +// Broker + +func NewBrokerGetter() *brokerGetter { + return new(brokerGetter) +} + +func NewBrokerListGetter() *brokerListGetter { + return new(brokerListGetter) +} + +func NewBrokerLister() *brokerLister { + return new(brokerLister) +} + +func NewGQLBrokerConverter() *gqlBrokerConverter { + return new(gqlBrokerConverter) +} + +// Class + +func NewClassGetter() *classGetter { + return new(classGetter) +} + +func NewClassInstanceLister() *classInstanceLister { + return new(classInstanceLister) +} + +func NewClassListGetter() *classListGetter { + return new(classListGetter) +} + +func NewGQLClassConverter() *gqlClassConverter { + return new(gqlClassConverter) +} + +// Plan + +func NewPlanGetter() *planGetter { + return new(planGetter) +} + +func NewPlanLister() *planLister { + return new(planLister) +} + +func NewGQLPlanConverter() *gqlPlanConverter { + return new(gqlPlanConverter) +} + +// Service Instance + +func NewInstanceGetter() *instanceGetter { + return new(instanceGetter) +} + +func NewInstanceLister() *instanceLister { + return new(instanceLister) +} + +// Service Binding + +func NewServiceBindingOperations() *serviceBindingOperations { + return new(serviceBindingOperations) +} + +// Service Binding Usage + +func NewServiceBindingUsageOperations() *serviceBindingUsageOperations { + return new(serviceBindingUsageOperations) +} + +func NewStatusBindingUsageExtractor() *statusBindingUsageExtractor { + return new(statusBindingUsageExtractor) +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/automock/gql_broker_converter.go b/components/ui-api-layer/internal/domain/servicecatalog/automock/gql_broker_converter.go new file mode 100644 index 000000000000..8540bbc8b38a --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/automock/gql_broker_converter.go @@ -0,0 +1,58 @@ +// Code generated by mockery v1.0.0 +package automock + +import gqlschema "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +import mock "github.com/stretchr/testify/mock" + +import v1beta1 "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + +// gqlBrokerConverter is an autogenerated mock type for the gqlBrokerConverter type +type gqlBrokerConverter struct { + mock.Mock +} + +// ToGQL provides a mock function with given fields: in +func (_m *gqlBrokerConverter) ToGQL(in *v1beta1.ClusterServiceBroker) (*gqlschema.ServiceBroker, error) { + ret := _m.Called(in) + + var r0 *gqlschema.ServiceBroker + if rf, ok := ret.Get(0).(func(*v1beta1.ClusterServiceBroker) *gqlschema.ServiceBroker); ok { + r0 = rf(in) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*gqlschema.ServiceBroker) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*v1beta1.ClusterServiceBroker) error); ok { + r1 = rf(in) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ToGQLs provides a mock function with given fields: in +func (_m *gqlBrokerConverter) ToGQLs(in []*v1beta1.ClusterServiceBroker) ([]gqlschema.ServiceBroker, error) { + ret := _m.Called(in) + + var r0 []gqlschema.ServiceBroker + if rf, ok := ret.Get(0).(func([]*v1beta1.ClusterServiceBroker) []gqlschema.ServiceBroker); ok { + r0 = rf(in) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gqlschema.ServiceBroker) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func([]*v1beta1.ClusterServiceBroker) error); ok { + r1 = rf(in) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/automock/gql_class_converter.go b/components/ui-api-layer/internal/domain/servicecatalog/automock/gql_class_converter.go new file mode 100644 index 000000000000..3a933aa91e2e --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/automock/gql_class_converter.go @@ -0,0 +1,58 @@ +// Code generated by mockery v1.0.0 +package automock + +import gqlschema "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +import mock "github.com/stretchr/testify/mock" + +import v1beta1 "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + +// gqlClassConverter is an autogenerated mock type for the gqlClassConverter type +type gqlClassConverter struct { + mock.Mock +} + +// ToGQL provides a mock function with given fields: in +func (_m *gqlClassConverter) ToGQL(in *v1beta1.ClusterServiceClass) (*gqlschema.ServiceClass, error) { + ret := _m.Called(in) + + var r0 *gqlschema.ServiceClass + if rf, ok := ret.Get(0).(func(*v1beta1.ClusterServiceClass) *gqlschema.ServiceClass); ok { + r0 = rf(in) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*gqlschema.ServiceClass) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*v1beta1.ClusterServiceClass) error); ok { + r1 = rf(in) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ToGQLs provides a mock function with given fields: in +func (_m *gqlClassConverter) ToGQLs(in []*v1beta1.ClusterServiceClass) ([]gqlschema.ServiceClass, error) { + ret := _m.Called(in) + + var r0 []gqlschema.ServiceClass + if rf, ok := ret.Get(0).(func([]*v1beta1.ClusterServiceClass) []gqlschema.ServiceClass); ok { + r0 = rf(in) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gqlschema.ServiceClass) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func([]*v1beta1.ClusterServiceClass) error); ok { + r1 = rf(in) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/automock/gql_plan_converter.go b/components/ui-api-layer/internal/domain/servicecatalog/automock/gql_plan_converter.go new file mode 100644 index 000000000000..7db1caf2d213 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/automock/gql_plan_converter.go @@ -0,0 +1,58 @@ +// Code generated by mockery v1.0.0 +package automock + +import gqlschema "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +import mock "github.com/stretchr/testify/mock" + +import v1beta1 "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + +// gqlPlanConverter is an autogenerated mock type for the gqlPlanConverter type +type gqlPlanConverter struct { + mock.Mock +} + +// ToGQL provides a mock function with given fields: item +func (_m *gqlPlanConverter) ToGQL(item *v1beta1.ClusterServicePlan) (*gqlschema.ServicePlan, error) { + ret := _m.Called(item) + + var r0 *gqlschema.ServicePlan + if rf, ok := ret.Get(0).(func(*v1beta1.ClusterServicePlan) *gqlschema.ServicePlan); ok { + r0 = rf(item) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*gqlschema.ServicePlan) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*v1beta1.ClusterServicePlan) error); ok { + r1 = rf(item) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ToGQLs provides a mock function with given fields: in +func (_m *gqlPlanConverter) ToGQLs(in []*v1beta1.ClusterServicePlan) ([]gqlschema.ServicePlan, error) { + ret := _m.Called(in) + + var r0 []gqlschema.ServicePlan + if rf, ok := ret.Get(0).(func([]*v1beta1.ClusterServicePlan) []gqlschema.ServicePlan); ok { + r0 = rf(in) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gqlschema.ServicePlan) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func([]*v1beta1.ClusterServicePlan) error); ok { + r1 = rf(in) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/automock/instance_getter.go b/components/ui-api-layer/internal/domain/servicecatalog/automock/instance_getter.go new file mode 100644 index 000000000000..c94f986b2498 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/automock/instance_getter.go @@ -0,0 +1,34 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" + +import v1beta1 "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + +// instanceGetter is an autogenerated mock type for the instanceGetter type +type instanceGetter struct { + mock.Mock +} + +// Find provides a mock function with given fields: name, environment +func (_m *instanceGetter) Find(name string, environment string) (*v1beta1.ServiceInstance, error) { + ret := _m.Called(name, environment) + + var r0 *v1beta1.ServiceInstance + if rf, ok := ret.Get(0).(func(string, string) *v1beta1.ServiceInstance); ok { + r0 = rf(name, environment) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1beta1.ServiceInstance) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(name, environment) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/automock/instance_lister.go b/components/ui-api-layer/internal/domain/servicecatalog/automock/instance_lister.go new file mode 100644 index 000000000000..c3d7b969dd7a --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/automock/instance_lister.go @@ -0,0 +1,59 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import pager "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + +import status "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog/status" +import v1beta1 "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + +// instanceLister is an autogenerated mock type for the instanceLister type +type instanceLister struct { + mock.Mock +} + +// List provides a mock function with given fields: environment, pagingParams +func (_m *instanceLister) List(environment string, pagingParams pager.PagingParams) ([]*v1beta1.ServiceInstance, error) { + ret := _m.Called(environment, pagingParams) + + var r0 []*v1beta1.ServiceInstance + if rf, ok := ret.Get(0).(func(string, pager.PagingParams) []*v1beta1.ServiceInstance); ok { + r0 = rf(environment, pagingParams) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1beta1.ServiceInstance) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, pager.PagingParams) error); ok { + r1 = rf(environment, pagingParams) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ListForStatus provides a mock function with given fields: environment, pagingParams, _a2 +func (_m *instanceLister) ListForStatus(environment string, pagingParams pager.PagingParams, _a2 *status.ServiceInstanceStatusType) ([]*v1beta1.ServiceInstance, error) { + ret := _m.Called(environment, pagingParams, _a2) + + var r0 []*v1beta1.ServiceInstance + if rf, ok := ret.Get(0).(func(string, pager.PagingParams, *status.ServiceInstanceStatusType) []*v1beta1.ServiceInstance); ok { + r0 = rf(environment, pagingParams, _a2) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1beta1.ServiceInstance) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, pager.PagingParams, *status.ServiceInstanceStatusType) error); ok { + r1 = rf(environment, pagingParams, _a2) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/automock/plan_getter.go b/components/ui-api-layer/internal/domain/servicecatalog/automock/plan_getter.go new file mode 100644 index 000000000000..37ee67cb240f --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/automock/plan_getter.go @@ -0,0 +1,57 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" + +import v1beta1 "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + +// planGetter is an autogenerated mock type for the planGetter type +type planGetter struct { + mock.Mock +} + +// Find provides a mock function with given fields: name +func (_m *planGetter) Find(name string) (*v1beta1.ClusterServicePlan, error) { + ret := _m.Called(name) + + var r0 *v1beta1.ClusterServicePlan + if rf, ok := ret.Get(0).(func(string) *v1beta1.ClusterServicePlan); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1beta1.ClusterServicePlan) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FindByExternalNameForClass provides a mock function with given fields: planExternalName, className +func (_m *planGetter) FindByExternalNameForClass(planExternalName string, className string) (*v1beta1.ClusterServicePlan, error) { + ret := _m.Called(planExternalName, className) + + var r0 *v1beta1.ClusterServicePlan + if rf, ok := ret.Get(0).(func(string, string) *v1beta1.ClusterServicePlan); ok { + r0 = rf(planExternalName, className) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1beta1.ClusterServicePlan) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(planExternalName, className) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/automock/plan_lister.go b/components/ui-api-layer/internal/domain/servicecatalog/automock/plan_lister.go new file mode 100644 index 000000000000..941a8dd73e9e --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/automock/plan_lister.go @@ -0,0 +1,34 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" + +import v1beta1 "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + +// planLister is an autogenerated mock type for the planLister type +type planLister struct { + mock.Mock +} + +// ListForClass provides a mock function with given fields: name +func (_m *planLister) ListForClass(name string) ([]*v1beta1.ClusterServicePlan, error) { + ret := _m.Called(name) + + var r0 []*v1beta1.ClusterServicePlan + if rf, ok := ret.Get(0).(func(string) []*v1beta1.ClusterServicePlan); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1beta1.ClusterServicePlan) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/automock/service_binding_operations.go b/components/ui-api-layer/internal/domain/servicecatalog/automock/service_binding_operations.go new file mode 100644 index 000000000000..566453144d1c --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/automock/service_binding_operations.go @@ -0,0 +1,94 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" + +import v1beta1 "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + +// serviceBindingOperations is an autogenerated mock type for the serviceBindingOperations type +type serviceBindingOperations struct { + mock.Mock +} + +// Create provides a mock function with given fields: env, sb +func (_m *serviceBindingOperations) Create(env string, sb *v1beta1.ServiceBinding) (*v1beta1.ServiceBinding, error) { + ret := _m.Called(env, sb) + + var r0 *v1beta1.ServiceBinding + if rf, ok := ret.Get(0).(func(string, *v1beta1.ServiceBinding) *v1beta1.ServiceBinding); ok { + r0 = rf(env, sb) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1beta1.ServiceBinding) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, *v1beta1.ServiceBinding) error); ok { + r1 = rf(env, sb) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: env, name +func (_m *serviceBindingOperations) Delete(env string, name string) error { + ret := _m.Called(env, name) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(env, name) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Find provides a mock function with given fields: env, name +func (_m *serviceBindingOperations) Find(env string, name string) (*v1beta1.ServiceBinding, error) { + ret := _m.Called(env, name) + + var r0 *v1beta1.ServiceBinding + if rf, ok := ret.Get(0).(func(string, string) *v1beta1.ServiceBinding); ok { + r0 = rf(env, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1beta1.ServiceBinding) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(env, name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ListForServiceInstance provides a mock function with given fields: env, instanceName +func (_m *serviceBindingOperations) ListForServiceInstance(env string, instanceName string) ([]*v1beta1.ServiceBinding, error) { + ret := _m.Called(env, instanceName) + + var r0 []*v1beta1.ServiceBinding + if rf, ok := ret.Get(0).(func(string, string) []*v1beta1.ServiceBinding); ok { + r0 = rf(env, instanceName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1beta1.ServiceBinding) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(env, instanceName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/automock/service_binding_usage_operations.go b/components/ui-api-layer/internal/domain/servicecatalog/automock/service_binding_usage_operations.go new file mode 100644 index 000000000000..4a852b3eaf6c --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/automock/service_binding_usage_operations.go @@ -0,0 +1,94 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" + +import v1alpha1 "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + +// serviceBindingUsageOperations is an autogenerated mock type for the serviceBindingUsageOperations type +type serviceBindingUsageOperations struct { + mock.Mock +} + +// Create provides a mock function with given fields: env, sb +func (_m *serviceBindingUsageOperations) Create(env string, sb *v1alpha1.ServiceBindingUsage) (*v1alpha1.ServiceBindingUsage, error) { + ret := _m.Called(env, sb) + + var r0 *v1alpha1.ServiceBindingUsage + if rf, ok := ret.Get(0).(func(string, *v1alpha1.ServiceBindingUsage) *v1alpha1.ServiceBindingUsage); ok { + r0 = rf(env, sb) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1alpha1.ServiceBindingUsage) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, *v1alpha1.ServiceBindingUsage) error); ok { + r1 = rf(env, sb) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: env, name +func (_m *serviceBindingUsageOperations) Delete(env string, name string) error { + ret := _m.Called(env, name) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(env, name) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Find provides a mock function with given fields: env, name +func (_m *serviceBindingUsageOperations) Find(env string, name string) (*v1alpha1.ServiceBindingUsage, error) { + ret := _m.Called(env, name) + + var r0 *v1alpha1.ServiceBindingUsage + if rf, ok := ret.Get(0).(func(string, string) *v1alpha1.ServiceBindingUsage); ok { + r0 = rf(env, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1alpha1.ServiceBindingUsage) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(env, name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ListForServiceInstance provides a mock function with given fields: env, instanceName +func (_m *serviceBindingUsageOperations) ListForServiceInstance(env string, instanceName string) ([]*v1alpha1.ServiceBindingUsage, error) { + ret := _m.Called(env, instanceName) + + var r0 []*v1alpha1.ServiceBindingUsage + if rf, ok := ret.Get(0).(func(string, string) []*v1alpha1.ServiceBindingUsage); ok { + r0 = rf(env, instanceName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1alpha1.ServiceBindingUsage) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(env, instanceName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/automock/status_binding_usage_extractor.go b/components/ui-api-layer/internal/domain/servicecatalog/automock/status_binding_usage_extractor.go new file mode 100644 index 000000000000..02a5b5455fd8 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/automock/status_binding_usage_extractor.go @@ -0,0 +1,26 @@ +// Code generated by mockery v1.0.0 +package automock + +import gqlschema "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +import mock "github.com/stretchr/testify/mock" + +import v1alpha1 "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + +// statusBindingUsageExtractor is an autogenerated mock type for the statusBindingUsageExtractor type +type statusBindingUsageExtractor struct { + mock.Mock +} + +// Status provides a mock function with given fields: conditions +func (_m *statusBindingUsageExtractor) Status(conditions []v1alpha1.ServiceBindingUsageCondition) gqlschema.ServiceBindingUsageStatus { + ret := _m.Called(conditions) + + var r0 gqlschema.ServiceBindingUsageStatus + if rf, ok := ret.Get(0).(func([]v1alpha1.ServiceBindingUsageCondition) gqlschema.ServiceBindingUsageStatus); ok { + r0 = rf(conditions) + } else { + r0 = ret.Get(0).(gqlschema.ServiceBindingUsageStatus) + } + + return r0 +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/binding_converter.go b/components/ui-api-layer/internal/domain/servicecatalog/binding_converter.go new file mode 100644 index 000000000000..6bb725e25a85 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/binding_converter.go @@ -0,0 +1,49 @@ +package servicecatalog + +import ( + api "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog/status" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +) + +type serviceBindingConverter struct { + extractor status.BindingExtractor +} + +func (c *serviceBindingConverter) ToCreateOutputGQL(in *api.ServiceBinding) *gqlschema.CreateServiceBindingOutput { + if in == nil { + return nil + } + + return &gqlschema.CreateServiceBindingOutput{ + Name: in.Name, + Environment: in.Namespace, + ServiceInstanceName: in.Spec.ServiceInstanceRef.Name, + } +} + +func (c *serviceBindingConverter) ToGQL(in *api.ServiceBinding) *gqlschema.ServiceBinding { + if in == nil { + return nil + } + + return &gqlschema.ServiceBinding{ + Name: in.Name, + ServiceInstanceName: in.Spec.ServiceInstanceRef.Name, + Environment: in.Namespace, + SecretName: in.Spec.SecretName, + Status: c.extractor.Status(in.Status.Conditions), + } +} + +func (c *serviceBindingConverter) ToGQLs(in []*api.ServiceBinding) []gqlschema.ServiceBinding { + var result []gqlschema.ServiceBinding + for _, item := range in { + converted := c.ToGQL(item) + if converted != nil { + result = append(result, *converted) + } + } + + return result +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/binding_converter_test.go b/components/ui-api-layer/internal/domain/servicecatalog/binding_converter_test.go new file mode 100644 index 000000000000..2106f5cba2a7 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/binding_converter_test.go @@ -0,0 +1,155 @@ +package servicecatalog + +import ( + "testing" + + api "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestServiceBindingConverter_ToGQL(t *testing.T) { + t.Run("Empty", func(t *testing.T) { + converter := serviceBindingConverter{} + result := converter.ToGQL(&api.ServiceBinding{}) + + assert.Equal(t, fixEmptyServiceBindingToGQL(), result) + }) + + t.Run("Nil", func(t *testing.T) { + converter := serviceBindingConverter{} + result := converter.ToGQL(nil) + + assert.Nil(t, result) + }) +} + +func TestServiceBindingConverter_ToGQLs(t *testing.T) { + t.Run("Success", func(t *testing.T) { + bindings := []*api.ServiceBinding{ + fixBinding(), + fixBinding(), + } + + expected := []gqlschema.ServiceBinding{ + { + Name: "service-binding", + Environment: "production", + ServiceInstanceName: "instance", + SecretName: "secret-name", + Status: gqlschema.ServiceBindingStatus{ + Type: gqlschema.ServiceBindingStatusTypePending, + }, + }, + { + Name: "service-binding", + Environment: "production", + ServiceInstanceName: "instance", + SecretName: "secret-name", + Status: gqlschema.ServiceBindingStatus{ + Type: gqlschema.ServiceBindingStatusTypePending, + }, + }, + } + + converter := serviceBindingConverter{} + result := converter.ToGQLs(bindings) + + assert.Equal(t, expected, result) + }) + + t.Run("Empty", func(t *testing.T) { + var bindings []*api.ServiceBinding + + converter := serviceBindingConverter{} + result := converter.ToGQLs(bindings) + + assert.Empty(t, result) + }) + + t.Run("With nil", func(t *testing.T) { + bindings := []*api.ServiceBinding{ + nil, + fixBinding(), + nil, + } + + expected := []gqlschema.ServiceBinding{ + { + Name: "service-binding", + Environment: "production", + ServiceInstanceName: "instance", + SecretName: "secret-name", + Status: gqlschema.ServiceBindingStatus{ + Type: gqlschema.ServiceBindingStatusTypePending, + }, + }, + } + + converter := serviceBindingConverter{} + result := converter.ToGQLs(bindings) + + assert.Equal(t, expected, result) + }) +} + +func TestServiceBindingConverter_ToCreateOutputGQL(t *testing.T) { + t.Run("Empty", func(t *testing.T) { + converter := serviceBindingConverter{} + result := converter.ToCreateOutputGQL(&api.ServiceBinding{}) + + assert.Empty(t, result) + }) + + t.Run("Nil", func(t *testing.T) { + converter := serviceBindingConverter{} + result := converter.ToCreateOutputGQL(nil) + + assert.Nil(t, result) + }) +} + +func TestServiceBindingConversionToGQL(t *testing.T) { + // GIVEN + sut := serviceBindingConverter{} + // WHEN + actual := sut.ToGQL(fixBinding()) + // THEN + assert.Equal(t, "service-binding", actual.Name) + assert.Equal(t, "production", actual.Environment) + assert.Equal(t, "secret-name", actual.SecretName) + assert.Equal(t, "instance", actual.ServiceInstanceName) +} + +func TestServicebindingConversionToCreateOutputGQL(t *testing.T) { + // GIVEN + sut := serviceBindingConverter{} + // WHEN + actual := sut.ToCreateOutputGQL(fixBinding()) + // THEN + assert.Equal(t, "service-binding", actual.Name) + assert.Equal(t, "production", actual.Environment) + assert.Equal(t, "instance", actual.ServiceInstanceName) +} + +func fixBinding() *api.ServiceBinding { + return &api.ServiceBinding{ + ObjectMeta: v1.ObjectMeta{ + Name: "service-binding", + Namespace: "production", + }, + Spec: api.ServiceBindingSpec{ + ServiceInstanceRef: api.LocalObjectReference{Name: "instance"}, + SecretName: "secret-name", + }, + } +} + +func fixEmptyServiceBindingToGQL() *gqlschema.ServiceBinding { + return &gqlschema.ServiceBinding{ + Status: gqlschema.ServiceBindingStatus{ + Type: gqlschema.ServiceBindingStatusTypePending, + }, + } +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/binding_resolver.go b/components/ui-api-layer/internal/domain/servicecatalog/binding_resolver.go new file mode 100644 index 000000000000..aa6716c05aae --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/binding_resolver.go @@ -0,0 +1,83 @@ +package servicecatalog + +import ( + "context" + "fmt" + + "github.com/golang/glog" + api "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/pkg/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + apierrors "k8s.io/apimachinery/pkg/api/errors" +) + +type serviceBindingResolver struct { + operations serviceBindingOperations + converter serviceBindingConverter +} + +func newServiceBindingResolver(op serviceBindingOperations) *serviceBindingResolver { + return &serviceBindingResolver{ + operations: op, + converter: serviceBindingConverter{}, + } +} + +func (r *serviceBindingResolver) CreateServiceBindingMutation(ctx context.Context, serviceBindingName, serviceInstanceName, env string) (*gqlschema.CreateServiceBindingOutput, error) { + sb, err := r.operations.Create(env, &api.ServiceBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceBindingName, + }, + Spec: api.ServiceBindingSpec{ + ServiceInstanceRef: api.LocalObjectReference{ + Name: serviceInstanceName, + }, + }, + }) + switch { + case apierrors.IsAlreadyExists(err): + return nil, fmt.Errorf("ServiceBinding %s already exists", serviceBindingName) + case err != nil: + glog.Error(errors.Wrapf(err, "while creating ServiceBinding %s", serviceBindingName)) + return nil, errors.New("cannot create ServiceBinding") + } + + return r.converter.ToCreateOutputGQL(sb), nil +} + +func (r *serviceBindingResolver) DeleteServiceBindingMutation(ctx context.Context, serviceBindingName, env string) (*gqlschema.DeleteServiceBindingOutput, error) { + err := r.operations.Delete(env, serviceBindingName) + switch { + case apierrors.IsNotFound(err): + return nil, fmt.Errorf("ServiceBinding %s not found", serviceBindingName) + case err != nil: + glog.Error(errors.Wrapf(err, "while deleting ServiceBinding %s", serviceBindingName)) + return nil, errors.New("cannot delete ServiceBinding") + } + + return &gqlschema.DeleteServiceBindingOutput{ + Environment: env, + Name: serviceBindingName, + }, nil +} + +func (r *serviceBindingResolver) ServiceBindingQuery(ctx context.Context, name, env string) (*gqlschema.ServiceBinding, error) { + binding, err := r.operations.Find(env, name) + if err != nil { + glog.Error(errors.Wrapf(err, "while getting ServiceBinding [name: %s, environment: %s]", name, env)) + return nil, errors.New("cannot get ServiceBinding") + } + + return r.converter.ToGQL(binding), nil +} + +func (r *serviceBindingResolver) ServiceBindingsToInstanceQuery(ctx context.Context, instanceName, environment string) ([]gqlschema.ServiceBinding, error) { + list, err := r.operations.ListForServiceInstance(environment, instanceName) + if err != nil { + glog.Error(errors.Wrapf(err, "while getting many ServiceBindings to Instance [instance name: %s. environment: %s]", instanceName, environment)) + return []gqlschema.ServiceBinding{}, errors.New("cannot get ServiceBindings") + } + return r.converter.ToGQLs(list), nil +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/binding_resolver_test.go b/components/ui-api-layer/internal/domain/servicecatalog/binding_resolver_test.go new file mode 100644 index 000000000000..4d43c5250781 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/binding_resolver_test.go @@ -0,0 +1,199 @@ +package servicecatalog_test + +import ( + "testing" + + api "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog/automock" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestServiceBindingResolver_CreateServiceBindingMutation(t *testing.T) { + t.Run("Success", func(t *testing.T) { + svc := automock.NewServiceBindingOperations() + binding := fixServiceBindingToRedis() + binding.Namespace = "" + svc.On("Create", "production", binding). + Return(fixServiceBindingToRedis(), nil).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingResolver(svc) + + result, err := resolver.CreateServiceBindingMutation(nil, "redis-binding", "redis", "production") + + require.NoError(t, err) + assert.Equal(t, fixCreateServiceBindingOutput(), result) + }) + + t.Run("Already exists", func(t *testing.T) { + svc := automock.NewServiceBindingOperations() + svc.On("Create", mock.Anything, mock.Anything).Return(nil, apiErrors.NewAlreadyExists(schema.GroupResource{}, "test")).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingResolver(svc) + + _, err := resolver.CreateServiceBindingMutation(nil, "redis-binding", "redis", "production") + + require.Error(t, err) + assert.Equal(t, "ServiceBinding redis-binding already exists", err.Error()) + }) + + t.Run("Error", func(t *testing.T) { + svc := automock.NewServiceBindingOperations() + svc.On("Create", mock.Anything, mock.Anything).Return(nil, errors.New("nope")).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingResolver(svc) + + _, err := resolver.CreateServiceBindingMutation(nil, "redis-binding", "redis", "production") + + require.Error(t, err) + }) +} + +func TestServiceBindingResolver_DeleteServiceBindingMutation(t *testing.T) { + t.Run("Success", func(t *testing.T) { + svc := automock.NewServiceBindingOperations() + svc.On("Delete", "production", "redis-binding").Return(nil).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingResolver(svc) + + result, err := resolver.DeleteServiceBindingMutation(nil, "redis-binding", "production") + + require.NoError(t, err) + assert.Equal(t, &gqlschema.DeleteServiceBindingOutput{ + Name: "redis-binding", + Environment: "production", + }, result) + }) + + t.Run("Already exists", func(t *testing.T) { + svc := automock.NewServiceBindingOperations() + svc.On("Delete", "production", "redis-binding").Return(apiErrors.NewNotFound(schema.GroupResource{}, "test")).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingResolver(svc) + + _, err := resolver.DeleteServiceBindingMutation(nil, "redis-binding", "production") + + require.Error(t, err) + assert.Equal(t, "ServiceBinding redis-binding not found", err.Error()) + }) + + t.Run("Error", func(t *testing.T) { + svc := automock.NewServiceBindingOperations() + svc.On("Delete", "production", "redis-binding").Return(errors.New("ta")).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingResolver(svc) + + _, err := resolver.DeleteServiceBindingMutation(nil, "redis-binding", "production") + + require.Error(t, err) + }) +} + +func TestServiceBindingResolver_ServiceBindingQuery(t *testing.T) { + t.Run("Success", func(t *testing.T) { + svc := automock.NewServiceBindingOperations() + svc.On("Find", "production", "redis-binding"). + Return(fixServiceBindingToRedis(), nil).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingResolver(svc) + + result, err := resolver.ServiceBindingQuery(nil, "redis-binding", "production") + + require.NoError(t, err) + assert.Equal(t, fixServiceBindingGQLToRedis(), result) + }) + + t.Run("Not found", func(t *testing.T) { + svc := automock.NewServiceBindingOperations() + svc.On("Find", "production", "redis-binding"). + Return(nil, nil).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingResolver(svc) + + result, err := resolver.ServiceBindingQuery(nil, "redis-binding", "production") + + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("Error", func(t *testing.T) { + svc := automock.NewServiceBindingOperations() + svc.On("Find", "production", "redis-binding"). + Return(nil, errors.New("nope")).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingResolver(svc) + + _, err := resolver.ServiceBindingQuery(nil, "redis-binding", "production") + + require.Error(t, err) + }) +} + +func TestServiceBindingResolver_ServiceBindingsToInstanceQuery(t *testing.T) { + t.Run("Success", func(t *testing.T) { + svc := automock.NewServiceBindingOperations() + svc.On("ListForServiceInstance", "production", "redis"). + Return([]*api.ServiceBinding{ + fixServiceBindingToRedis(), + fixServiceBindingToRedis(), + }, nil).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingResolver(svc) + + result, err := resolver.ServiceBindingsToInstanceQuery(nil, "redis", "production") + + require.NoError(t, err) + assert.Equal(t, []gqlschema.ServiceBinding{ + *fixServiceBindingGQLToRedis(), + *fixServiceBindingGQLToRedis(), + }, result) + }) + + t.Run("Not found", func(t *testing.T) { + svc := automock.NewServiceBindingOperations() + svc.On("ListForServiceInstance", "production", "redis").Return([]*api.ServiceBinding{}, nil).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingResolver(svc) + + result, err := resolver.ServiceBindingsToInstanceQuery(nil, "redis", "production") + + require.NoError(t, err) + assert.Empty(t, result) + }) + + t.Run("Error", func(t *testing.T) { + svc := automock.NewServiceBindingOperations() + svc.On("ListForServiceInstance", "production", "redis").Return(nil, errors.New("yhm")).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingResolver(svc) + + _, err := resolver.ServiceBindingsToInstanceQuery(nil, "redis", "production") + + require.Error(t, err) + }) +} + +func fixServiceBindingGQLToRedis() *gqlschema.ServiceBinding { + return &gqlschema.ServiceBinding{ + Name: "redis-binding", + ServiceInstanceName: "redis", + Environment: "production", + Status: gqlschema.ServiceBindingStatus{ + Type: gqlschema.ServiceBindingStatusTypePending, + }, + } +} + +func fixCreateServiceBindingOutput() *gqlschema.CreateServiceBindingOutput { + return &gqlschema.CreateServiceBindingOutput{ + Environment: "production", + ServiceInstanceName: "redis", + Name: "redis-binding", + } +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/binding_service.go b/components/ui-api-layer/internal/domain/servicecatalog/binding_service.go new file mode 100644 index 000000000000..67d9e6bade62 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/binding_service.go @@ -0,0 +1,88 @@ +package servicecatalog + +import ( + "fmt" + + api "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kubernetes-incubator/service-catalog/pkg/client/clientset_generated/clientset/typed/servicecatalog/v1beta1" + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/cache" +) + +type serviceBindingService struct { + client v1beta1.ServicecatalogV1beta1Interface + informer cache.SharedIndexInformer +} + +func newServiceBindingService(client v1beta1.ServicecatalogV1beta1Interface, informer cache.SharedIndexInformer) *serviceBindingService { + svc := &serviceBindingService{ + client: client, + informer: informer, + } + + informer.AddIndexers(cache.Indexers{ + "relatedServiceInstanceName": func(obj interface{}) ([]string, error) { + serviceBinding, err := svc.toServiceBinding(obj) + if err != nil { + return nil, errors.Wrapf(err, "while indexing by `relatedServiceInstanceName`") + } + + key := fmt.Sprintf("%s/%s", serviceBinding.Namespace, serviceBinding.Spec.ServiceInstanceRef.Name) + return []string{key}, nil + }, + }) + + return svc +} + +func (f *serviceBindingService) Create(env string, sb *api.ServiceBinding) (*api.ServiceBinding, error) { + return f.client.ServiceBindings(env).Create(sb) +} + +func (f *serviceBindingService) Delete(env string, name string) error { + return f.client.ServiceBindings(env).Delete(name, &v1.DeleteOptions{}) +} + +func (f *serviceBindingService) Find(env string, name string) (*api.ServiceBinding, error) { + key := fmt.Sprintf("%s/%s", env, name) + item, exists, err := f.informer.GetStore().GetByKey(key) + if err != nil || !exists { + return nil, err + } + + return f.toServiceBinding(item) +} + +func (f *serviceBindingService) ListForServiceInstance(env string, instanceName string) ([]*api.ServiceBinding, error) { + key := fmt.Sprintf("%s/%s", env, instanceName) + items, err := f.informer.GetIndexer().ByIndex("relatedServiceInstanceName", key) + if err != nil { + return nil, err + } + + return f.toServiceBindings(items) +} + +func (f *serviceBindingService) toServiceBinding(item interface{}) (*api.ServiceBinding, error) { + serviceBinding, ok := item.(*api.ServiceBinding) + if !ok { + return nil, fmt.Errorf("incorrect item type: %T, should be: *ServiceBinding", item) + } + + return serviceBinding, nil +} + +func (f *serviceBindingService) toServiceBindings(items []interface{}) ([]*api.ServiceBinding, error) { + var serviceBindings []*api.ServiceBinding + for _, item := range items { + serviceBinding, err := f.toServiceBinding(item) + if err != nil { + return nil, err + } + + serviceBindings = append(serviceBindings, serviceBinding) + } + + return serviceBindings, nil +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/binding_service_test.go b/components/ui-api-layer/internal/domain/servicecatalog/binding_service_test.go new file mode 100644 index 000000000000..fb0e4d0534a5 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/binding_service_test.go @@ -0,0 +1,106 @@ +package servicecatalog_test + +import ( + "testing" + "time" + + api "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kubernetes-incubator/service-catalog/pkg/client/clientset_generated/clientset/fake" + "github.com/kubernetes-incubator/service-catalog/pkg/client/informers_generated/externalversions" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/cache" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog" + testingUtils "github.com/kyma-project/kyma/components/ui-api-layer/internal/testing" +) + +func TestBindingServiceCreate(t *testing.T) { + // GIVEN + fakeClient := fake.NewSimpleClientset() + sut := servicecatalog.NewServiceBindingService(fakeClient.ServicecatalogV1beta1(), fixBindingInformer(fakeClient)) + // WHEN + actualBinding, err := sut.Create("production", fixServiceBindingToRedis()) + // THEN + require.NoError(t, err) + bindingFromClientSet, err := fakeClient.ServicecatalogV1beta1().ServiceBindings("production").Get("redis-binding", v1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, bindingFromClientSet, actualBinding) +} + +func TestBindingServiceDelete(t *testing.T) { + // GIVEN + fakeClient := fake.NewSimpleClientset(fixServiceBindingToRedis()) + sut := servicecatalog.NewServiceBindingService(fakeClient.ServicecatalogV1beta1(), fixBindingInformer(fakeClient)) + // WHEN + err := sut.Delete("production", "redis-binding") + // THEN + require.NoError(t, err) + _, err = fakeClient.ServicecatalogV1beta1().ServiceBindings("production").Get("redis-binding", v1.GetOptions{}) + assert.True(t, apierrors.IsNotFound(err)) + +} + +func TestBindingServiceFind(t *testing.T) { + // GIVEN + client := fake.NewSimpleClientset(fixServiceBindingToRedis()) + informer := fixBindingInformer(client) + sut := servicecatalog.NewServiceBindingService(client.ServicecatalogV1beta1(), informer) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + // WHEN + actual, err := sut.Find("production", "redis-binding") + // THEN + require.NoError(t, err) + assert.Equal(t, fixServiceBindingToRedis(), actual) +} + +func TestBindingServiceListForServiceInstance(t *testing.T) { + // GIVEN + client := fake.NewSimpleClientset(fixServiceBindingToRedis(), fixServiceBindingToSql()) + informer := fixBindingInformer(client) + sut := servicecatalog.NewServiceBindingService(client.ServicecatalogV1beta1(), informer) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + // WHEN + actualBindings, err := sut.ListForServiceInstance("production", "redis") + // THEN + require.NoError(t, err) + assert.Len(t, actualBindings, 1) + assert.Contains(t, actualBindings, fixServiceBindingToRedis()) +} + +func fixServiceBindingToRedis() *api.ServiceBinding { + return &api.ServiceBinding{ + ObjectMeta: v1.ObjectMeta{ + Name: "redis-binding", + Namespace: "production", + }, + Spec: api.ServiceBindingSpec{ + ServiceInstanceRef: api.LocalObjectReference{ + Name: "redis", + }, + }, + } +} + +func fixServiceBindingToSql() *api.ServiceBinding { + return &api.ServiceBinding{ + ObjectMeta: v1.ObjectMeta{ + Name: "sql-binding", + Namespace: "production", + }, + Spec: api.ServiceBindingSpec{ + ServiceInstanceRef: api.LocalObjectReference{ + Name: "sql", + }, + }, + } +} + +func fixBindingInformer(client *fake.Clientset) cache.SharedIndexInformer { + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + informer := informerFactory.Servicecatalog().V1beta1().ServiceBindings().Informer() + + return informer +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/binding_usage_converter.go b/components/ui-api-layer/internal/domain/servicecatalog/binding_usage_converter.go new file mode 100644 index 000000000000..707aacf4f237 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/binding_usage_converter.go @@ -0,0 +1,137 @@ +package servicecatalog + +import ( + "fmt" + + api "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + sbuTypes "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog/status" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +) + +//go:generate mockery -name=statusBindingUsageExtractor -output=automock -outpkg=automock -case=underscore +type statusBindingUsageExtractor interface { + Status(conditions []sbuTypes.ServiceBindingUsageCondition) gqlschema.ServiceBindingUsageStatus +} + +type bindingUsageConverter struct { + extractor statusBindingUsageExtractor +} + +func newBindingUsageConverter() bindingUsageConverter { + return bindingUsageConverter{ + extractor: &status.BindingUsageExtractor{}, + } +} + +func (c *bindingUsageConverter) ToGQL(in *api.ServiceBindingUsage) (*gqlschema.ServiceBindingUsage, error) { + if in == nil { + return nil, nil + } + + kind, err := c.refTypeToQL(in.Spec.UsedBy.Kind) + if err != nil { + return nil, err + } + + gqlSBU := gqlschema.ServiceBindingUsage{ + Name: in.Name, + Environment: in.Namespace, + UsedBy: gqlschema.LocalObjectReference{ + Name: in.Spec.UsedBy.Name, + Kind: kind, + }, + ServiceBindingName: in.Spec.ServiceBindingRef.Name, + Status: c.extractor.Status(in.Status.Conditions), + } + + if in.Spec.Parameters != nil && in.Spec.Parameters.EnvPrefix != nil { + gqlSBU.Parameters = &gqlschema.ServiceBindingUsageParameters{ + EnvPrefix: &gqlschema.EnvPrefix{ + Name: in.Spec.Parameters.EnvPrefix.Name, + }, + } + } + + return &gqlSBU, nil +} + +func (c *bindingUsageConverter) ToGQLs(in []*api.ServiceBindingUsage) ([]gqlschema.ServiceBindingUsage, error) { + var out []gqlschema.ServiceBindingUsage + for _, u := range in { + converted, err := c.ToGQL(u) + if err != nil { + return nil, err + } + + if converted != nil { + out = append(out, *converted) + } + } + return out, nil +} + +func (c *bindingUsageConverter) InputToK8s(in *gqlschema.CreateServiceBindingUsageInput) (*api.ServiceBindingUsage, error) { + if in == nil { + return nil, nil + } + + kind, err := c.referenceTypeToStr(in.UsedBy.Kind) + if err != nil { + return nil, err + } + + k8sSBU := api.ServiceBindingUsage{ + TypeMeta: metav1.TypeMeta{ + Kind: "ServiceBindingUsage", + APIVersion: api.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: in.Name, + }, + Spec: api.ServiceBindingUsageSpec{ + ServiceBindingRef: api.LocalReferenceByName{ + Name: in.ServiceBindingRef.Name, + }, + UsedBy: api.LocalReferenceByKindAndName{ + Kind: kind, + Name: in.UsedBy.Name, + }, + }, + } + + if in.Parameters != nil && in.Parameters.EnvPrefix != nil { + k8sSBU.Spec.Parameters = &api.Parameters{ + EnvPrefix: &api.EnvPrefix{ + Name: in.Parameters.EnvPrefix.Name, + }, + } + } + + return &k8sSBU, nil +} + +func (*bindingUsageConverter) referenceTypeToStr(referenceType gqlschema.BindingUsageReferenceType) (string, error) { + switch referenceType { + case gqlschema.BindingUsageReferenceTypeDeployment: + return "Deployment", nil + case gqlschema.BindingUsageReferenceTypeFunction: + return "Function", nil + default: + return "", fmt.Errorf("unknown reference kind %s", referenceType) + } +} + +// refTypeToQL converts string to reference type, if the kind is unknown, returns exactly the same string. +func (*bindingUsageConverter) refTypeToQL(kind string) (gqlschema.BindingUsageReferenceType, error) { + switch kind { + case "Deployment": + return gqlschema.BindingUsageReferenceTypeDeployment, nil + case "Function": + return gqlschema.BindingUsageReferenceTypeFunction, nil + default: + return "", fmt.Errorf("unknown kind %s", kind) + } +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/binding_usage_converter_test.go b/components/ui-api-layer/internal/domain/servicecatalog/binding_usage_converter_test.go new file mode 100644 index 000000000000..f71c778cd686 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/binding_usage_converter_test.go @@ -0,0 +1,284 @@ +package servicecatalog + +import ( + "testing" + + api "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog/automock" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestBindingUsageConversionToGQLCornerCases(t *testing.T) { + t.Run("nil objs", func(t *testing.T) { + // GIVEN + var ( + givenK8sSBU *api.ServiceBindingUsage = nil + expGQLSBU *gqlschema.ServiceBindingUsage = nil + ) + sut := bindingUsageConverter{} + + // WHEN + gotGQLSBU, err := sut.ToGQL(givenK8sSBU) + + // THEN + require.NoError(t, err) + assert.Equal(t, expGQLSBU, gotGQLSBU) + }) + + t.Run("only kind provided", func(t *testing.T) { + // GIVEN + givenK8sSBU := &api.ServiceBindingUsage{ + Spec: api.ServiceBindingUsageSpec{ + UsedBy: api.LocalReferenceByKindAndName{ + Kind: "Function", + }, + }, + } + expGQLSBU := &gqlschema.ServiceBindingUsage{ + UsedBy: gqlschema.LocalObjectReference{ + Kind: gqlschema.BindingUsageReferenceTypeFunction, + }, + } + + statusExtractorMock := automock.NewStatusBindingUsageExtractor() + defer statusExtractorMock.AssertExpectations(t) + statusExtractorMock. + On("Status", mock.Anything). + Return(gqlschema.ServiceBindingUsageStatus{}) + + sut := bindingUsageConverter{statusExtractorMock} + + // WHEN + gotGQLSBU, err := sut.ToGQL(givenK8sSBU) + + // THEN + require.NoError(t, err) + assert.Equal(t, expGQLSBU, gotGQLSBU) + }) +} + +func TestBindingUsageConversionToGQL(t *testing.T) { + tests := map[string]struct { + givenK8sSBU *api.ServiceBindingUsage + expGQLSBU *gqlschema.ServiceBindingUsage + }{ + "without env prefix": { + givenK8sSBU: fixRedisUsage(), + expGQLSBU: &gqlschema.ServiceBindingUsage{ + Name: "usage", + UsedBy: gqlschema.LocalObjectReference{ + Name: "app", + Kind: gqlschema.BindingUsageReferenceTypeDeployment, + }, + ServiceBindingName: "redis-binding", + Environment: "production", + }, + }, + "with env prefix": { + givenK8sSBU: func() *api.ServiceBindingUsage { + fix := fixRedisUsage() + fix.Spec.Parameters = &api.Parameters{ + EnvPrefix: &api.EnvPrefix{Name: "ENV_PREFIX"}, + } + return fix + }(), + expGQLSBU: &gqlschema.ServiceBindingUsage{ + Name: "usage", + UsedBy: gqlschema.LocalObjectReference{ + Name: "app", + Kind: gqlschema.BindingUsageReferenceTypeDeployment, + }, + ServiceBindingName: "redis-binding", + Environment: "production", + Parameters: &gqlschema.ServiceBindingUsageParameters{ + EnvPrefix: &gqlschema.EnvPrefix{Name: "ENV_PREFIX"}, + }, + }, + }, + } + for tn, tc := range tests { + t.Run(tn, func(t *testing.T) { + // GIVEN + statusExtractorMock := automock.NewStatusBindingUsageExtractor() + defer statusExtractorMock.AssertExpectations(t) + statusExtractorMock. + On("Status", tc.givenK8sSBU.Status.Conditions). + Return(gqlschema.ServiceBindingUsageStatus{}) + + sut := bindingUsageConverter{statusExtractorMock} + + // WHEN + gotGQLSBU, err := sut.ToGQL(tc.givenK8sSBU) + + // THEN + require.NoError(t, err) + assert.Equal(t, tc.expGQLSBU, gotGQLSBU) + }) + } +} + +func TestBindingUsageConversionToGQLs(t *testing.T) { + tests := map[string]struct { + givenK8sSBUs []*api.ServiceBindingUsage + }{ + "with one entry": { + givenK8sSBUs: []*api.ServiceBindingUsage{ + fixRedisUsage(), + }, + }, + "with nil": { + givenK8sSBUs: []*api.ServiceBindingUsage{ + nil, + fixRedisUsage(), + nil, + }, + }, + } + for tn, tc := range tests { + t.Run(tn, func(t *testing.T) { + // GIVEN + sut := newBindingUsageConverter() + // WHEN + actual, err := sut.ToGQLs(tc.givenK8sSBUs) + // THEN + require.NoError(t, err) + assert.Len(t, actual, 1) + assert.Equal(t, "usage", actual[0].Name) + }) + } +} + +func TestBindingUsageConversionInput(t *testing.T) { + tests := map[string]struct { + givenSBUInput *gqlschema.CreateServiceBindingUsageInput + expK8sSBU *api.ServiceBindingUsage + }{ + "only kind is provided": { + givenSBUInput: &gqlschema.CreateServiceBindingUsageInput{ + UsedBy: gqlschema.LocalObjectReferenceInput{ + Kind: gqlschema.BindingUsageReferenceTypeFunction, + }, + }, + expK8sSBU: &api.ServiceBindingUsage{ + TypeMeta: v1.TypeMeta{ + Kind: "ServiceBindingUsage", + APIVersion: "servicecatalog.kyma.cx/v1alpha1", + }, + Spec: api.ServiceBindingUsageSpec{ + UsedBy: api.LocalReferenceByKindAndName{ + Kind: "Function", + }, + }, + }, + }, + "nil": { + givenSBUInput: nil, + expK8sSBU: nil, + }, + "with env prefix": { + givenSBUInput: &gqlschema.CreateServiceBindingUsageInput{ + Name: "usage", + Environment: "production", + ServiceBindingRef: gqlschema.ServiceBindingRefInput{ + Name: "redis-binding", + }, + UsedBy: gqlschema.LocalObjectReferenceInput{ + Name: "app", + Kind: gqlschema.BindingUsageReferenceTypeDeployment, + }, + Parameters: &gqlschema.ServiceBindingUsageParametersInput{ + EnvPrefix: &gqlschema.EnvPrefixInput{Name: "ENV_PREFIX"}, + }, + }, + expK8sSBU: &api.ServiceBindingUsage{ + ObjectMeta: v1.ObjectMeta{ + Name: "usage", + }, + TypeMeta: v1.TypeMeta{ + Kind: "ServiceBindingUsage", + APIVersion: "servicecatalog.kyma.cx/v1alpha1", + }, + Spec: api.ServiceBindingUsageSpec{ + ServiceBindingRef: api.LocalReferenceByName{ + Name: "redis-binding", + }, + UsedBy: api.LocalReferenceByKindAndName{ + Name: "app", + Kind: "Deployment", + }, + Parameters: &api.Parameters{ + EnvPrefix: &api.EnvPrefix{Name: "ENV_PREFIX"}, + }, + }, + }, + }, + "without env prefix": { + givenSBUInput: &gqlschema.CreateServiceBindingUsageInput{ + Name: "usage", + Environment: "production", + ServiceBindingRef: gqlschema.ServiceBindingRefInput{ + Name: "redis-binding", + }, + UsedBy: gqlschema.LocalObjectReferenceInput{ + Name: "app", + Kind: gqlschema.BindingUsageReferenceTypeDeployment, + }, + }, + expK8sSBU: &api.ServiceBindingUsage{ + ObjectMeta: v1.ObjectMeta{ + Name: "usage", + }, + TypeMeta: v1.TypeMeta{ + Kind: "ServiceBindingUsage", + APIVersion: "servicecatalog.kyma.cx/v1alpha1", + }, + Spec: api.ServiceBindingUsageSpec{ + ServiceBindingRef: api.LocalReferenceByName{ + Name: "redis-binding", + }, + UsedBy: api.LocalReferenceByKindAndName{ + Name: "app", + Kind: "Deployment", + }, + }, + }, + }, + } + for tn, tc := range tests { + t.Run(tn, func(t *testing.T) { + // GIVEN + sut := bindingUsageConverter{} + // WHEN + gotK8sSBU, err := sut.InputToK8s(tc.givenSBUInput) + // THEN + require.NoError(t, err) + assert.Equal(t, tc.expK8sSBU, gotK8sSBU) + }) + } +} + +func fixRedisUsage() *api.ServiceBindingUsage { + return &api.ServiceBindingUsage{ + TypeMeta: v1.TypeMeta{ + Kind: "ServiceBindingUsage", + APIVersion: api.SchemeGroupVersion.String(), + }, + ObjectMeta: v1.ObjectMeta{ + Name: "usage", + Namespace: "production", + }, + Spec: api.ServiceBindingUsageSpec{ + ServiceBindingRef: api.LocalReferenceByName{ + Name: "redis-binding", + }, + UsedBy: api.LocalReferenceByKindAndName{ + Name: "app", + Kind: "Deployment", + }, + }, + } +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/binding_usage_resolver.go b/components/ui-api-layer/internal/domain/servicecatalog/binding_usage_resolver.go new file mode 100644 index 000000000000..0aa394f2540d --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/binding_usage_resolver.go @@ -0,0 +1,107 @@ +package servicecatalog + +import ( + "context" + "fmt" + + "github.com/golang/glog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/pkg/errors" + apierrors "k8s.io/apimachinery/pkg/api/errors" +) + +type serviceBindingUsageResolver struct { + operations serviceBindingUsageOperations + converter bindingUsageConverter +} + +func newServiceBindingUsageResolver(op serviceBindingUsageOperations) *serviceBindingUsageResolver { + return &serviceBindingUsageResolver{ + operations: op, + converter: newBindingUsageConverter(), + } +} + +func (r *serviceBindingUsageResolver) CreateServiceBindingUsageMutation(ctx context.Context, input *gqlschema.CreateServiceBindingUsageInput) (*gqlschema.ServiceBindingUsage, error) { + inBindingUsage, err := r.converter.InputToK8s(input) + if err != nil { + glog.Error(errors.Wrapf(err, "while creating ServiceBindingUsage from input [%+v]", input)) + return nil, r.genericErrorOnCreate() + } + bu, err := r.operations.Create(input.Environment, inBindingUsage) + switch { + case apierrors.IsAlreadyExists(err): + return nil, fmt.Errorf("ServiceBindingUsage %s already exists", input.Name) + case err != nil: + glog.Error(errors.Wrapf(err, "while creating ServiceBindingUsage from input [%v]", input)) + return nil, r.genericErrorOnCreate() + } + + out, err := r.converter.ToGQL(bu) + if err != nil { + return nil, err + } + + return out, nil +} + +func (r *serviceBindingUsageResolver) DeleteServiceBindingUsageMutation(ctx context.Context, serviceBindingUsageName, namespace string) (*gqlschema.DeleteServiceBindingUsageOutput, error) { + err := r.operations.Delete(namespace, serviceBindingUsageName) + switch { + case apierrors.IsNotFound(err): + return nil, fmt.Errorf("ServiceBindingUsage %s not found", serviceBindingUsageName) + case err != nil: + glog.Error(errors.Wrapf(err, "while deleting ServiceBindingUsage %s", serviceBindingUsageName)) + return nil, errors.New("cannot delete ServiceBindingUsage") + } + + return &gqlschema.DeleteServiceBindingUsageOutput{ + Environment: namespace, + Name: serviceBindingUsageName, + }, nil +} + +func (r *serviceBindingUsageResolver) ServiceBindingUsageQuery(ctx context.Context, name, environment string) (*gqlschema.ServiceBindingUsage, error) { + usage, err := r.operations.Find(environment, name) + if err != nil { + glog.Error(errors.Wrapf(err, "while getting single ServiceBindingUsage [name: %s, environment: %s]", name, environment)) + return nil, r.genericErrorOnSingleGet() + } + + out, err := r.converter.ToGQL(usage) + if err != nil { + glog.Error( + errors.Wrapf(err, + "while getting single ServiceBindingUsage [name: %s, environment: %s]: while converting ServiceBindingUsage to QL representation", + name, environment)) + return nil, r.genericErrorOnSingleGet() + } + return out, nil +} + +func (r *serviceBindingUsageResolver) ServiceBindingUsagesOfInstanceQuery(ctx context.Context, instanceName, env string) ([]gqlschema.ServiceBindingUsage, error) { + usages, err := + r.operations.ListForServiceInstance(env, instanceName) + if err != nil { + glog.Error(errors.Wrapf(err, "while getting ServiceBindingUsages of instance [environment: %s, instance: %s]", env, instanceName)) + return nil, r.genericErrorOnMultipleGet() + } + out, err := r.converter.ToGQLs(usages) + if err != nil { + glog.Error(errors.Wrapf(err, "while converting ServiceBindingUsages of instance [environment: %s, instance: %s]", env, instanceName)) + return nil, r.genericErrorOnMultipleGet() + } + return out, nil +} + +func (*serviceBindingUsageResolver) genericErrorOnCreate() error { + return errors.New("cannot create ServiceBindingUsage") +} + +func (*serviceBindingUsageResolver) genericErrorOnSingleGet() error { + return errors.New("cannot get ServiceBindingUsage") +} + +func (*serviceBindingUsageResolver) genericErrorOnMultipleGet() error { + return errors.New("Cannot get ServiceBindingUsages") +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/binding_usage_resolver_test.go b/components/ui-api-layer/internal/domain/servicecatalog/binding_usage_resolver_test.go new file mode 100644 index 000000000000..bab8c2343325 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/binding_usage_resolver_test.go @@ -0,0 +1,246 @@ +package servicecatalog_test + +import ( + "errors" + "fmt" + "testing" + + api "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog/automock" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stesting "k8s.io/client-go/testing" +) + +func TestServiceBindingUsageResolver_CreateServiceBindingUsageMutation(t *testing.T) { + t.Run("Success", func(t *testing.T) { + svc := automock.NewServiceBindingUsageOperations() + bindingUsage := fixServiceBindingUsageResource() + bindingUsage.Namespace = "" + svc.On("Create", "test-ns", bindingUsage).Return(fixServiceBindingUsageResource(), nil).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingUsageResolver(svc) + + result, err := resolver.CreateServiceBindingUsageMutation(nil, fixCreateServiceBindingUsageInput()) + + require.NoError(t, err) + assert.Equal(t, fixServiceBindingUsage(), result) + }) + + t.Run("Wrong kind", func(t *testing.T) { + resolver := servicecatalog.NewServiceBindingUsageResolver(nil) + + input := fixCreateServiceBindingUsageInput() + input.UsedBy.Kind = "nope" + _, err := resolver.CreateServiceBindingUsageMutation(nil, input) + + require.Error(t, err) + }) + + t.Run("Already exists", func(t *testing.T) { + svc := automock.NewServiceBindingUsageOperations() + svc.On("Create", mock.Anything, mock.Anything).Return(nil, apiErrors.NewAlreadyExists(schema.GroupResource{}, "test")).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingUsageResolver(svc) + binding := fixCreateServiceBindingUsageInput() + + _, err := resolver.CreateServiceBindingUsageMutation(nil, binding) + + require.Error(t, err) + assert.Equal(t, fmt.Sprintf("ServiceBindingUsage %s already exists", binding.Name), err.Error()) + }) + + t.Run("Error", func(t *testing.T) { + svc := automock.NewServiceBindingUsageOperations() + svc.On("Create", mock.Anything, mock.Anything).Return(nil, errors.New("trololo")).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingUsageResolver(svc) + + _, err := resolver.CreateServiceBindingUsageMutation(nil, fixCreateServiceBindingUsageInput()) + + require.Error(t, err) + }) +} + +func TestServiceBindingUsageResolver_DeleteServiceBindingUsageMutation(t *testing.T) { + t.Run("Success", func(t *testing.T) { + svc := automock.NewServiceBindingUsageOperations() + svc.On("Delete", "test", "test").Return(nil).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingUsageResolver(svc) + + result, err := resolver.DeleteServiceBindingUsageMutation(nil, "test", "test") + + require.NoError(t, err) + assert.Equal(t, &gqlschema.DeleteServiceBindingUsageOutput{ + Name: "test", + Environment: "test", + }, result) + }) + + t.Run("Not exists", func(t *testing.T) { + svc := automock.NewServiceBindingUsageOperations() + svc.On("Delete", "test", "test").Return(apiErrors.NewNotFound(schema.GroupResource{}, "test")).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingUsageResolver(svc) + + _, err := resolver.DeleteServiceBindingUsageMutation(nil, "test", "test") + + require.Error(t, err) + assert.Equal(t, "ServiceBindingUsage test not found", err.Error()) + }) + + t.Run("Error", func(t *testing.T) { + svc := automock.NewServiceBindingUsageOperations() + svc.On("Delete", "test", "test").Return(errors.New("trololo")).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingUsageResolver(svc) + + _, err := resolver.DeleteServiceBindingUsageMutation(nil, "test", "test") + + require.Error(t, err) + }) +} + +func TestServiceBindingUsageResolver_ServiceBindingUsageQuery(t *testing.T) { + t.Run("Success", func(t *testing.T) { + svc := automock.NewServiceBindingUsageOperations() + svc.On("Find", "test", "test").Return(fixServiceBindingUsageResource(), nil).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingUsageResolver(svc) + + result, err := resolver.ServiceBindingUsageQuery(nil, "test", "test") + + require.NoError(t, err) + assert.Equal(t, fixServiceBindingUsage(), result) + }) + + t.Run("Not found", func(t *testing.T) { + svc := automock.NewServiceBindingUsageOperations() + svc.On("Find", "test", "test").Return(nil, nil).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingUsageResolver(svc) + + result, err := resolver.ServiceBindingUsageQuery(nil, "test", "test") + + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("Error", func(t *testing.T) { + svc := automock.NewServiceBindingUsageOperations() + svc.On("Find", "test", "test").Return(nil, errors.New("trolololo")).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingUsageResolver(svc) + + _, err := resolver.ServiceBindingUsageQuery(nil, "test", "test") + + require.Error(t, err) + }) +} + +func TestServiceBindingUsageResolver_ServiceBindingUsagesOfInstanceQuery(t *testing.T) { + t.Run("Success", func(t *testing.T) { + usages := []*api.ServiceBindingUsage{ + fixServiceBindingUsageResource(), + fixServiceBindingUsageResource(), + } + svc := automock.NewServiceBindingUsageOperations() + svc.On("ListForServiceInstance", "test", "test").Return(usages, nil).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingUsageResolver(svc) + + result, err := resolver.ServiceBindingUsagesOfInstanceQuery(nil, "test", "test") + + require.NoError(t, err) + assert.Equal(t, []gqlschema.ServiceBindingUsage{ + *fixServiceBindingUsage(), + *fixServiceBindingUsage(), + }, result) + }) + + t.Run("Not found", func(t *testing.T) { + svc := automock.NewServiceBindingUsageOperations() + svc.On("ListForServiceInstance", "test", "test").Return([]*api.ServiceBindingUsage{}, nil).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingUsageResolver(svc) + + result, err := resolver.ServiceBindingUsagesOfInstanceQuery(nil, "test", "test") + + require.NoError(t, err) + assert.Empty(t, result) + }) + + t.Run("Error", func(t *testing.T) { + svc := automock.NewServiceBindingUsageOperations() + svc.On("ListForServiceInstance", "test", "test").Return(nil, errors.New("trolololo")).Once() + defer svc.AssertExpectations(t) + resolver := servicecatalog.NewServiceBindingUsageResolver(svc) + + _, err := resolver.ServiceBindingUsagesOfInstanceQuery(nil, "test", "test") + + require.Error(t, err) + }) +} + +func fixServiceBindingUsage() *gqlschema.ServiceBindingUsage { + return &gqlschema.ServiceBindingUsage{ + Name: "bu-name", + Environment: "test-ns", + UsedBy: gqlschema.LocalObjectReference{ + Kind: gqlschema.BindingUsageReferenceTypeDeployment, + Name: "sample-deployment", + }, + ServiceBindingName: "binding-name", + Status: gqlschema.ServiceBindingUsageStatus{ + Type: gqlschema.ServiceBindingUsageStatusTypePending, + }, + } +} + +func fixCreateServiceBindingUsageInput() *gqlschema.CreateServiceBindingUsageInput { + return &gqlschema.CreateServiceBindingUsageInput{ + Name: "bu-name", + Environment: "test-ns", + ServiceBindingRef: gqlschema.ServiceBindingRefInput{ + Name: "binding-name", + }, + UsedBy: gqlschema.LocalObjectReferenceInput{ + Kind: gqlschema.BindingUsageReferenceTypeDeployment, + Name: "sample-deployment", + }, + } +} + +func fixServiceBindingUsageResource() *api.ServiceBindingUsage { + return &api.ServiceBindingUsage{ + TypeMeta: metav1.TypeMeta{ + Kind: "ServiceBindingUsage", + APIVersion: "servicecatalog.kyma.cx/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "bu-name", + Namespace: "test-ns", + }, + Spec: api.ServiceBindingUsageSpec{ + ServiceBindingRef: api.LocalReferenceByName{ + Name: "binding-name", + }, + UsedBy: api.LocalReferenceByKindAndName{ + Kind: "Deployment", + Name: "sample-deployment", + }, + }, + } +} + +func failingReactor(action k8stesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, errors.New("custom error") +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/binding_usage_service.go b/components/ui-api-layer/internal/domain/servicecatalog/binding_usage_service.go new file mode 100644 index 000000000000..a81db9d5074a --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/binding_usage_service.go @@ -0,0 +1,126 @@ +package servicecatalog + +import ( + "fmt" + "strings" + + api "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1" + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/cache" +) + +type serviceBindingUsageService struct { + client v1alpha1.ServicecatalogV1alpha1Interface + informer cache.SharedIndexInformer + bindingService serviceBindingOperations +} + +func newServiceBindingUsageService(client v1alpha1.ServicecatalogV1alpha1Interface, informer cache.SharedIndexInformer, service serviceBindingOperations) *serviceBindingUsageService { + svc := &serviceBindingUsageService{ + client: client, + informer: informer, + bindingService: service, + } + + informer.AddIndexers(cache.Indexers{ + "usedBy": func(obj interface{}) ([]string, error) { + serviceBindingUsage, err := svc.toServiceBindingUsage(obj) + if err != nil { + return nil, errors.New("while indexing by `usedBy`") + } + + key := fmt.Sprintf("%s/%s/%s", serviceBindingUsage.Namespace, strings.ToLower(serviceBindingUsage.Spec.UsedBy.Kind), serviceBindingUsage.Spec.UsedBy.Name) + + return []string{key}, nil + }, + }) + + return svc +} + +func (f *serviceBindingUsageService) Create(env string, sb *api.ServiceBindingUsage) (*api.ServiceBindingUsage, error) { + return f.client.ServiceBindingUsages(env).Create(sb) +} + +func (f *serviceBindingUsageService) Delete(env string, name string) error { + return f.client.ServiceBindingUsages(env).Delete(name, &v1.DeleteOptions{}) +} + +func (f *serviceBindingUsageService) Find(env string, name string) (*api.ServiceBindingUsage, error) { + key := fmt.Sprintf("%s/%s", env, name) + item, exists, err := f.informer.GetStore().GetByKey(key) + if err != nil || !exists { + return nil, err + } + + return f.toServiceBindingUsage(item) +} + +func (f *serviceBindingUsageService) List(env string) ([]*api.ServiceBindingUsage, error) { + items, err := f.informer.GetIndexer().ByIndex("namespace", env) + if err != nil { + return nil, err + } + + return f.toServiceBindingUsages(items) +} + +func (f *serviceBindingUsageService) ListForServiceInstance(env string, instanceName string) ([]*api.ServiceBindingUsage, error) { + bindings, err := f.bindingService.ListForServiceInstance(env, instanceName) + if err != nil { + return nil, errors.Wrapf(err, "while getting ServiceBindings for instance [env: %s, name: %s]", env, instanceName) + } + + bindingNames := make(map[string]struct{}) + for _, binding := range bindings { + bindingNames[binding.Name] = struct{}{} + } + + usages, err := f.List(env) + if err != nil { + return nil, errors.Wrapf(err, "while getting all ServiceBindingUsages from env: %s", env) + } + filteredUsages := make([]*api.ServiceBindingUsage, 0) + for _, usage := range usages { + if _, ex := bindingNames[usage.Spec.ServiceBindingRef.Name]; ex { + filteredUsages = append(filteredUsages, usage) + } + } + return filteredUsages, nil +} + +func (f *serviceBindingUsageService) ListForDeployment(environment, kind, deploymentName string) ([]*api.ServiceBindingUsage, error) { + key := fmt.Sprintf("%s/%s/%s", environment, strings.ToLower(kind), deploymentName) + indexer := f.informer.GetIndexer() + items, err := indexer.ByIndex("usedBy", key) + if err != nil { + return nil, err + } + + return f.toServiceBindingUsages(items) +} + +func (f *serviceBindingUsageService) toServiceBindingUsages(items []interface{}) ([]*api.ServiceBindingUsage, error) { + var usages []*api.ServiceBindingUsage + for _, item := range items { + usage, err := f.toServiceBindingUsage(item) + if err != nil { + return nil, err + } + + usages = append(usages, usage) + } + + return usages, nil +} + +func (f *serviceBindingUsageService) toServiceBindingUsage(item interface{}) (*api.ServiceBindingUsage, error) { + usage, ok := item.(*api.ServiceBindingUsage) + if !ok { + return nil, fmt.Errorf("incorrect item type: %T, should be: *ServiceBindingUsage", item) + } + + return usage, nil +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/binding_usage_service_test.go b/components/ui-api-layer/internal/domain/servicecatalog/binding_usage_service_test.go new file mode 100644 index 000000000000..e3bd43adcca9 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/binding_usage_service_test.go @@ -0,0 +1,213 @@ +package servicecatalog_test + +import ( + "fmt" + "testing" + "time" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + api "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/clientset/versioned/fake" + "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/informers/externalversions" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog/automock" + testingUtils "github.com/kyma-project/kyma/components/ui-api-layer/internal/testing" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/cache" +) + +func TestBindingUsageServiceCreate(t *testing.T) { + // GIVEN + fakeClient := fake.NewSimpleClientset() + sut := servicecatalog.NewServiceBindingUsageService(fakeClient.ServicecatalogV1alpha1(), fixBindingUsageInformer(fakeClient), nil) + // WHEN + _, err := sut.Create("prod", fixBindingUsage()) + // THEN + require.NoError(t, err) + actualUsage, err := fakeClient.ServicecatalogV1alpha1().ServiceBindingUsages("prod").Get("usage", v1.GetOptions{}) + require.NoError(t, err) + assert.NotNil(t, actualUsage) + +} + +func TestBindingUsageServiceDelete(t *testing.T) { + // GIVEN + fakeClient := fake.NewSimpleClientset(fixBindingUsage()) + sut := servicecatalog.NewServiceBindingUsageService(fakeClient.ServicecatalogV1alpha1(), fixBindingUsageInformer(fakeClient), nil) + // WHEN + err := sut.Delete("prod", "usage") + // THEN + require.NoError(t, err) + _, err = fakeClient.ServicecatalogV1alpha1().ServiceBindingUsages("prod").Get("usage", v1.GetOptions{}) + require.True(t, apierrors.IsNotFound(err)) +} + +func TestBindingUsageServiceFind(t *testing.T) { + // GIVEN + fakeClient := fake.NewSimpleClientset(fixBindingUsage()) + informer := fixBindingUsageInformer(fakeClient) + sut := servicecatalog.NewServiceBindingUsageService(fakeClient.ServicecatalogV1alpha1(), informer, nil) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + // WHEN + actual, err := sut.Find("prod", "usage") + // THEN + require.NoError(t, err) + assert.Equal(t, fixBindingUsage(), actual) +} + +func TestBindingUsageServiceList(t *testing.T) { + // GIVEN + us1 := fixBindingUsage() + us2 := fixBindingUsage() + us2.Name = "second-usage" + fakeClient := fake.NewSimpleClientset(us1, us2) + informer := fixBindingUsageInformer(fakeClient) + sut := servicecatalog.NewServiceBindingUsageService(fakeClient.ServicecatalogV1alpha1(), informer, nil) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + // WHEN + actualUsages, err := sut.List("prod") + // THEN + require.NoError(t, err) + assert.Len(t, actualUsages, 2) + assert.Contains(t, actualUsages, us1) + assert.Contains(t, actualUsages, us2) + +} + +func TestBindingUsageServiceListForServiceInstance(t *testing.T) { + // GIVEN + us1 := customBindingUsage("redis-1") + us2 := customBindingUsage("redis-2") + us3 := customBindingUsage("mysql-1") + + fakeClient := fake.NewSimpleClientset(us1, us2, us3) + informer := fixBindingUsageInformer(fakeClient) + mockBindingFacade := automock.NewServiceBindingOperations() + defer mockBindingFacade.AssertExpectations(t) + + mockBindingFacade.On("ListForServiceInstance", "prod", "redis-instance").Return( + []*v1beta1.ServiceBinding{ + { + ObjectMeta: v1.ObjectMeta{ + Name: "binding-redis-1", + Namespace: "prod", + }, + Spec: v1beta1.ServiceBindingSpec{ + ServiceInstanceRef: v1beta1.LocalObjectReference{ + Name: "redis-instance", + }, + }}, + { + ObjectMeta: v1.ObjectMeta{ + Name: "binding-redis-2", + Namespace: "prod", + }, + Spec: v1beta1.ServiceBindingSpec{ + ServiceInstanceRef: v1beta1.LocalObjectReference{ + Name: "redis-instance", + }, + }}, + }, nil) + + sut := servicecatalog.NewServiceBindingUsageService(fakeClient.ServicecatalogV1alpha1(), informer, mockBindingFacade) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + // WHEN + usages, err := sut.ListForServiceInstance("prod", "redis-instance") + // THEN + require.NoError(t, err) + assert.Len(t, usages, 2) + assert.Contains(t, usages, us1) + assert.Contains(t, usages, us2) +} + +func TestBindingUsageServiceListForServiceInstanceErrors(t *testing.T) { + t.Run("on getting bindings", func(t *testing.T) { + // GIVEN + fakeClient := fake.NewSimpleClientset() + informer := fixBindingUsageInformer(fakeClient) + mockBindingFacade := automock.NewServiceBindingOperations() + defer mockBindingFacade.AssertExpectations(t) + mockBindingFacade.On("ListForServiceInstance", mock.Anything, mock.Anything).Return(nil, errors.New("some error")) + sut := servicecatalog.NewServiceBindingUsageService(fakeClient.ServicecatalogV1alpha1(), informer, mockBindingFacade) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + // WHEN + _, err := sut.ListForServiceInstance("prod", "redis-instance") + // THEN + assert.EqualError(t, err, "while getting ServiceBindings for instance [env: prod, name: redis-instance]: some error") + }) +} + +func TestBindingUsageServiceListForDeployment(t *testing.T) { + // GIVEN + us1 := customBindingUsage("redis-1") + us2 := customBindingUsage("redis-2") + us3 := customFunctionBindingUsage("mysql-1") + + fakeClient := fake.NewSimpleClientset(us1, us2, us3) + informer := fixBindingUsageInformer(fakeClient) + sut := servicecatalog.NewServiceBindingUsageService(fakeClient.ServicecatalogV1alpha1(), informer, nil) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + // WHEN + usages, err := sut.ListForDeployment("prod", "deployment", "app") + // THEN + require.NoError(t, err) + assert.Len(t, usages, 2) + assert.Contains(t, usages, us1) + assert.Contains(t, usages, us2) + +} + +func fixBindingUsage() *api.ServiceBindingUsage { + return &api.ServiceBindingUsage{ + ObjectMeta: v1.ObjectMeta{ + Name: "usage", + Namespace: "prod", + }, + Spec: api.ServiceBindingUsageSpec{ + UsedBy: api.LocalReferenceByKindAndName{ + Kind: "deployment", + Name: "app", + }, + ServiceBindingRef: api.LocalReferenceByName{ + Name: "binding", + }, + }, + } +} + +func fixBindingUsageInformer(client *fake.Clientset) cache.SharedIndexInformer { + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + informer := informerFactory.Servicecatalog().V1alpha1().ServiceBindingUsages().Informer() + + return informer +} + +func customBindingUsage(id string) *api.ServiceBindingUsage { + return &api.ServiceBindingUsage{ + ObjectMeta: v1.ObjectMeta{ + Name: fmt.Sprintf("usage-%s", id), + Namespace: "prod", + }, + Spec: api.ServiceBindingUsageSpec{ + UsedBy: api.LocalReferenceByKindAndName{ + Kind: "deployment", + Name: "app", + }, + ServiceBindingRef: api.LocalReferenceByName{ + Name: fmt.Sprintf("binding-%s", id), + }, + }, + } +} + +func customFunctionBindingUsage(id string) *api.ServiceBindingUsage { + usage := customBindingUsage(id) + usage.Spec.UsedBy.Kind = "function" + + return usage +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/broker_converter.go b/components/ui-api-layer/internal/domain/servicecatalog/broker_converter.go new file mode 100644 index 000000000000..ce3dc55c869b --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/broker_converter.go @@ -0,0 +1,102 @@ +package servicecatalog + +import ( + "sort" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/pkg/errors" +) + +type brokerConverter struct{} + +func (c *brokerConverter) ToGQL(item *v1beta1.ClusterServiceBroker) (*gqlschema.ServiceBroker, error) { + if item == nil { + return nil, nil + } + + conditions := item.Status.Conditions + c.sortConditions(conditions) + returnStatus := c.conditionToBrokerStatus(conditions) + + labels := new(gqlschema.JSON) + err := labels.UnmarshalGQL(c.mapStringMapToJson(item.Labels)) + + if err != nil { + return nil, errors.Wrap(err, "While unmarshalling labels") + } + + broker := gqlschema.ServiceBroker{ + Name: item.Name, + Status: returnStatus, + CreationTimestamp: item.CreationTimestamp.Time, + Labels: *labels, + Url: item.Spec.URL, + } + + return &broker, nil +} + +func (c *brokerConverter) ToGQLs(in []*v1beta1.ClusterServiceBroker) ([]gqlschema.ServiceBroker, error) { + var result []gqlschema.ServiceBroker + for _, u := range in { + converted, err := c.ToGQL(u) + if err != nil { + return nil, err + } + + if converted != nil { + result = append(result, *converted) + } + } + return result, nil +} + +func (c *brokerConverter) sortConditions(conditions []v1beta1.ServiceBrokerCondition) { + sort.SliceStable(conditions, func(i, j int) bool { + return conditions[j].LastTransitionTime.Before(&conditions[i].LastTransitionTime) + }) +} + +func (c *brokerConverter) conditionToBrokerStatus(conditions []v1beta1.ServiceBrokerCondition) gqlschema.ServiceBrokerStatus { + readyStatus, exists := c.findReadyCondition(conditions) + if exists { + return gqlschema.ServiceBrokerStatus{ + Ready: readyStatus.Status == v1beta1.ConditionTrue, + Reason: readyStatus.Reason, + Message: readyStatus.Message, + } + } + + var reason, message string + if len(conditions) > 0 { + condition := conditions[0] + reason = condition.Reason + message = condition.Message + } + + return gqlschema.ServiceBrokerStatus{ + Ready: false, + Reason: reason, + Message: message, + } +} + +func (c *brokerConverter) findReadyCondition(conditions []v1beta1.ServiceBrokerCondition) (v1beta1.ServiceBrokerCondition, bool) { + for _, condition := range conditions { + if condition.Type == "Ready" { + return condition, true + } + } + + return v1beta1.ServiceBrokerCondition{}, false +} + +func (c *brokerConverter) mapStringMapToJson(labels map[string]string) map[string]interface{} { + result := make(map[string]interface{}) + for k, v := range labels { + result[k] = v + } + + return result +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/broker_converter_test.go b/components/ui-api-layer/internal/domain/servicecatalog/broker_converter_test.go new file mode 100644 index 000000000000..0c26f26f26d4 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/broker_converter_test.go @@ -0,0 +1,158 @@ +package servicecatalog + +import ( + "testing" + "time" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestBrokerConverter_ToGQL(t *testing.T) { + t.Run("All properties are given", func(t *testing.T) { + converter := brokerConverter{} + var zeroTimeStamp time.Time + labels := map[string]string{ + "label1": "labelValue1", + "label2": "labelValue2", + } + + item := fixServiceBroker() + + labelsJSON := new(gqlschema.JSON) + err := labelsJSON.UnmarshalGQL(converter.mapStringMapToJson(labels)) + require.Nil(t, err) + + expected := gqlschema.ServiceBroker{ + Name: "exampleName", + CreationTimestamp: zeroTimeStamp, + Labels: *labelsJSON, + Url: "ExampleURL", + Status: gqlschema.ServiceBrokerStatus{ + Ready: true, + Reason: "ExampleReason", + Message: "ExampleMessage", + }, + } + + result, err := converter.ToGQL(item) + require.NoError(t, err) + assert.Equal(t, &expected, result) + }) + + t.Run("Empty", func(t *testing.T) { + converter := &brokerConverter{} + _, err := converter.ToGQL(&v1beta1.ClusterServiceBroker{}) + require.NoError(t, err) + }) + + t.Run("Empty auth info", func(t *testing.T) { + converter := &brokerConverter{} + _, err := converter.ToGQL(&v1beta1.ClusterServiceBroker{ + Spec: v1beta1.ClusterServiceBrokerSpec{ + AuthInfo: &v1beta1.ClusterServiceBrokerAuthInfo{}, + }, + }) + require.NoError(t, err) + }) + + t.Run("Empty basic and bearer", func(t *testing.T) { + converter := &brokerConverter{} + _, err := converter.ToGQL(&v1beta1.ClusterServiceBroker{ + Spec: v1beta1.ClusterServiceBrokerSpec{ + AuthInfo: &v1beta1.ClusterServiceBrokerAuthInfo{ + Basic: &v1beta1.ClusterBasicAuthConfig{}, + Bearer: &v1beta1.ClusterBearerTokenAuthConfig{}, + }, + }, + }) + require.NoError(t, err) + }) + + t.Run("Nil", func(t *testing.T) { + converter := &brokerConverter{} + item, err := converter.ToGQL(nil) + + require.NoError(t, err) + assert.Nil(t, item) + }) +} + +func TestBrokerConverter_ToGQLs(t *testing.T) { + t.Run("Success", func(t *testing.T) { + brokers := []*v1beta1.ClusterServiceBroker{ + fixServiceBroker(), + fixServiceBroker(), + } + + converter := brokerConverter{} + result, err := converter.ToGQLs(brokers) + + require.NoError(t, err) + assert.Len(t, result, 2) + assert.Equal(t, "exampleName", result[0].Name) + }) + + t.Run("Empty", func(t *testing.T) { + var brokers []*v1beta1.ClusterServiceBroker + + converter := brokerConverter{} + result, err := converter.ToGQLs(brokers) + + require.NoError(t, err) + assert.Empty(t, result) + }) + + t.Run("With nil", func(t *testing.T) { + brokers := []*v1beta1.ClusterServiceBroker{ + nil, + fixServiceBroker(), + nil, + } + + converter := brokerConverter{} + result, err := converter.ToGQLs(brokers) + + require.NoError(t, err) + assert.Len(t, result, 1) + assert.Equal(t, "exampleName", result[0].Name) + }) +} + +func fixServiceBroker() *v1beta1.ClusterServiceBroker { + var mockTimeStamp metav1.Time + labels := map[string]string{ + "label1": "labelValue1", + "label2": "labelValue2", + } + + return &v1beta1.ClusterServiceBroker{ + + ObjectMeta: metav1.ObjectMeta{ + Name: "exampleName", + CreationTimestamp: mockTimeStamp, + Labels: labels, + }, + Spec: v1beta1.ClusterServiceBrokerSpec{ + CommonServiceBrokerSpec: v1beta1.CommonServiceBrokerSpec{ + URL: "ExampleURL", + }, + }, + Status: v1beta1.ClusterServiceBrokerStatus{ + CommonServiceBrokerStatus: v1beta1.CommonServiceBrokerStatus{ + Conditions: []v1beta1.ServiceBrokerCondition{ + { + Type: v1beta1.ServiceBrokerConditionType("Ready"), + Status: v1beta1.ConditionStatus("True"), + LastTransitionTime: mockTimeStamp, + Reason: "ExampleReason", + Message: "ExampleMessage", + }, + }, + }, + }, + } +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/broker_resolver.go b/components/ui-api-layer/internal/domain/servicecatalog/broker_resolver.go new file mode 100644 index 000000000000..8e1b4b5a41ea --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/broker_resolver.go @@ -0,0 +1,68 @@ +package servicecatalog + +import ( + "context" + "fmt" + + "github.com/golang/glog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + "github.com/pkg/errors" +) + +//TODO: Write unit tests for brokerResolver + +type brokerResolver struct { + brokerLister brokerListGetter + brokerConverter gqlBrokerConverter +} + +func newBrokerResolver(brokerLister brokerListGetter) *brokerResolver { + return &brokerResolver{ + brokerLister: brokerLister, + brokerConverter: &brokerConverter{}, + } +} + +func (r *brokerResolver) ServiceBrokersQuery(ctx context.Context, first *int, offset *int) ([]gqlschema.ServiceBroker, error) { + externalErr := errors.New("Cannot query ServiceBrokers") + + items, err := r.brokerLister.List(pager.PagingParams{ + First: first, + Offset: offset, + }) + + if err != nil { + glog.Error(errors.Wrap(err, "while listing ServiceBrokers")) + return nil, externalErr + } + + serviceBrokers, err := r.brokerConverter.ToGQLs(items) + if err != nil { + glog.Error(errors.Wrapf(err, "while converting ServiceBrokers")) + return nil, externalErr + } + + return serviceBrokers, nil +} + +func (r *brokerResolver) ServiceBrokerQuery(ctx context.Context, name string) (*gqlschema.ServiceBroker, error) { + externalErr := fmt.Errorf("Cannot query ServiceBroker with name `%s`", name) + + serviceBroker, err := r.brokerLister.Find(name) + if err != nil { + glog.Error(errors.Wrapf(err, "while getting ServiceBroker")) + return nil, externalErr + } + if serviceBroker == nil { + return nil, nil + } + + result, err := r.brokerConverter.ToGQL(serviceBroker) + if err != nil { + glog.Error(errors.Wrapf(err, "while converting to ServiceBroker type")) + return nil, externalErr + } + + return result, nil +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/broker_service.go b/components/ui-api-layer/internal/domain/servicecatalog/broker_service.go new file mode 100644 index 000000000000..5217d9fcc41a --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/broker_service.go @@ -0,0 +1,51 @@ +package servicecatalog + +import ( + "fmt" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + "k8s.io/client-go/tools/cache" +) + +type brokerService struct { + informer cache.SharedIndexInformer +} + +func newBrokerService(informer cache.SharedIndexInformer) *brokerService { + return &brokerService{ + informer: informer, + } +} + +func (svc *brokerService) Find(name string) (*v1beta1.ClusterServiceBroker, error) { + item, exists, err := svc.informer.GetStore().GetByKey(name) + if err != nil || !exists { + return nil, err + } + + serviceBroker, ok := item.(*v1beta1.ClusterServiceBroker) + if !ok { + return nil, fmt.Errorf("Incorrect item type: %T, should be: *ClusterServiceBroker", item) + } + + return serviceBroker, nil +} + +func (svc *brokerService) List(pagingParams pager.PagingParams) ([]*v1beta1.ClusterServiceBroker, error) { + items, err := pager.From(svc.informer.GetStore()).Limit(pagingParams) + if err != nil { + return nil, err + } + + var serviceBrokers []*v1beta1.ClusterServiceBroker + for _, item := range items { + serviceBroker, ok := item.(*v1beta1.ClusterServiceBroker) + if !ok { + return nil, fmt.Errorf("Incorrect item type: %T, should be: *ClusterServiceBroker", item) + } + serviceBrokers = append(serviceBrokers, serviceBroker) + } + + return serviceBrokers, nil +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/broker_service_test.go b/components/ui-api-layer/internal/domain/servicecatalog/broker_service_test.go new file mode 100644 index 000000000000..f3e15bfc6126 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/broker_service_test.go @@ -0,0 +1,101 @@ +package servicecatalog_test + +import ( + "testing" + "time" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kubernetes-incubator/service-catalog/pkg/client/clientset_generated/clientset/fake" + "github.com/kubernetes-incubator/service-catalog/pkg/client/informers_generated/externalversions" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + testingUtils "github.com/kyma-project/kyma/components/ui-api-layer/internal/testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestBrokerService_GetServiceBroker(t *testing.T) { + t.Run("Success", func(t *testing.T) { + brokerName := "testExample" + serviceBroker := fixServiceBroker(brokerName) + client := fake.NewSimpleClientset(serviceBroker) + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + serviceBrokerInformer := informerFactory.Servicecatalog().V1beta1().ClusterServiceBrokers().Informer() + + svc := servicecatalog.NewBrokerService(serviceBrokerInformer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, serviceBrokerInformer) + + broker, err := svc.Find(brokerName) + require.NoError(t, err) + assert.Equal(t, serviceBroker, broker) + }) + + t.Run("NotFound", func(t *testing.T) { + client := fake.NewSimpleClientset() + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + serviceBrokerInformer := informerFactory.Servicecatalog().V1beta1().ClusterServiceBrokers().Informer() + + svc := servicecatalog.NewBrokerService(serviceBrokerInformer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, serviceBrokerInformer) + + broker, err := svc.Find("doesntExist") + require.NoError(t, err) + assert.Nil(t, broker) + }) +} + +func TestBrokerService_ListServiceBrokers(t *testing.T) { + t.Run("Success", func(t *testing.T) { + serviceBroker1 := fixServiceBroker("1") + serviceBroker2 := fixServiceBroker("2") + serviceBroker3 := fixServiceBroker("3") + client := fake.NewSimpleClientset(serviceBroker1, serviceBroker2, serviceBroker3) + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + serviceBrokerInformer := informerFactory.Servicecatalog().V1beta1().ClusterServiceBrokers().Informer() + + svc := servicecatalog.NewBrokerService(serviceBrokerInformer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, serviceBrokerInformer) + + brokers, err := svc.List(pager.PagingParams{}) + require.NoError(t, err) + assert.Equal(t, []*v1beta1.ClusterServiceBroker{ + serviceBroker1, serviceBroker2, serviceBroker3, + }, brokers) + }) + + t.Run("NotFound", func(t *testing.T) { + client := fake.NewSimpleClientset() + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + serviceBrokerInformer := informerFactory.Servicecatalog().V1beta1().ClusterServiceBrokers().Informer() + + svc := servicecatalog.NewBrokerService(serviceBrokerInformer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, serviceBrokerInformer) + + var emptyArray []*v1beta1.ClusterServiceBroker + brokers, err := svc.List(pager.PagingParams{}) + require.NoError(t, err) + assert.Equal(t, emptyArray, brokers) + }) +} + +func fixServiceBroker(name string) *v1beta1.ClusterServiceBroker { + var mockTimeStamp metav1.Time + + broker := v1beta1.ClusterServiceBroker{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + CreationTimestamp: mockTimeStamp, + }, + } + + return &broker +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/class_converter.go b/components/ui-api-layer/internal/domain/servicecatalog/class_converter.go new file mode 100644 index 000000000000..4660e61ede4f --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/class_converter.go @@ -0,0 +1,59 @@ +package servicecatalog + +import ( + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/resource" + "github.com/pkg/errors" +) + +type classConverter struct{} + +func (c *classConverter) ToGQL(in *v1beta1.ClusterServiceClass) (*gqlschema.ServiceClass, error) { + if in == nil { + return nil, nil + } + + var externalMetadata map[string]interface{} + var err error + if in.Spec.ExternalMetadata != nil { + externalMetadata, err = resource.ExtractRawToMap("ExternalMetadata", in.Spec.ExternalMetadata.Raw) + if err != nil { + return nil, errors.Wrapf(err, "while converting externalMetadata for ServiceClass `%s`", in.Name) + } + } + + providerDisplayName := resource.ToStringPtr(externalMetadata["providerDisplayName"]) + imageUrl := resource.ToStringPtr(externalMetadata["imageUrl"]) + documentationUrl := resource.ToStringPtr(externalMetadata["documentationUrl"]) + displayName := resource.ToStringPtr(externalMetadata["displayName"]) + + class := gqlschema.ServiceClass{ + Name: in.Name, + ExternalName: in.Spec.ExternalName, + DisplayName: displayName, + Description: in.Spec.Description, + ProviderDisplayName: providerDisplayName, + ImageUrl: imageUrl, + DocumentationUrl: documentationUrl, + CreationTimestamp: in.CreationTimestamp.Time, + Tags: in.Spec.Tags, + } + + return &class, nil +} + +func (c *classConverter) ToGQLs(in []*v1beta1.ClusterServiceClass) ([]gqlschema.ServiceClass, error) { + var result []gqlschema.ServiceClass + for _, u := range in { + converted, err := c.ToGQL(u) + if err != nil { + return nil, err + } + + if converted != nil { + result = append(result, *converted) + } + } + return result, nil +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/class_converter_test.go b/components/ui-api-layer/internal/domain/servicecatalog/class_converter_test.go new file mode 100644 index 000000000000..13468cb56648 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/class_converter_test.go @@ -0,0 +1,178 @@ +package servicecatalog + +import ( + "encoding/json" + "testing" + "time" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" +) + +func TestClassConverter_ToGQL(t *testing.T) { + var mockTimeStamp metav1.Time + var zeroTimeStamp time.Time + t.Run("All properties are given", func(t *testing.T) { + converter := classConverter{} + maps := map[string]string{ + "displayName": "exampleDisplayName", + "providerDisplayName": "exampleProviderName", + "imageUrl": "exampleImageUrl", + "documentationUrl": "exampleDocumentationUrl", + } + + byteMaps, err := json.Marshal(maps) + item := v1beta1.ClusterServiceClass{ + Spec: v1beta1.ClusterServiceClassSpec{ + CommonServiceClassSpec: v1beta1.CommonServiceClassSpec{ + ExternalMetadata: &runtime.RawExtension{Raw: byteMaps}, + ExternalName: "ExampleExternalName", + Tags: []string{"tag1", "tag2"}, + Description: "ExampleDescription", + }, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "exampleName", + UID: types.UID("exampleUid"), + CreationTimestamp: mockTimeStamp, + ResourceVersion: "exampleVersion", + }, + } + + providerDisplayName := "exampleProviderName" + imageUrl := "exampleImageUrl" + documentationUrl := "exampleDocumentationUrl" + displayName := "exampleDisplayName" + expected := gqlschema.ServiceClass{ + Name: "exampleName", + ExternalName: "ExampleExternalName", + DisplayName: &displayName, + Description: "ExampleDescription", + ProviderDisplayName: &providerDisplayName, + ImageUrl: &imageUrl, + DocumentationUrl: &documentationUrl, + CreationTimestamp: zeroTimeStamp, + Tags: []string{"tag1", "tag2"}} + + result, err := converter.ToGQL(&item) + + assert.Equal(t, err, nil) + assert.Equal(t, &expected, result) + }) + + t.Run("Invalid externalMetadata (not equals to maps[string]string)", func(t *testing.T) { + converter := classConverter{} + maps := "randomString" + byteMaps, err := json.Marshal(maps) + item := v1beta1.ClusterServiceClass{ + Spec: v1beta1.ClusterServiceClassSpec{ + CommonServiceClassSpec: v1beta1.CommonServiceClassSpec{ + ExternalMetadata: &runtime.RawExtension{Raw: byteMaps}, + ExternalName: "ExampleExternalName", + Tags: []string{"tag1", "tag2"}, + Description: "ExampleDescription", + }, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "exampleName", + UID: types.UID("exampleUid"), + CreationTimestamp: mockTimeStamp, + ResourceVersion: "exampleVersion", + }, + } + + _, err = converter.ToGQL(&item) + + assert.Error(t, err) + }) + + t.Run("Empty", func(t *testing.T) { + converter := &classConverter{} + _, err := converter.ToGQL(&v1beta1.ClusterServiceClass{}) + require.NoError(t, err) + }) + + t.Run("Nil", func(t *testing.T) { + converter := &classConverter{} + item, err := converter.ToGQL(nil) + + require.NoError(t, err) + assert.Nil(t, item) + }) +} + +func TestClassConverter_ToGQLs(t *testing.T) { + t.Run("Success", func(t *testing.T) { + classes := []*v1beta1.ClusterServiceClass{ + fixServiceClass(t), + fixServiceClass(t), + } + + converter := classConverter{} + result, err := converter.ToGQLs(classes) + + require.NoError(t, err) + assert.Len(t, result, 2) + assert.Equal(t, "exampleName", result[0].Name) + }) + + t.Run("Empty", func(t *testing.T) { + var classes []*v1beta1.ClusterServiceClass + + converter := classConverter{} + result, err := converter.ToGQLs(classes) + + require.NoError(t, err) + assert.Empty(t, result) + }) + + t.Run("With nil", func(t *testing.T) { + classes := []*v1beta1.ClusterServiceClass{ + nil, + fixServiceClass(t), + nil, + } + + converter := classConverter{} + result, err := converter.ToGQLs(classes) + + require.NoError(t, err) + assert.Len(t, result, 1) + assert.Equal(t, "exampleName", result[0].Name) + }) +} + +func fixServiceClass(t require.TestingT) *v1beta1.ClusterServiceClass { + var mockTimeStamp metav1.Time + maps := map[string]string{ + "displayName": "exampleDisplayName", + "providerDisplayName": "exampleProviderName", + "imageUrl": "exampleImageUrl", + "documentationUrl": "exampleDocumentationUrl", + } + + byteMaps, err := json.Marshal(maps) + require.NoError(t, err) + + return &v1beta1.ClusterServiceClass{ + Spec: v1beta1.ClusterServiceClassSpec{ + CommonServiceClassSpec: v1beta1.CommonServiceClassSpec{ + ExternalMetadata: &runtime.RawExtension{Raw: byteMaps}, + ExternalName: "ExampleExternalName", + Tags: []string{"tag1", "tag2"}, + Description: "ExampleDescription", + }, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "exampleName", + UID: types.UID("exampleUid"), + CreationTimestamp: mockTimeStamp, + ResourceVersion: "exampleVersion", + }, + } +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/class_resolver.go b/components/ui-api-layer/internal/domain/servicecatalog/class_resolver.go new file mode 100644 index 000000000000..286fa934a1e4 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/class_resolver.go @@ -0,0 +1,216 @@ +package servicecatalog + +import ( + "context" + "fmt" + + "github.com/golang/glog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + "github.com/pkg/errors" +) + +type classResolver struct { + classLister classListGetter + planLister planLister + instanceLister classInstanceLister + asyncApiSpecGetter AsyncApiSpecGetter + apiSpecGetter ApiSpecGetter + contentGetter ContentGetter + classConverter gqlClassConverter + planConverter gqlPlanConverter +} + +func newClassResolver(classLister classListGetter, planLister planLister, instanceLister classInstanceLister, asyncApiSpecGetter AsyncApiSpecGetter, apiSpecGetter ApiSpecGetter, contentGetter ContentGetter) *classResolver { + return &classResolver{ + classLister: classLister, + planLister: planLister, + instanceLister: instanceLister, + asyncApiSpecGetter: asyncApiSpecGetter, + apiSpecGetter: apiSpecGetter, + contentGetter: contentGetter, + classConverter: &classConverter{}, + planConverter: &planConverter{}, + } +} + +func (r *classResolver) ServiceClassQuery(ctx context.Context, name string) (*gqlschema.ServiceClass, error) { + externalErr := fmt.Errorf("Cannot query ServiceClass with name `%s`", name) + + serviceClass, err := r.classLister.Find(name) + if err != nil { + glog.Error(errors.Wrapf(err, "while getting ServiceClass with name %s", name)) + return nil, externalErr + } + if serviceClass == nil { + return nil, nil + } + + result, err := r.classConverter.ToGQL(serviceClass) + if err != nil { + glog.Error(errors.Wrapf(err, "while converting to ServiceClass type")) + return nil, externalErr + } + + return result, nil +} + +func (r *classResolver) ServiceClassesQuery(ctx context.Context, first *int, offset *int) ([]gqlschema.ServiceClass, error) { + externalErr := fmt.Errorf("Cannot query ServiceClasses") + + items, err := r.classLister.List(pager.PagingParams{ + First: first, + Offset: offset, + }) + + if err != nil { + glog.Error(errors.Wrap(err, "while listing ServiceClasss")) + return nil, externalErr + } + + serviceClasses, err := r.classConverter.ToGQLs(items) + if err != nil { + glog.Error(errors.Wrapf(err, "while converting ServiceClasses")) + return nil, externalErr + } + + return serviceClasses, nil +} + +func (r *classResolver) ServiceClassPlansField(ctx context.Context, obj *gqlschema.ServiceClass) ([]gqlschema.ServicePlan, error) { + errMessage := "Cannot query ServicePlans for serviceClass" + + if obj == nil { + glog.Error(errors.New("ServiceClass cannot be empty in order to resolve ServicePlans for class")) + return nil, errors.New(errMessage) + } + + externalErr := fmt.Errorf("%s `%s`", errMessage, obj.Name) + + items, err := r.planLister.ListForClass(obj.Name) + if err != nil { + glog.Error(errors.Wrap(err, "while getting ServicePlans")) + return nil, externalErr + } + + convertedPlans, err := r.planConverter.ToGQLs(items) + if err != nil { + glog.Error(errors.Wrapf(err, "while converting ServicePlans")) + return nil, externalErr + } + + return convertedPlans, nil +} + +func (r *classResolver) ServiceClassActivatedField(ctx context.Context, obj *gqlschema.ServiceClass) (bool, error) { + errMessage := "Cannot query activated field for serviceClass" + + if obj == nil { + glog.Error(errors.New("ServiceClass cannot be empty in order to resolve activated field")) + return false, errors.New(errMessage) + } + + externalErr := fmt.Errorf("%s `%s`", errMessage, obj.Name) + + items, err := r.instanceLister.ListForClass(obj.Name, obj.ExternalName) + if err != nil { + glog.Error(errors.Wrapf(err, "while getting ServiceInstancesQuery for ServiceClass %s", obj.Name)) + return false, externalErr + } + + return len(items) > 0, nil +} + +func (r *classResolver) ServiceClassApiSpecField(ctx context.Context, obj *gqlschema.ServiceClass) (*gqlschema.JSON, error) { + errMessage := "Cannot query apiSpec field for ServiceClass" + + if obj == nil { + glog.Error(errors.New("ServiceClass cannot be empty in order to resolve apiSpec field")) + return nil, errors.New(errMessage) + } + + externalErr := fmt.Errorf("%s `%s`", errMessage, obj.ExternalName) + + apiSpec, err := r.apiSpecGetter.Find("service-class", obj.Name) + if err != nil { + glog.Error(errors.Wrapf(err, "while gathering apiSpec for ServiceClass %s", obj.ExternalName)) + return nil, externalErr + } + + if apiSpec == nil { + return nil, nil + } + + var result gqlschema.JSON + err = result.UnmarshalGQL(apiSpec.Raw) + if err != nil { + glog.Error(errors.Wrapf(err, "while converting apiSpec for ServiceClass %s", obj.ExternalName)) + return nil, externalErr + } + + return &result, nil +} + +func (r *classResolver) ServiceClassAsyncApiSpecField(ctx context.Context, obj *gqlschema.ServiceClass) (*gqlschema.JSON, error) { + errMessage := "Cannot query asyncApiSpec field for ServiceClass" + + if obj == nil { + glog.Error(errors.New("ServiceClass cannot be empty in order to resolve asyncApiSpec field")) + return nil, errors.New(errMessage) + } + + externalErr := fmt.Errorf("%s `%s`", errMessage, obj.ExternalName) + + asyncApiSpec, err := r.asyncApiSpecGetter.Find("service-class", obj.Name) + if err != nil { + glog.Error(errors.Wrapf(err, "while gathering asyncApiSpec for ServiceClass %s", obj.ExternalName)) + return nil, externalErr + } + + if asyncApiSpec == nil { + return nil, nil + } + + var result gqlschema.JSON + err = result.UnmarshalGQL(asyncApiSpec.Raw) + if err != nil { + glog.Error(errors.Wrapf(err, "while converting asyncApiSpec for ServiceClass %s", obj.ExternalName)) + return nil, externalErr + } + + return &result, nil +} + +func (r *classResolver) ServiceClassContentField(ctx context.Context, obj *gqlschema.ServiceClass) (*gqlschema.JSON, error) { + errMessage := "Cannot query content field for ServiceClass" + + if obj == nil { + glog.Error(errors.New("ServiceClass cannot be empty in order to resolve `content` field")) + return nil, errors.New(errMessage) + } + + externalErr := fmt.Errorf("%s `%s`", errMessage, obj.ExternalName) + + content, err := r.contentGetter.Find("service-class", obj.Name) + if err != nil { + glog.Error(errors.Wrapf(err, "while gathering content for ServiceClass %s", obj.ExternalName)) + return nil, externalErr + } + + if content == nil { + return nil, nil + } + + var result gqlschema.JSON + err = result.UnmarshalGQL(content.Raw) + if err != nil { + glog.Error(errors.Wrapf(err, "while converting content for ServiceClass %s", obj.ExternalName)) + return nil, externalErr + } + + return &result, nil +} + +func (r *classResolver) getContentId(obj *gqlschema.ServiceClass) string { + return fmt.Sprintf("service-class/%s", obj.Name) +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/class_resolver_test.go b/components/ui-api-layer/internal/domain/servicecatalog/class_resolver_test.go new file mode 100644 index 000000000000..d7c5f86cc9d6 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/class_resolver_test.go @@ -0,0 +1,473 @@ +package servicecatalog_test + +import ( + "testing" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog/automock" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TODO: Create test suite to reduce boilerplate +func TestClassResolver_ServiceClassQuery(t *testing.T) { + t.Run("Success", func(t *testing.T) { + expected := &gqlschema.ServiceClass{ + Name: "Test", + } + name := "name" + resource := &v1beta1.ClusterServiceClass{} + resourceGetter := automock.NewClassListGetter() + resourceGetter.On("Find", name).Return(resource, nil).Once() + defer resourceGetter.AssertExpectations(t) + + converter := automock.NewGQLClassConverter() + converter.On("ToGQL", resource).Return(expected, nil).Once() + defer converter.AssertExpectations(t) + + resolver := servicecatalog.NewClassResolver(resourceGetter, nil, nil, nil, nil, nil) + resolver.SetClassConverter(converter) + + result, err := resolver.ServiceClassQuery(nil, name) + + require.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("NotFound", func(t *testing.T) { + name := "name" + resourceGetter := automock.NewClassListGetter() + resourceGetter.On("Find", name).Return(nil, nil).Once() + defer resourceGetter.AssertExpectations(t) + + resolver := servicecatalog.NewClassResolver(resourceGetter, nil, nil, nil, nil, nil) + + result, err := resolver.ServiceClassQuery(nil, name) + + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("Error", func(t *testing.T) { + expected := errors.New("Test") + name := "name" + resource := &v1beta1.ClusterServiceClass{} + resourceGetter := automock.NewClassListGetter() + resourceGetter.On("Find", name).Return(resource, expected).Once() + defer resourceGetter.AssertExpectations(t) + + resolver := servicecatalog.NewClassResolver(resourceGetter, nil, nil, nil, nil, nil) + + result, err := resolver.ServiceClassQuery(nil, name) + + assert.Error(t, err) + assert.Nil(t, result) + }) +} + +func TestClassResolver_ServiceClassesQuery(t *testing.T) { + t.Run("Success", func(t *testing.T) { + resource := + &v1beta1.ClusterServiceClass{ + ObjectMeta: v1.ObjectMeta{ + Name: "test", + }, + } + resources := []*v1beta1.ClusterServiceClass{ + resource, resource, + } + expected := []gqlschema.ServiceClass{ + { + Name: "Test", + }, { + Name: "Test", + }, + } + + resourceGetter := automock.NewClassListGetter() + resourceGetter.On("List", pager.PagingParams{}).Return(resources, nil).Once() + defer resourceGetter.AssertExpectations(t) + + converter := automock.NewGQLClassConverter() + converter.On("ToGQLs", resources).Return(expected, nil) + defer converter.AssertExpectations(t) + + resolver := servicecatalog.NewClassResolver(resourceGetter, nil, nil, nil, nil, nil) + resolver.SetClassConverter(converter) + + result, err := resolver.ServiceClassesQuery(nil, nil, nil) + + require.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("NotFound", func(t *testing.T) { + var resources []*v1beta1.ClusterServiceClass + + resourceGetter := automock.NewClassListGetter() + resourceGetter.On("List", pager.PagingParams{}).Return(resources, nil).Once() + defer resourceGetter.AssertExpectations(t) + resolver := servicecatalog.NewClassResolver(resourceGetter, nil, nil, nil, nil, nil) + var expected []gqlschema.ServiceClass + + result, err := resolver.ServiceClassesQuery(nil, nil, nil) + + require.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("Error", func(t *testing.T) { + expected := errors.New("Test") + + var resources []*v1beta1.ClusterServiceClass + + resourceGetter := automock.NewClassListGetter() + resourceGetter.On("List", pager.PagingParams{}).Return(resources, expected).Once() + defer resourceGetter.AssertExpectations(t) + resolver := servicecatalog.NewClassResolver(resourceGetter, nil, nil, nil, nil, nil) + + _, err := resolver.ServiceClassesQuery(nil, nil, nil) + + require.Error(t, err) + }) +} + +func TestClassResolver_ServiceClassActivatedField(t *testing.T) { + t.Run("Success", func(t *testing.T) { + expected := true + name := "name" + externalName := "externalName" + resources := []*v1beta1.ServiceInstance{{}, {}} + resourceGetter := automock.NewClassInstanceLister() + resourceGetter.On("ListForClass", name, externalName).Return(resources, nil).Once() + defer resourceGetter.AssertExpectations(t) + + parentObj := gqlschema.ServiceClass{ + Name: name, + ExternalName: externalName, + } + + resolver := servicecatalog.NewClassResolver(nil, nil, resourceGetter, nil, nil, nil) + + result, err := resolver.ServiceClassActivatedField(nil, &parentObj) + + require.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("NotFound", func(t *testing.T) { + name := "name" + externalName := "externalName" + resourceGetter := automock.NewClassInstanceLister() + resourceGetter.On("ListForClass", name, externalName).Return(nil, nil).Once() + defer resourceGetter.AssertExpectations(t) + + parentObj := &gqlschema.ServiceClass{ + Name: name, + ExternalName: externalName, + } + + resolver := servicecatalog.NewClassResolver(nil, nil, resourceGetter, nil, nil, nil) + + result, err := resolver.ServiceClassActivatedField(nil, parentObj) + + require.NoError(t, err) + assert.False(t, result) + }) + + t.Run("Error", func(t *testing.T) { + expectedErr := errors.New("Test") + name := "name" + externalName := "externalName" + resourceGetter := automock.NewClassInstanceLister() + resourceGetter.On("ListForClass", name, externalName).Return(nil, expectedErr).Once() + defer resourceGetter.AssertExpectations(t) + + parentObj := gqlschema.ServiceClass{ + Name: name, + ExternalName: externalName, + } + + resolver := servicecatalog.NewClassResolver(nil, nil, resourceGetter, nil, nil, nil) + + _, err := resolver.ServiceClassActivatedField(nil, &parentObj) + + assert.Error(t, err) + }) +} + +func TestClassResolver_ServiceClassPlansField(t *testing.T) { + t.Run("Success", func(t *testing.T) { + expectedSingleObj := gqlschema.ServicePlan{ + Name: "Test", + } + expected := []gqlschema.ServicePlan{ + expectedSingleObj, + expectedSingleObj, + } + + name := "name" + resource := v1beta1.ClusterServicePlan{} + resources := []*v1beta1.ClusterServicePlan{ + &resource, + &resource, + } + resourceGetter := automock.NewPlanLister() + resourceGetter.On("ListForClass", name).Return(resources, nil).Once() + defer resourceGetter.AssertExpectations(t) + + converter := automock.NewGQLPlanConverter() + converter.On("ToGQLs", resources).Return(expected, nil).Once() + defer converter.AssertExpectations(t) + + parentObj := gqlschema.ServiceClass{ + Name: name, + } + resolver := servicecatalog.NewClassResolver(nil, resourceGetter, nil, nil, nil, nil) + resolver.SetPlanConverter(converter) + + result, err := resolver.ServiceClassPlansField(nil, &parentObj) + + require.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("NotFound", func(t *testing.T) { + name := "name" + resourceGetter := automock.NewPlanLister() + resourceGetter.On("ListForClass", name).Return(nil, nil).Once() + defer resourceGetter.AssertExpectations(t) + + parentObj := gqlschema.ServiceClass{ + Name: name, + } + resolver := servicecatalog.NewClassResolver(nil, resourceGetter, nil, nil, nil, nil) + + result, err := resolver.ServiceClassPlansField(nil, &parentObj) + + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("Error", func(t *testing.T) { + expectedErr := errors.New("Test") + name := "name" + resourceGetter := automock.NewPlanLister() + resourceGetter.On("ListForClass", name).Return(nil, expectedErr).Once() + defer resourceGetter.AssertExpectations(t) + + parentObj := gqlschema.ServiceClass{ + Name: name, + } + resolver := servicecatalog.NewClassResolver(nil, resourceGetter, nil, nil, nil, nil) + + result, err := resolver.ServiceClassPlansField(nil, &parentObj) + + assert.Error(t, err) + assert.Nil(t, result) + }) +} + +func TestClassResolver_ServiceClassContentField(t *testing.T) { + t.Run("Success", func(t *testing.T) { + name := "name" + resource := &storage.Content{ + Raw: map[string]interface{}{ + "test": "data", + }, + } + expected := new(gqlschema.JSON) + err := expected.UnmarshalGQL(resource.Raw) + require.NoError(t, err) + + resourceGetter := new(automock.ContentGetter) + resourceGetter.On("Find", "service-class", name).Return(resource, nil).Once() + defer resourceGetter.AssertExpectations(t) + + parentObj := gqlschema.ServiceClass{ + Name: name, + } + + resolver := servicecatalog.NewClassResolver(nil, nil, nil, nil, nil, resourceGetter) + + result, err := resolver.ServiceClassContentField(nil, &parentObj) + + require.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("NotFound", func(t *testing.T) { + name := "name" + resourceGetter := new(automock.ContentGetter) + resourceGetter.On("Find", "service-class", name).Return(nil, nil).Once() + defer resourceGetter.AssertExpectations(t) + + parentObj := gqlschema.ServiceClass{ + Name: name, + } + + resolver := servicecatalog.NewClassResolver(nil, nil, nil, nil, nil, resourceGetter) + + result, err := resolver.ServiceClassContentField(nil, &parentObj) + + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("Error", func(t *testing.T) { + expectedErr := errors.New("Test") + name := "name" + resourceGetter := new(automock.ContentGetter) + resourceGetter.On("Find", "service-class", name).Return(nil, expectedErr).Once() + defer resourceGetter.AssertExpectations(t) + + parentObj := gqlschema.ServiceClass{ + Name: name, + } + + resolver := servicecatalog.NewClassResolver(nil, nil, nil, nil, nil, resourceGetter) + + result, err := resolver.ServiceClassContentField(nil, &parentObj) + + assert.Error(t, err) + assert.Nil(t, result) + }) +} + +func TestClassResolver_ServiceClassApiSpecField(t *testing.T) { + t.Run("Success", func(t *testing.T) { + name := "name" + resource := &storage.ApiSpec{ + Raw: map[string]interface{}{ + "test": "data", + }, + } + expected := new(gqlschema.JSON) + err := expected.UnmarshalGQL(resource.Raw) + require.NoError(t, err) + + resourceGetter := new(automock.ApiSpecGetter) + resourceGetter.On("Find", "service-class", name).Return(resource, nil).Once() + defer resourceGetter.AssertExpectations(t) + + parentObj := gqlschema.ServiceClass{ + Name: name, + } + + resolver := servicecatalog.NewClassResolver(nil, nil, nil, nil, resourceGetter, nil) + + result, err := resolver.ServiceClassApiSpecField(nil, &parentObj) + + require.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("NotFound", func(t *testing.T) { + name := "name" + resourceGetter := new(automock.ApiSpecGetter) + resourceGetter.On("Find", "service-class", name).Return(nil, nil).Once() + defer resourceGetter.AssertExpectations(t) + + parentObj := gqlschema.ServiceClass{ + Name: name, + } + + resolver := servicecatalog.NewClassResolver(nil, nil, nil, nil, resourceGetter, nil) + + result, err := resolver.ServiceClassApiSpecField(nil, &parentObj) + + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("Error", func(t *testing.T) { + expectedErr := errors.New("Test") + name := "name" + resourceGetter := new(automock.ApiSpecGetter) + resourceGetter.On("Find", "service-class", name).Return(nil, expectedErr).Once() + defer resourceGetter.AssertExpectations(t) + + parentObj := gqlschema.ServiceClass{ + Name: name, + } + + resolver := servicecatalog.NewClassResolver(nil, nil, nil, nil, resourceGetter, nil) + + result, err := resolver.ServiceClassApiSpecField(nil, &parentObj) + + assert.Error(t, err) + assert.Nil(t, result) + }) +} + +func TestClassResolver_ServiceClassAsyncApiSpecField(t *testing.T) { + t.Run("Success", func(t *testing.T) { + name := "name" + resource := &storage.AsyncApiSpec{ + Raw: map[string]interface{}{ + "test": "data", + }, + } + expected := new(gqlschema.JSON) + err := expected.UnmarshalGQL(resource.Raw) + require.NoError(t, err) + + resourceGetter := new(automock.AsyncApiSpecGetter) + resourceGetter.On("Find", "service-class", name).Return(resource, nil).Once() + defer resourceGetter.AssertExpectations(t) + + parentObj := gqlschema.ServiceClass{ + Name: name, + } + + resolver := servicecatalog.NewClassResolver(nil, nil, nil, resourceGetter, nil, nil) + + result, err := resolver.ServiceClassAsyncApiSpecField(nil, &parentObj) + + require.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("NotFound", func(t *testing.T) { + name := "name" + resourceGetter := new(automock.AsyncApiSpecGetter) + resourceGetter.On("Find", "service-class", name).Return(nil, nil).Once() + defer resourceGetter.AssertExpectations(t) + + parentObj := gqlschema.ServiceClass{ + Name: name, + } + + resolver := servicecatalog.NewClassResolver(nil, nil, nil, resourceGetter, nil, nil) + + result, err := resolver.ServiceClassAsyncApiSpecField(nil, &parentObj) + + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("Error", func(t *testing.T) { + expectedErr := errors.New("Test") + name := "name" + resourceGetter := new(automock.AsyncApiSpecGetter) + resourceGetter.On("Find", "service-class", name).Return(nil, expectedErr).Once() + defer resourceGetter.AssertExpectations(t) + + parentObj := gqlschema.ServiceClass{ + Name: name, + } + + resolver := servicecatalog.NewClassResolver(nil, nil, nil, resourceGetter, nil, nil) + + result, err := resolver.ServiceClassAsyncApiSpecField(nil, &parentObj) + + assert.Error(t, err) + assert.Nil(t, result) + }) +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/class_service.go b/components/ui-api-layer/internal/domain/servicecatalog/class_service.go new file mode 100644 index 000000000000..66c41ed225fd --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/class_service.go @@ -0,0 +1,87 @@ +package servicecatalog + +import ( + "fmt" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + "github.com/pkg/errors" + "k8s.io/client-go/tools/cache" +) + +type classService struct { + informer cache.SharedIndexInformer +} + +func newClassService(informer cache.SharedIndexInformer) *classService { + informer.AddIndexers(cache.Indexers{ + "externalName": func(obj interface{}) ([]string, error) { + servicePlan, ok := obj.(*v1beta1.ClusterServiceClass) + if !ok { + return nil, errors.New("Cannot convert item") + } + + return []string{servicePlan.Spec.ExternalName}, nil + }, + }) + + return &classService{ + informer: informer, + } +} + +func (svc *classService) Find(name string) (*v1beta1.ClusterServiceClass, error) { + item, exists, err := svc.informer.GetStore().GetByKey(name) + if err != nil || !exists { + return nil, err + } + + serviceClass, ok := item.(*v1beta1.ClusterServiceClass) + if !ok { + return nil, fmt.Errorf("Incorrect item type: %T, should be: *ClusterServiceClass", item) + } + + return serviceClass, nil +} + +func (svc *classService) FindByExternalName(externalName string) (*v1beta1.ClusterServiceClass, error) { + items, err := svc.informer.GetIndexer().ByIndex("externalName", externalName) + if err != nil { + return nil, err + } + + if len(items) == 0 { + return nil, nil + } + + if len(items) > 1 { + return nil, fmt.Errorf("Multiple ServiceClass resources with the same externalName %s", externalName) + } + + item := items[0] + serviceClass, ok := item.(*v1beta1.ClusterServiceClass) + if !ok { + return nil, fmt.Errorf("Incorrect item type: %T, should be: *ClusterServiceClass", item) + } + + return serviceClass, nil +} + +func (svc *classService) List(pagingParams pager.PagingParams) ([]*v1beta1.ClusterServiceClass, error) { + items, err := pager.From(svc.informer.GetStore()).Limit(pagingParams) + if err != nil { + return nil, err + } + + var serviceClasses []*v1beta1.ClusterServiceClass + for _, item := range items { + serviceClass, ok := item.(*v1beta1.ClusterServiceClass) + if !ok { + return nil, fmt.Errorf("Incorrect item type: %T, should be: *ClusterServiceClass", item) + } + + serviceClasses = append(serviceClasses, serviceClass) + } + + return serviceClasses, nil +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/class_service_test.go b/components/ui-api-layer/internal/domain/servicecatalog/class_service_test.go new file mode 100644 index 000000000000..9ed957190112 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/class_service_test.go @@ -0,0 +1,160 @@ +package servicecatalog_test + +import ( + "testing" + "time" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kubernetes-incubator/service-catalog/pkg/client/clientset_generated/clientset/fake" + "github.com/kubernetes-incubator/service-catalog/pkg/client/informers_generated/externalversions" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + testingUtils "github.com/kyma-project/kyma/components/ui-api-layer/internal/testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestClassService_GetServiceClass(t *testing.T) { + t.Run("Success", func(t *testing.T) { + className := "testExample" + serviceClass := fixServiceClass(className, className) + client := fake.NewSimpleClientset(serviceClass) + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + serviceClassInformer := informerFactory.Servicecatalog().V1beta1().ClusterServiceClasses().Informer() + + svc := servicecatalog.NewClassService(serviceClassInformer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, serviceClassInformer) + + class, err := svc.Find(className) + require.NoError(t, err) + assert.Equal(t, serviceClass, class) + }) + + t.Run("NotFound", func(t *testing.T) { + client := fake.NewSimpleClientset() + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + serviceClassInformer := informerFactory.Servicecatalog().V1beta1().ClusterServiceClasses().Informer() + + svc := servicecatalog.NewClassService(serviceClassInformer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, serviceClassInformer) + + class, err := svc.Find("doesntExist") + + require.NoError(t, err) + assert.Nil(t, class) + }) +} + +func TestClassService_FindByExternalName(t *testing.T) { + t.Run("Success", func(t *testing.T) { + className := "testExample" + externalName := "testExternal" + serviceClass := fixServiceClass(className, externalName) + client := fake.NewSimpleClientset(serviceClass) + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + serviceClassInformer := informerFactory.Servicecatalog().V1beta1().ClusterServiceClasses().Informer() + + svc := servicecatalog.NewClassService(serviceClassInformer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, serviceClassInformer) + + class, err := svc.FindByExternalName(externalName) + require.NoError(t, err) + assert.Equal(t, serviceClass, class) + }) + + t.Run("NotFound", func(t *testing.T) { + client := fake.NewSimpleClientset() + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + serviceClassInformer := informerFactory.Servicecatalog().V1beta1().ClusterServiceClasses().Informer() + + svc := servicecatalog.NewClassService(serviceClassInformer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, serviceClassInformer) + + class, err := svc.FindByExternalName("doesntExist") + + require.NoError(t, err) + assert.Nil(t, class) + }) + + t.Run("Error", func(t *testing.T) { + externalName := "duplicateName" + client := fake.NewSimpleClientset( + fixServiceClass("1", externalName), + fixServiceClass("2", externalName), + fixServiceClass("3", externalName), + ) + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + serviceClassInformer := informerFactory.Servicecatalog().V1beta1().ClusterServiceClasses().Informer() + + svc := servicecatalog.NewClassService(serviceClassInformer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, serviceClassInformer) + + _, err := svc.FindByExternalName(externalName) + + assert.Error(t, err) + }) +} + +func TestClassService_ListServiceClasses(t *testing.T) { + t.Run("Success", func(t *testing.T) { + serviceClass1 := fixServiceClass("1", "1") + serviceClass2 := fixServiceClass("2", "2") + serviceClass3 := fixServiceClass("3", "3") + client := fake.NewSimpleClientset(serviceClass1, serviceClass2, serviceClass3) + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + serviceClassInformer := informerFactory.Servicecatalog().V1beta1().ClusterServiceClasses().Informer() + + svc := servicecatalog.NewClassService(serviceClassInformer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, serviceClassInformer) + + classes, err := svc.List(pager.PagingParams{}) + require.NoError(t, err) + assert.Equal(t, []*v1beta1.ClusterServiceClass{ + serviceClass1, serviceClass2, serviceClass3, + }, classes) + }) + + t.Run("NotFound", func(t *testing.T) { + client := fake.NewSimpleClientset() + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + serviceClassInformer := informerFactory.Servicecatalog().V1beta1().ClusterServiceClasses().Informer() + + svc := servicecatalog.NewClassService(serviceClassInformer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, serviceClassInformer) + + var emptyArray []*v1beta1.ClusterServiceClass + classes, err := svc.List(pager.PagingParams{}) + require.NoError(t, err) + assert.Equal(t, emptyArray, classes) + }) +} + +func fixServiceClass(name, externalName string) *v1beta1.ClusterServiceClass { + class := v1beta1.ClusterServiceClass{ + Spec: v1beta1.ClusterServiceClassSpec{ + CommonServiceClassSpec: v1beta1.CommonServiceClassSpec{ + ExternalName: externalName, + Tags: []string{"tag1", "tag2"}, + }, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } + return &class +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/export_test.go b/components/ui-api-layer/internal/domain/servicecatalog/export_test.go new file mode 100644 index 000000000000..986f5007be89 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/export_test.go @@ -0,0 +1,106 @@ +package servicecatalog + +// ServiceInstance +import ( + "github.com/kubernetes-incubator/service-catalog/pkg/client/clientset_generated/clientset/typed/servicecatalog/v1beta1" + "k8s.io/client-go/tools/cache" + + "github.com/kubernetes-incubator/service-catalog/pkg/client/clientset_generated/clientset" + + "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/clientset/versioned/typed/servicecatalog/v1alpha1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +) + +func NewInstanceService(informer cache.SharedIndexInformer, client clientset.Interface) *instanceService { + return newInstanceService(informer, client) +} + +func NewInstanceResolver(iS instanceSvc, pG planGetter, cG classGetter) *instanceResolver { + return newInstanceResolver(iS, pG, cG) +} + +func NewMockInstanceConverter() *mockGqlInstanceConverter { + return new(mockGqlInstanceConverter) +} + +func NewMockInstanceService() *mockInstanceSvc { + return new(mockInstanceSvc) +} + +func NewInstanceListener(channel chan<- gqlschema.ServiceInstanceEvent, filter func(object interface{}) bool, instanceConverter gqlInstanceConverter) *instanceListener { + return &instanceListener{ + channel: channel, + filter: filter, + instanceConverter: instanceConverter, + } +} + +func NewInstanceCreateParameters(name, namespace string, labels []string, externalServicePlanName, externalServiceClassName string, schema map[string]interface{}) *instanceCreateParameters { + return &instanceCreateParameters{ + Name: name, + Namespace: namespace, + Labels: labels, + ExternalServicePlanName: externalServicePlanName, + ExternalServiceClassName: externalServiceClassName, + Schema: schema, + } +} + +func (r *instanceResolver) SetInstanceConverter(converter gqlInstanceConverter) { + r.instanceConverter = converter +} + +func (r *instanceResolver) SetClassConverter(converter gqlClassConverter) { + r.classConverter = converter +} + +func (r *instanceResolver) SetPlanConverter(converter gqlPlanConverter) { + r.planConverter = converter +} + +// ServiceClass + +func NewClassService(informer cache.SharedIndexInformer) *classService { + return newClassService(informer) +} + +func NewClassResolver(classLister classListGetter, planLister planLister, instanceLister classInstanceLister, asyncApiSpecGetter AsyncApiSpecGetter, apiSpecGetter ApiSpecGetter, contentGetter ContentGetter) *classResolver { + return newClassResolver(classLister, planLister, instanceLister, asyncApiSpecGetter, apiSpecGetter, contentGetter) +} + +func (r *classResolver) SetClassConverter(converter gqlClassConverter) { + r.classConverter = converter +} + +func (r *classResolver) SetPlanConverter(converter gqlPlanConverter) { + r.planConverter = converter +} + +// ServiceBroker + +func NewBrokerService(informer cache.SharedIndexInformer) *brokerService { + return newBrokerService(informer) +} + +// ServicePlan + +func NewPlanService(informer cache.SharedIndexInformer) *planService { + return newPlanService(informer) +} + +func NewServiceBindingResolver(sbService serviceBindingOperations) *serviceBindingResolver { + return newServiceBindingResolver(sbService) +} +func NewServiceBindingService(client v1beta1.ServicecatalogV1beta1Interface, informer cache.SharedIndexInformer) *serviceBindingService { + return newServiceBindingService(client, informer) +} + +// Binding usage + +func NewServiceBindingUsageService(buInterface v1alpha1.ServicecatalogV1alpha1Interface, informer cache.SharedIndexInformer, bindingOp serviceBindingOperations) *serviceBindingUsageService { + return newServiceBindingUsageService(buInterface, informer, bindingOp) +} + +func NewServiceBindingUsageResolver(op serviceBindingUsageOperations) *serviceBindingUsageResolver { + return newServiceBindingUsageResolver(op) +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/instance_converter.go b/components/ui-api-layer/internal/domain/servicecatalog/instance_converter.go new file mode 100644 index 000000000000..e8a061b14d04 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/instance_converter.go @@ -0,0 +1,172 @@ +package servicecatalog + +import ( + "fmt" + "strings" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog/status" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +) + +type instanceConverter struct { + extractor status.InstanceExtractor +} + +func (c *instanceConverter) ToGQL(in *v1beta1.ServiceInstance) *gqlschema.ServiceInstance { + if in == nil { + return nil + } + + instanceLabels := c.extractLabels(in) + + var servicePlanName *string + if in.Spec.ClusterServicePlanRef != nil { + servicePlanName = &in.Spec.ClusterServicePlanRef.Name + } + + var serviceClassName *string + if in.Spec.ClusterServiceClassRef != nil { + serviceClassName = &in.Spec.ClusterServiceClassRef.Name + } + + instance := gqlschema.ServiceInstance{ + Name: in.Name, + Environment: in.Namespace, + ServicePlanName: servicePlanName, + ServicePlanDisplayName: in.Spec.ClusterServicePlanExternalName, + ServiceClassName: serviceClassName, + ServiceClassDisplayName: in.Spec.ClusterServiceClassExternalName, + Labels: instanceLabels, + Status: *c.ServiceStatusToGQLStatus(c.extractor.Status(in)), + CreationTimestamp: in.CreationTimestamp.Time, + } + + return &instance +} + +func (c *instanceConverter) ToGQLs(in []*v1beta1.ServiceInstance) []gqlschema.ServiceInstance { + var result []gqlschema.ServiceInstance + for _, u := range in { + converted := c.ToGQL(u) + + if converted != nil { + result = append(result, *converted) + } + } + return result +} + +func (c *instanceConverter) GQLCreateInputToInstanceCreateParameters(in *gqlschema.ServiceInstanceCreateInput) *instanceCreateParameters { + if in == nil { + return nil + } + + var parameterSchema map[string]interface{} + if in.ParameterSchema != nil { + parameterSchema = *in.ParameterSchema + } + + parameters := instanceCreateParameters{ + Name: in.Name, + Namespace: in.Environment, + Labels: in.Labels, + ExternalServicePlanName: in.ExternalPlanName, + ExternalServiceClassName: in.ExternalServiceClassName, + Schema: parameterSchema, + } + + return ¶meters +} + +func (c *instanceConverter) ServiceStatusTypeToGQLStatusType(in status.ServiceInstanceStatusType) gqlschema.InstanceStatusType { + if &in == nil { + return "" + } + + switch in { + case status.ServiceInstanceStatusTypeRunning: + return gqlschema.InstanceStatusTypeRunning + case status.ServiceInstanceStatusTypeProvisioning: + return gqlschema.InstanceStatusTypeProvisioning + case status.ServiceInstanceStatusTypeDeprovisioning: + return gqlschema.InstanceStatusTypeDeprovisioning + case status.ServiceInstanceStatusTypeFailed: + return gqlschema.InstanceStatusTypeFailed + default: + return gqlschema.InstanceStatusTypePending + } +} + +func (c *instanceConverter) GQLStatusTypeToServiceStatusType(in gqlschema.InstanceStatusType) status.ServiceInstanceStatusType { + if &in == nil { + return "" + } + + switch in { + case gqlschema.InstanceStatusTypeRunning: + return status.ServiceInstanceStatusTypeRunning + case gqlschema.InstanceStatusTypeProvisioning: + return status.ServiceInstanceStatusTypeProvisioning + case gqlschema.InstanceStatusTypeDeprovisioning: + return status.ServiceInstanceStatusTypeDeprovisioning + case gqlschema.InstanceStatusTypeFailed: + return status.ServiceInstanceStatusTypeFailed + default: + return status.ServiceInstanceStatusTypePending + } +} + +func (c *instanceConverter) GQLStatusToServiceStatus(in *gqlschema.ServiceInstanceStatus) *status.ServiceInstanceStatus { + if in == nil { + return nil + } + + return &status.ServiceInstanceStatus{ + Type: c.GQLStatusTypeToServiceStatusType(in.Type), + Reason: in.Reason, + Message: in.Message, + } +} + +func (c *instanceConverter) ServiceStatusToGQLStatus(in *status.ServiceInstanceStatus) *gqlschema.ServiceInstanceStatus { + if in == nil { + return nil + } + + return &gqlschema.ServiceInstanceStatus{ + Type: c.ServiceStatusTypeToGQLStatusType(in.Type), + Reason: in.Reason, + Message: in.Message, + } +} + +func (c *instanceConverter) extractLabels(in *v1beta1.ServiceInstance) []string { + if in == nil || len(in.Annotations["tags"]) == 0 { + return []string{} + } + + return strings.Split(in.Annotations["tags"], ",") +} + +func (c *instanceConverter) populateLabels(inputLabels interface{}) ([]string, error) { + if inputLabels == nil { + return []string{}, nil + } + + items, ok := inputLabels.([]interface{}) + if !ok { + return []string{}, fmt.Errorf("Incorrect items type %T: should be []interface{}", inputLabels) + } + + var labels []string + for _, item := range items { + label, ok := item.(string) + if !ok { + return []string{}, fmt.Errorf("Incorrect item type %T: should be string", inputLabels) + } + labels = append(labels, label) + } + + return labels, nil +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/instance_converter_test.go b/components/ui-api-layer/internal/domain/servicecatalog/instance_converter_test.go new file mode 100644 index 000000000000..4b7cbfcd0b55 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/instance_converter_test.go @@ -0,0 +1,336 @@ +package servicecatalog + +import ( + "encoding/json" + "testing" + "time" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog/status" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" +) + +func TestInstanceConverter_ToGQL(t *testing.T) { + var mockTimeStamp metav1.Time + var zeroTimeStamp time.Time + t.Run("All properties are given", func(t *testing.T) { + converter := instanceConverter{} + + in := v1beta1.ServiceInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "exampleName", + Namespace: "Environment", + UID: types.UID("uid"), + CreationTimestamp: mockTimeStamp, + Annotations: map[string]string{ + "tags": "test1,test2", + }, + }, + Spec: v1beta1.ServiceInstanceSpec{ + ClusterServiceClassRef: &v1beta1.ClusterObjectReference{ + Name: "testClass", + }, + ClusterServicePlanRef: &v1beta1.ClusterObjectReference{ + Name: "testPlan", + }, + }, + Status: v1beta1.ServiceInstanceStatus{ + AsyncOpInProgress: false, + Conditions: []v1beta1.ServiceInstanceCondition{ + { + Type: v1beta1.ServiceInstanceConditionReady, + Status: v1beta1.ConditionTrue, + Message: "Working", + Reason: "Testing", + }, + }, + }, + } + + testClassName := "testClass" + testPlanName := "testPlan" + expected := gqlschema.ServiceInstance{ + Name: "exampleName", + Environment: "Environment", + ServiceClassName: &testClassName, + ServicePlanName: &testPlanName, + Labels: []string{"test1", "test2"}, + CreationTimestamp: zeroTimeStamp, + Status: gqlschema.ServiceInstanceStatus{ + Type: gqlschema.InstanceStatusTypeRunning, + Reason: "Testing", + Message: "Working", + }, + } + + result := converter.ToGQL(&in) + + assert.Equal(t, &expected, result) + }) + + t.Run("Parameters not provided", func(t *testing.T) { + converter := instanceConverter{} + + in := v1beta1.ServiceInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "exampleName", + Namespace: "Environment", + UID: types.UID("uid"), + CreationTimestamp: mockTimeStamp, + }, + Spec: v1beta1.ServiceInstanceSpec{ + ClusterServiceClassRef: &v1beta1.ClusterObjectReference{ + Name: "testClass", + }, + ClusterServicePlanRef: &v1beta1.ClusterObjectReference{ + Name: "testPlan", + }, + }, + Status: v1beta1.ServiceInstanceStatus{ + AsyncOpInProgress: false, + Conditions: []v1beta1.ServiceInstanceCondition{ + { + Type: v1beta1.ServiceInstanceConditionReady, + Status: v1beta1.ConditionTrue, + Message: "Working", + Reason: "Testing", + }, + }, + }, + } + + testClassName := "testClass" + testPlanName := "testPlan" + expected := gqlschema.ServiceInstance{ + Name: "exampleName", + Environment: "Environment", + ServiceClassName: &testClassName, + ServicePlanName: &testPlanName, + Labels: []string{}, + CreationTimestamp: zeroTimeStamp, + Status: gqlschema.ServiceInstanceStatus{ + Type: gqlschema.InstanceStatusTypeRunning, + Reason: "Testing", + Message: "Working", + }, + } + + result := converter.ToGQL(&in) + + assert.Equal(t, &expected, result) + }) + + t.Run("Empty", func(t *testing.T) { + converter := &instanceConverter{} + converter.ToGQL(&v1beta1.ServiceInstance{}) + }) + + t.Run("Empty properties", func(t *testing.T) { + converter := &instanceConverter{} + converter.ToGQL(&v1beta1.ServiceInstance{ + Status: v1beta1.ServiceInstanceStatus{ + InProgressProperties: &v1beta1.ServiceInstancePropertiesState{}, + ExternalProperties: &v1beta1.ServiceInstancePropertiesState{}, + }, + }) + }) + + t.Run("Nil", func(t *testing.T) { + converter := &instanceConverter{} + item := converter.ToGQL(nil) + assert.Nil(t, item) + }) +} + +func TestInstanceConverter_GQLCreateInputToInstanceCreateParameters(t *testing.T) { + t.Run("Success", func(t *testing.T) { + JSON := gqlschema.JSON{ + "key1": "val1", + "key2": "val2", + } + input := gqlschema.ServiceInstanceCreateInput{ + Name: "name", + Environment: "environment", + Labels: []string{"test", "label"}, + ParameterSchema: &JSON, + ExternalServiceClassName: "className", + ExternalPlanName: "planName", + } + expected := &instanceCreateParameters{ + Name: "name", + Namespace: "environment", + Labels: []string{"test", "label"}, + Schema: JSON, + ExternalServiceClassName: "className", + ExternalServicePlanName: "planName", + } + converter := instanceConverter{} + + result := converter.GQLCreateInputToInstanceCreateParameters(&input) + + assert.Equal(t, expected, result) + }) + + t.Run("Empty", func(t *testing.T) { + converter := instanceConverter{} + result := converter.GQLCreateInputToInstanceCreateParameters(&gqlschema.ServiceInstanceCreateInput{}) + + assert.Empty(t, result) + }) + + t.Run("Nil", func(t *testing.T) { + converter := instanceConverter{} + result := converter.GQLCreateInputToInstanceCreateParameters(nil) + + assert.Nil(t, result) + }) +} + +func TestInstanceConverter_ToGQLs(t *testing.T) { + t.Run("Success", func(t *testing.T) { + instances := []*v1beta1.ServiceInstance{ + fixServiceInstance(t), + fixServiceInstance(t), + } + + converter := instanceConverter{} + result := converter.ToGQLs(instances) + + assert.Len(t, result, 2) + assert.Equal(t, "exampleName", result[0].Name) + }) + + t.Run("Empty", func(t *testing.T) { + var instances []*v1beta1.ServiceInstance + + converter := instanceConverter{} + result := converter.ToGQLs(instances) + + assert.Empty(t, result) + }) + + t.Run("With nil", func(t *testing.T) { + instances := []*v1beta1.ServiceInstance{ + nil, + fixServiceInstance(t), + nil, + } + + converter := instanceConverter{} + result := converter.ToGQLs(instances) + + assert.Len(t, result, 1) + assert.Equal(t, "exampleName", result[0].Name) + }) +} + +func TestInstanceConverter_ServiceStatusToGQLStatus(t *testing.T) { + t.Run("Success", func(t *testing.T) { + s := status.ServiceInstanceStatus{ + Type: status.ServiceInstanceStatusTypeRunning, + Reason: "Testing", + Message: "Working", + } + expected := gqlschema.ServiceInstanceStatus{ + Type: gqlschema.InstanceStatusTypeRunning, + Reason: "Testing", + Message: "Working", + } + + converter := instanceConverter{} + result := converter.ServiceStatusToGQLStatus(&s) + + assert.Equal(t, &expected, result) + }) +} + +func TestInstanceConverter_GQLStatusToServiceStatus(t *testing.T) { + t.Run("Success", func(t *testing.T) { + s := gqlschema.ServiceInstanceStatus{ + Type: gqlschema.InstanceStatusTypeRunning, + Reason: "Testing", + Message: "Working", + } + expected := status.ServiceInstanceStatus{ + Type: status.ServiceInstanceStatusTypeRunning, + Reason: "Testing", + Message: "Working", + } + + converter := instanceConverter{} + result := converter.GQLStatusToServiceStatus(&s) + + assert.Equal(t, &expected, result) + }) +} + +func TestInstanceConverter_ServiceStatusToGQLStatusWithConvert(t *testing.T) { + t.Run("Success", func(t *testing.T) { + instance := v1beta1.ServiceInstance{ + Status: v1beta1.ServiceInstanceStatus{ + AsyncOpInProgress: false, + Conditions: []v1beta1.ServiceInstanceCondition{ + { + Type: v1beta1.ServiceInstanceConditionReady, + Status: v1beta1.ConditionTrue, + Message: "Working", + Reason: "Testing", + }, + }, + }, + } + expected := gqlschema.ServiceInstanceStatus{ + Type: gqlschema.InstanceStatusTypeRunning, + Reason: "Testing", + Message: "Working", + } + + converter := instanceConverter{} + result := converter.ServiceStatusToGQLStatus(converter.extractor.Status(&instance)) + + assert.Equal(t, &expected, result) + }) +} + +func fixServiceInstance(t require.TestingT) *v1beta1.ServiceInstance { + var mockTimeStamp metav1.Time + rawMap := map[string]interface{}{ + "labels": []string{"test1", "test2"}, + } + raw, err := json.Marshal(rawMap) + require.NoError(t, err) + + return &v1beta1.ServiceInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "exampleName", + Namespace: "Environment", + UID: types.UID("uid"), + CreationTimestamp: mockTimeStamp, + }, + Spec: v1beta1.ServiceInstanceSpec{ + ClusterServiceClassRef: &v1beta1.ClusterObjectReference{ + Name: "testClass", + }, + ClusterServicePlanRef: &v1beta1.ClusterObjectReference{ + Name: "testPlan", + }, + Parameters: &runtime.RawExtension{Raw: raw}, + }, + Status: v1beta1.ServiceInstanceStatus{ + AsyncOpInProgress: false, + Conditions: []v1beta1.ServiceInstanceCondition{ + { + Type: v1beta1.ServiceInstanceConditionReady, + Status: v1beta1.ConditionTrue, + Message: "Working", + Reason: "Testing", + }, + }, + }, + } +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/instance_listener.go b/components/ui-api-layer/internal/domain/servicecatalog/instance_listener.go new file mode 100644 index 000000000000..d73bc5cd0bc0 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/instance_listener.go @@ -0,0 +1,59 @@ +package servicecatalog + +import ( + "fmt" + + "github.com/golang/glog" + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +) + +// TODO: Move to subpackage + +type instanceListener struct { + channel chan<- gqlschema.ServiceInstanceEvent + filter func(object interface{}) bool + instanceConverter gqlInstanceConverter +} + +func newInstanceListener(channel chan<- gqlschema.ServiceInstanceEvent, filter func(object interface{}) bool, instanceConverter gqlInstanceConverter) *instanceListener { + return &instanceListener{ + channel: channel, + filter: filter, + instanceConverter: instanceConverter, + } +} + +func (il *instanceListener) OnAdd(object interface{}) { + if il.filter(object) { + il.notify(gqlschema.ServiceInstanceEventTypeAdd, object) + } +} + +func (il *instanceListener) OnUpdate(oldObject, newObject interface{}) { + if il.filter(newObject) { + il.notify(gqlschema.ServiceInstanceEventTypeUpdate, newObject) + } +} + +func (il *instanceListener) OnDelete(object interface{}) { + if il.filter(object) { + il.notify(gqlschema.ServiceInstanceEventTypeDelete, object) + } +} + +func (il *instanceListener) notify(eventType gqlschema.ServiceInstanceEventType, object interface{}) { + instance, ok := object.(*v1beta1.ServiceInstance) + if !ok { + glog.Error(fmt.Errorf("Incorrect object type: %T, should be: *ServiceInstance", object)) + return + } + + gqlInstance := il.instanceConverter.ToGQL(instance) + event := gqlschema.ServiceInstanceEvent{ + Type: eventType, + Instance: *gqlInstance, + } + + il.channel <- event +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/instance_listener_test.go b/components/ui-api-layer/internal/domain/servicecatalog/instance_listener_test.go new file mode 100644 index 000000000000..8c1e923ad61d --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/instance_listener_test.go @@ -0,0 +1,127 @@ +package servicecatalog_test + +import ( + "testing" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" +) + +func TestInstanceListener_OnAdd(t *testing.T) { + t.Run("Success", func(t *testing.T) { + channel := make(chan gqlschema.ServiceInstanceEvent, 1) + defer close(channel) + gqlInstance := new(gqlschema.ServiceInstance) + instance := new(v1beta1.ServiceInstance) + converter := servicecatalog.NewMockInstanceConverter() + converter.On("ToGQL", instance).Return(gqlInstance, nil).Once() + listener := servicecatalog.NewInstanceListener(channel, filterTrue, converter) + + listener.OnAdd(instance) + result := <-channel + + assert.Equal(t, gqlschema.ServiceInstanceEventTypeAdd, result.Type) + assert.Equal(t, *gqlInstance, result.Instance) + + }) + + t.Run("Filtered out", func(t *testing.T) { + listener := servicecatalog.NewInstanceListener(nil, filterFalse, nil) + + listener.OnAdd(new(v1beta1.ServiceInstance)) + }) + + t.Run("Nil", func(t *testing.T) { + listener := servicecatalog.NewInstanceListener(nil, filterTrue, nil) + + listener.OnAdd(nil) + }) + + t.Run("Invalid type", func(t *testing.T) { + listener := servicecatalog.NewInstanceListener(nil, filterTrue, nil) + + listener.OnAdd(new(struct{})) + }) +} + +func TestInstanceListener_OnDelete(t *testing.T) { + t.Run("Success", func(t *testing.T) { + channel := make(chan gqlschema.ServiceInstanceEvent, 1) + defer close(channel) + gqlInstance := new(gqlschema.ServiceInstance) + instance := new(v1beta1.ServiceInstance) + converter := servicecatalog.NewMockInstanceConverter() + converter.On("ToGQL", instance).Return(gqlInstance, nil).Once() + listener := servicecatalog.NewInstanceListener(channel, filterTrue, converter) + + listener.OnDelete(instance) + result := <-channel + + assert.Equal(t, gqlschema.ServiceInstanceEventTypeDelete, result.Type) + assert.Equal(t, *gqlInstance, result.Instance) + }) + + t.Run("Filtered out", func(t *testing.T) { + listener := servicecatalog.NewInstanceListener(nil, filterFalse, nil) + + listener.OnDelete(new(v1beta1.ServiceInstance)) + }) + + t.Run("Nil", func(t *testing.T) { + listener := servicecatalog.NewInstanceListener(nil, filterTrue, nil) + + listener.OnDelete(nil) + }) + + t.Run("Invalid type", func(t *testing.T) { + listener := servicecatalog.NewInstanceListener(nil, filterTrue, nil) + + listener.OnDelete(new(struct{})) + }) +} + +func TestInstanceListener_OnUpdate(t *testing.T) { + t.Run("Success", func(t *testing.T) { + channel := make(chan gqlschema.ServiceInstanceEvent, 1) + defer close(channel) + gqlInstance := new(gqlschema.ServiceInstance) + instance := new(v1beta1.ServiceInstance) + converter := servicecatalog.NewMockInstanceConverter() + converter.On("ToGQL", instance).Return(gqlInstance, nil).Once() + listener := servicecatalog.NewInstanceListener(channel, filterTrue, converter) + + listener.OnUpdate(instance, instance) + result := <-channel + + assert.Equal(t, gqlschema.ServiceInstanceEventTypeUpdate, result.Type) + assert.Equal(t, *gqlInstance, result.Instance) + }) + + t.Run("Filtered out", func(t *testing.T) { + listener := servicecatalog.NewInstanceListener(nil, filterFalse, nil) + + listener.OnUpdate(new(v1beta1.ServiceInstance), new(v1beta1.ServiceInstance)) + }) + + t.Run("Nil", func(t *testing.T) { + listener := servicecatalog.NewInstanceListener(nil, filterTrue, nil) + + listener.OnUpdate(nil, nil) + }) + + t.Run("Invalid type", func(t *testing.T) { + listener := servicecatalog.NewInstanceListener(nil, filterTrue, nil) + + listener.OnUpdate(new(struct{}), new(struct{})) + }) +} + +func filterTrue(object interface{}) bool { + return true +} + +func filterFalse(object interface{}) bool { + return false +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/instance_resolver.go b/components/ui-api-layer/internal/domain/servicecatalog/instance_resolver.go new file mode 100644 index 000000000000..55a5c48ac4c8 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/instance_resolver.go @@ -0,0 +1,248 @@ +package servicecatalog + +import ( + "context" + "fmt" + + "github.com/golang/glog" + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + "github.com/pkg/errors" +) + +type instanceResolver struct { + instanceSvc instanceSvc + planGetter planGetter + classGetter classGetter + instanceConverter gqlInstanceConverter + classConverter gqlClassConverter + planConverter gqlPlanConverter +} + +func newInstanceResolver(instanceSvc instanceSvc, planGetter planGetter, classGetter classGetter) *instanceResolver { + return &instanceResolver{ + instanceSvc: instanceSvc, + planGetter: planGetter, + classGetter: classGetter, + instanceConverter: &instanceConverter{}, + classConverter: &classConverter{}, + planConverter: &planConverter{}, + } +} + +func (r *instanceResolver) CreateServiceInstanceMutation(ctx context.Context, params gqlschema.ServiceInstanceCreateInput) (*gqlschema.ServiceInstance, error) { + externalErr := fmt.Errorf("Cannot create instance `%s` in environment `%s`", params.Name, params.Environment) + + parameters := r.instanceConverter.GQLCreateInputToInstanceCreateParameters(¶ms) + item, err := r.instanceSvc.Create(*parameters) + if err != nil { + glog.Error(errors.Wrapf(err, "while creating ServiceInstance `%s` in environment `%s`", params.Name, params.Environment)) + return nil, externalErr + } + + // ServicePlan and ServiceClass references are empty just after the resource has been created + // Adding these references manually, because they are needed to resolve all Service Instance fields + serviceClass, err := r.classGetter.FindByExternalName(parameters.ExternalServiceClassName) + if err != nil || serviceClass == nil { + glog.Error(errors.Wrapf(err, "while getting ServiceClass for externalName `%s`", parameters.ExternalServiceClassName)) + return nil, externalErr + } + + servicePlan, err := r.planGetter.FindByExternalNameForClass(parameters.ExternalServicePlanName, serviceClass.Name) + if err != nil || servicePlan == nil { + glog.Error(errors.Wrapf(err, "while getting ServicePlan for externalName `%s`", parameters.ExternalServicePlanName)) + return nil, externalErr + } + + instanceCopy := item.DeepCopy() + instanceCopy.Spec.ClusterServicePlanRef = &v1beta1.ClusterObjectReference{ + Name: servicePlan.Name, + } + instanceCopy.Spec.ClusterServiceClassRef = &v1beta1.ClusterObjectReference{ + Name: serviceClass.Name, + } + + instance := r.instanceConverter.ToGQL(instanceCopy) + return instance, nil +} + +func (r *instanceResolver) DeleteServiceInstanceMutation(ctx context.Context, name, environment string) (*gqlschema.ServiceInstance, error) { + externalErr := fmt.Errorf("Cannot delete instance `%s` in environment `%s`", name, environment) + + instance, err := r.instanceSvc.Find(name, environment) + if err != nil { + glog.Error(errors.Wrapf(err, "while finding ServiceInstance `%s` in environment `%s`", name, environment)) + return nil, externalErr + } + + if instance == nil { + return nil, fmt.Errorf("Cannot find instance `%s` in environment `%s`", name, environment) + } + + instanceCopy := instance.DeepCopy() + err = r.instanceSvc.Delete(name, environment) + if err != nil { + glog.Error(errors.Wrapf(err, "while deleting ServiceInstance `%s` from environment `%s`", name, environment)) + return nil, externalErr + } + + deletedInstance := r.instanceConverter.ToGQL(instanceCopy) + + return deletedInstance, nil +} + +func (r *instanceResolver) ServiceInstanceQuery(ctx context.Context, name string, environment string) (*gqlschema.ServiceInstance, error) { + externalErr := fmt.Errorf("Cannot query instance with name `%s` in environment `%s`", name, environment) + + serviceInstance, err := r.instanceSvc.Find(name, environment) + if err != nil { + glog.Error(errors.Wrapf(err, "while getting ServiceInstance `%s` from environment `%s`", name, environment)) + return nil, externalErr + } + if serviceInstance == nil { + return nil, nil + } + + result := r.instanceConverter.ToGQL(serviceInstance) + + return result, nil +} + +func (r *instanceResolver) ServiceInstancesQuery(ctx context.Context, environment string, first *int, offset *int, status *gqlschema.InstanceStatusType) ([]gqlschema.ServiceInstance, error) { + externalErr := fmt.Errorf("Cannot query instances in environment `%s`", environment) + + var items []*v1beta1.ServiceInstance + var err error + + if status != nil { + statusType := r.instanceConverter.GQLStatusTypeToServiceStatusType(*status) + items, err = r.instanceSvc.ListForStatus(environment, pager.PagingParams{ + First: first, + Offset: offset, + }, &statusType) + } else { + items, err = r.instanceSvc.List(environment, pager.PagingParams{ + First: first, + Offset: offset, + }) + } + + if err != nil { + glog.Error(errors.Wrapf(err, "while listing ServiceInstances for environment %s", environment)) + return nil, externalErr + } + + serviceInstances := r.instanceConverter.ToGQLs(items) + + return serviceInstances, nil +} + +func (r *instanceResolver) ServiceInstanceEventSubscription(ctx context.Context, environment string) (<-chan gqlschema.ServiceInstanceEvent, error) { + channel := make(chan gqlschema.ServiceInstanceEvent, 1) + filter := func(object interface{}) bool { + instance, ok := object.(*v1beta1.ServiceInstance) + if !ok { + return false + } + + return instance.Namespace == environment + } + + listener := newInstanceListener(channel, filter, r.instanceConverter) + + r.instanceSvc.Subscribe(listener) + go func() { + defer close(channel) + defer r.instanceSvc.Unsubscribe(listener) + <-ctx.Done() + }() + + return channel, nil +} + +func (r *instanceResolver) ServiceInstanceServicePlanField(ctx context.Context, obj *gqlschema.ServiceInstance) (*gqlschema.ServicePlan, error) { + errMessage := "Cannot query ServicePlan for instance" + + if obj == nil { + glog.Error(errors.New("ServiceInstance cannot be empty in order to resolve ServicePlan for instance")) + return nil, errors.New(errMessage) + } + + if obj.ServicePlanName == nil { + return nil, nil + } + + externalErr := fmt.Errorf("%s `%s` in environment `%s`", errMessage, obj.Name, obj.Environment) + + item, err := r.planGetter.Find(*obj.ServicePlanName) + if err != nil { + glog.Error(errors.Wrapf(err, "while getting ServicePlan for instance `%s` in environment `%s`", obj.Name, obj.Environment)) + return nil, externalErr + } + if item == nil { + return nil, nil + } + + plan, err := r.planConverter.ToGQL(item) + if err != nil { + glog.Error(errors.Wrapf(err, "while converting plan %s to ServicePlan type", plan.Name)) + return nil, externalErr + } + + return plan, nil +} + +func (r *instanceResolver) ServiceInstanceServiceClassField(ctx context.Context, obj *gqlschema.ServiceInstance) (*gqlschema.ServiceClass, error) { + errMessage := "Cannot query ServiceClass for instance" + + if obj == nil || obj.ServiceClassName == nil { + glog.Error(errors.New("ServiceClassName cannot be empty in order to resolve ServiceClass for instance")) + return nil, errors.New(errMessage) + } + + externalErr := fmt.Errorf("%s `%s` in environment `%s`", errMessage, obj.Name, obj.Environment) + + serviceClass, err := r.classGetter.Find(*obj.ServiceClassName) + if err != nil { + glog.Error(errors.Wrapf(err, "while getting ServiceClass for instance %s in environment `%s`", obj.Name, obj.Environment)) + return nil, externalErr + } + if serviceClass == nil { + return nil, nil + } + + result, err := r.classConverter.ToGQL(serviceClass) + if err != nil { + glog.Error(errors.Wrapf(err, "while converting class %s to ServiceClass type", serviceClass.Name)) + return nil, externalErr + } + + return result, nil +} + +func (r *instanceResolver) ServiceInstanceBindableField(ctx context.Context, obj *gqlschema.ServiceInstance) (bool, error) { + errMessage := "Cannot query `bindable` field for instance" + + // FIXME: It returns error for Create mutation, because ServiceClassName and ServicePlanName are empty + if obj == nil || obj.ServiceClassName == nil || obj.ServicePlanName == nil { + glog.Error(errors.New("ServiceClass or ServiceClassName or ServicePlanName cannot be empty in order to resolve ServiceClass for instance")) + return false, errors.New(errMessage) + } + + externalErr := fmt.Errorf("%s `%s` in environment `%s`", errMessage, obj.Name, obj.Environment) + + serviceClass, err := r.classGetter.Find(*obj.ServiceClassName) + if err != nil { + glog.Error(errors.Wrapf(err, "while getting ServiceClass for instance %s in environment `%s` in order to resolve `bindable` field", obj.Name, obj.Environment)) + return false, externalErr + } + + servicePlan, err := r.planGetter.Find(*obj.ServicePlanName) + if err != nil { + glog.Error(errors.Wrapf(err, "while getting ServicePlan for instance %s in environment `%s` in order to resolve `bindable` field", obj.Name, obj.Environment)) + return false, externalErr + } + + return r.instanceSvc.IsBindable(serviceClass, servicePlan), nil +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/instance_resolver_test.go b/components/ui-api-layer/internal/domain/servicecatalog/instance_resolver_test.go new file mode 100644 index 000000000000..b375a176a3de --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/instance_resolver_test.go @@ -0,0 +1,483 @@ +package servicecatalog_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog/automock" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog/status" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TODO: Create test suite to reduce boilerplate +func TestInstanceResolver_ServiceInstanceQuery(t *testing.T) { + t.Run("Success", func(t *testing.T) { + expected := &gqlschema.ServiceInstance{ + Name: "Test", + } + name := "name" + environment := "environment" + resource := &v1beta1.ServiceInstance{} + resourceGetter := servicecatalog.NewMockInstanceService() + resourceGetter.On("Find", name, environment).Return(resource, nil).Once() + defer resourceGetter.AssertExpectations(t) + + converter := servicecatalog.NewMockInstanceConverter() + converter.On("ToGQL", resource).Return(expected).Once() + defer converter.AssertExpectations(t) + + resolver := servicecatalog.NewInstanceResolver(resourceGetter, nil, nil) + resolver.SetInstanceConverter(converter) + + result, err := resolver.ServiceInstanceQuery(nil, name, environment) + + require.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("NotFound", func(t *testing.T) { + name := "name" + environment := "environment" + resourceGetter := servicecatalog.NewMockInstanceService() + resourceGetter.On("Find", name, environment).Return(nil, nil).Once() + defer resourceGetter.AssertExpectations(t) + + resolver := servicecatalog.NewInstanceResolver(resourceGetter, nil, nil) + + result, err := resolver.ServiceInstanceQuery(nil, name, environment) + + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("Error", func(t *testing.T) { + expected := errors.New("Test") + name := "name" + environment := "environment" + resource := &v1beta1.ServiceInstance{} + resourceGetter := servicecatalog.NewMockInstanceService() + resourceGetter.On("Find", name, environment).Return(resource, expected).Once() + defer resourceGetter.AssertExpectations(t) + resolver := servicecatalog.NewInstanceResolver(resourceGetter, nil, nil) + + result, err := resolver.ServiceInstanceQuery(nil, name, environment) + + assert.Error(t, err) + assert.Nil(t, result) + }) +} + +func TestInstanceResolver_ServiceInstancesQuery(t *testing.T) { + t.Run("Success", func(t *testing.T) { + environment := "environment" + resource := + &v1beta1.ServiceInstance{ + ObjectMeta: v1.ObjectMeta{ + Name: "Test", + }, + } + resources := []*v1beta1.ServiceInstance{ + resource, resource, + } + expected := []gqlschema.ServiceInstance{ + { + Name: "Test", + }, + { + Name: "Test", + }, + } + + resourceGetter := servicecatalog.NewMockInstanceService() + resourceGetter.On("List", environment, pager.PagingParams{}).Return(resources, nil).Once() + defer resourceGetter.AssertExpectations(t) + + converter := servicecatalog.NewMockInstanceConverter() + converter.On("ToGQLs", resources).Return(expected) + defer converter.AssertExpectations(t) + + resolver := servicecatalog.NewInstanceResolver(resourceGetter, nil, nil) + resolver.SetInstanceConverter(converter) + + result, err := resolver.ServiceInstancesQuery(nil, environment, nil, nil, nil) + + require.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("NotFound", func(t *testing.T) { + environment := "environment" + var resources []*v1beta1.ServiceInstance + + resourceGetter := servicecatalog.NewMockInstanceService() + resourceGetter.On("List", environment, pager.PagingParams{}).Return(resources, nil).Once() + defer resourceGetter.AssertExpectations(t) + resolver := servicecatalog.NewInstanceResolver(resourceGetter, nil, nil) + var expected []gqlschema.ServiceInstance + + result, err := resolver.ServiceInstancesQuery(nil, environment, nil, nil, nil) + + require.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("Error", func(t *testing.T) { + expected := errors.New("Test") + + environment := "environment" + var resources []*v1beta1.ServiceInstance + + resourceGetter := servicecatalog.NewMockInstanceService() + resourceGetter.On("List", environment, pager.PagingParams{}).Return(resources, expected).Once() + defer resourceGetter.AssertExpectations(t) + resolver := servicecatalog.NewInstanceResolver(resourceGetter, nil, nil) + + _, err := resolver.ServiceInstancesQuery(nil, environment, nil, nil, nil) + + require.Error(t, err) + }) +} + +func TestInstanceResolver_ServiceInstancesWithStatusQuery(t *testing.T) { + t.Run("Success", func(t *testing.T) { + environment := "environment" + resource := + &v1beta1.ServiceInstance{ + ObjectMeta: v1.ObjectMeta{ + Name: "Test", + }, + Status: v1beta1.ServiceInstanceStatus{ + AsyncOpInProgress: false, + Conditions: []v1beta1.ServiceInstanceCondition{ + { + Type: v1beta1.ServiceInstanceConditionReady, + Status: v1beta1.ConditionTrue, + Message: "Working", + Reason: "Testing", + }, + }, + }, + } + resources := []*v1beta1.ServiceInstance{ + resource, resource, + } + expected := []gqlschema.ServiceInstance{ + { + Name: "Test", + Status: gqlschema.ServiceInstanceStatus{ + Type: gqlschema.InstanceStatusTypeRunning, + Message: "Working", + Reason: "Testing", + }, + }, + { + Name: "Test", + Status: gqlschema.ServiceInstanceStatus{ + Type: gqlschema.InstanceStatusTypeRunning, + Message: "Working", + Reason: "Testing", + }, + }, + } + + statusType := status.ServiceInstanceStatusTypeRunning + gqlStatusType := gqlschema.InstanceStatusTypeRunning + + resourceGetter := servicecatalog.NewMockInstanceService() + resourceGetter.On("ListForStatus", environment, pager.PagingParams{}, &statusType).Return(resources, nil).Once() + defer resourceGetter.AssertExpectations(t) + + converter := servicecatalog.NewMockInstanceConverter() + converter.On("ToGQLs", resources).Return(expected) + converter.On("GQLStatusTypeToServiceStatusType", gqlStatusType).Return(statusType) + defer converter.AssertExpectations(t) + + resolver := servicecatalog.NewInstanceResolver(resourceGetter, nil, nil) + resolver.SetInstanceConverter(converter) + + result, err := resolver.ServiceInstancesQuery(nil, environment, nil, nil, &gqlStatusType) + + require.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("NotFound", func(t *testing.T) { + environment := "environment" + statusType := status.ServiceInstanceStatusTypeRunning + gqlStatusType := gqlschema.InstanceStatusTypeRunning + var resources []*v1beta1.ServiceInstance + + resourceGetter := servicecatalog.NewMockInstanceService() + resourceGetter.On("ListForStatus", environment, pager.PagingParams{}, &statusType).Return(resources, nil).Once() + defer resourceGetter.AssertExpectations(t) + resolver := servicecatalog.NewInstanceResolver(resourceGetter, nil, nil) + var expected []gqlschema.ServiceInstance + + result, err := resolver.ServiceInstancesQuery(nil, environment, nil, nil, &gqlStatusType) + + require.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("Error", func(t *testing.T) { + expected := errors.New("Test") + + environment := "environment" + statusType := status.ServiceInstanceStatusTypeRunning + gqlStatusType := gqlschema.InstanceStatusTypeRunning + var resources []*v1beta1.ServiceInstance + + resourceGetter := servicecatalog.NewMockInstanceService() + resourceGetter.On("ListForStatus", environment, pager.PagingParams{}, &statusType).Return(resources, expected).Once() + defer resourceGetter.AssertExpectations(t) + resolver := servicecatalog.NewInstanceResolver(resourceGetter, nil, nil) + + _, err := resolver.ServiceInstancesQuery(nil, environment, nil, nil, &gqlStatusType) + + require.Error(t, err) + }) +} + +func TestInstanceResolver_ServiceInstanceServicePlanField(t *testing.T) { + t.Run("Success", func(t *testing.T) { + expected := &gqlschema.ServicePlan{ + Name: "Test", + } + name := "name" + resource := &v1beta1.ClusterServicePlan{} + resourceGetter := automock.NewPlanGetter() + resourceGetter.On("Find", name).Return(resource, nil).Once() + defer resourceGetter.AssertExpectations(t) + + converter := automock.NewGQLPlanConverter() + converter.On("ToGQL", resource).Return(expected, nil).Once() + defer converter.AssertExpectations(t) + + parentObj := gqlschema.ServiceInstance{ + Name: "test", + ServicePlanName: &name, + } + + resolver := servicecatalog.NewInstanceResolver(nil, resourceGetter, nil) + resolver.SetPlanConverter(converter) + + result, err := resolver.ServiceInstanceServicePlanField(nil, &parentObj) + + require.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("NotFound", func(t *testing.T) { + name := "name" + resourceGetter := automock.NewPlanGetter() + resourceGetter.On("Find", name).Return(nil, nil).Once() + defer resourceGetter.AssertExpectations(t) + + parentObj := gqlschema.ServiceInstance{ + Name: "test", + ServicePlanName: &name, + } + + resolver := servicecatalog.NewInstanceResolver(nil, resourceGetter, nil) + + result, err := resolver.ServiceInstanceServicePlanField(nil, &parentObj) + + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("ServicePlanName not provided", func(t *testing.T) { + parentObj := gqlschema.ServiceInstance{ + Name: "test", + ServicePlanName: nil, + } + + resolver := servicecatalog.NewInstanceResolver(nil, nil, nil) + + result, err := resolver.ServiceInstanceServicePlanField(nil, &parentObj) + + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("Error", func(t *testing.T) { + expectedErr := errors.New("Test") + name := "name" + resourceGetter := automock.NewPlanGetter() + resourceGetter.On("Find", name).Return(nil, expectedErr).Once() + defer resourceGetter.AssertExpectations(t) + + parentObj := gqlschema.ServiceInstance{ + Name: "test", + ServicePlanName: &name, + } + + resolver := servicecatalog.NewInstanceResolver(nil, resourceGetter, nil) + + result, err := resolver.ServiceInstanceServicePlanField(nil, &parentObj) + + assert.Error(t, err) + assert.Nil(t, result) + }) +} + +func TestInstanceResolver_ServiceInstanceServiceClassField(t *testing.T) { + t.Run("Success", func(t *testing.T) { + expected := &gqlschema.ServiceClass{ + Name: "Test", + } + name := "name" + resource := &v1beta1.ClusterServiceClass{} + resourceGetter := automock.NewClassGetter() + resourceGetter.On("Find", name).Return(resource, nil).Once() + defer resourceGetter.AssertExpectations(t) + + converter := automock.NewGQLClassConverter() + converter.On("ToGQL", resource).Return(expected, nil).Once() + defer converter.AssertExpectations(t) + + parentObj := gqlschema.ServiceInstance{ + Name: "test", + ServiceClassName: &name, + } + + resolver := servicecatalog.NewInstanceResolver(nil, nil, resourceGetter) + resolver.SetClassConverter(converter) + + result, err := resolver.ServiceInstanceServiceClassField(nil, &parentObj) + + require.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("NotFound", func(t *testing.T) { + name := "name" + resourceGetter := automock.NewClassGetter() + resourceGetter.On("Find", name).Return(nil, nil).Once() + defer resourceGetter.AssertExpectations(t) + + parentObj := &gqlschema.ServiceInstance{ + Name: "test", + ServiceClassName: &name, + } + + resolver := servicecatalog.NewInstanceResolver(nil, nil, resourceGetter) + + result, err := resolver.ServiceInstanceServiceClassField(nil, parentObj) + + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("Error", func(t *testing.T) { + expectedErr := errors.New("Test") + name := "name" + resourceGetter := automock.NewClassGetter() + resourceGetter.On("Find", name).Return(nil, expectedErr).Once() + defer resourceGetter.AssertExpectations(t) + + parentObj := gqlschema.ServiceInstance{ + Name: "test", + ServiceClassName: &name, + } + + resolver := servicecatalog.NewInstanceResolver(nil, nil, resourceGetter) + + result, err := resolver.ServiceInstanceServiceClassField(nil, &parentObj) + + assert.Error(t, err) + assert.Nil(t, result) + }) +} + +func TestInstanceResolver_ServiceInstanceBindableField(t *testing.T) { + testErr := errors.New("Test") + + for _, tc := range []struct { + // Input + planErr error + classErr error + instanceBindable bool + + // Expected result + expectedResult bool + shouldReturnError bool + }{ + {planErr: nil, classErr: nil, instanceBindable: true, expectedResult: true, shouldReturnError: false}, + {planErr: nil, classErr: nil, instanceBindable: false, expectedResult: false, shouldReturnError: false}, + {planErr: testErr, classErr: nil, instanceBindable: false, expectedResult: false, shouldReturnError: true}, + {planErr: nil, classErr: testErr, instanceBindable: false, expectedResult: false, shouldReturnError: true}, + } { + className := "className" + planName := "planName" + class := &v1beta1.ClusterServiceClass{} + plan := &v1beta1.ClusterServicePlan{} + + planGetter := automock.NewPlanGetter() + planGetter.On("Find", planName).Return(plan, tc.planErr).Once() + classGetter := automock.NewClassGetter() + classGetter.On("Find", className).Return(class, tc.classErr).Once() + instanceSvc := servicecatalog.NewMockInstanceService() + instanceSvc.On("IsBindable", class, plan).Return(tc.instanceBindable).Once() + + resolver := servicecatalog.NewInstanceResolver(instanceSvc, planGetter, classGetter) + + parentObj := &gqlschema.ServiceInstance{ + Name: "test", + ServiceClassName: &className, + ServicePlanName: &planName, + } + + result, err := resolver.ServiceInstanceBindableField(nil, parentObj) + + if tc.shouldReturnError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tc.expectedResult, result) + } + +} + +func TestInstanceResolver_ServiceInstanceEventSubscription(t *testing.T) { + t.Run("Success", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), (-24 * time.Hour)) + cancel() + + svc := servicecatalog.NewMockInstanceService() + svc.On("Subscribe", mock.Anything).Once() + svc.On("Unsubscribe", mock.Anything).Once() + resolver := servicecatalog.NewInstanceResolver(svc, nil, nil) + + _, err := resolver.ServiceInstanceEventSubscription(ctx, "test") + + require.NoError(t, err) + svc.AssertCalled(t, "Subscribe", mock.Anything) + }) + + t.Run("Unsubscribe after connection close", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), (-24 * time.Hour)) + cancel() + + svc := servicecatalog.NewMockInstanceService() + svc.On("Subscribe", mock.Anything).Once() + svc.On("Unsubscribe", mock.Anything).Once() + resolver := servicecatalog.NewInstanceResolver(svc, nil, nil) + + channel, err := resolver.ServiceInstanceEventSubscription(ctx, "test") + <-channel + + require.NoError(t, err) + svc.AssertCalled(t, "Unsubscribe", mock.Anything) + }) +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/instance_service.go b/components/ui-api-layer/internal/domain/servicecatalog/instance_service.go new file mode 100644 index 000000000000..492c1390c992 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/instance_service.go @@ -0,0 +1,253 @@ +package servicecatalog + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kubernetes-incubator/service-catalog/pkg/client/clientset_generated/clientset" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog/status" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + "github.com/kyma-project/kyma/components/ui-api-layer/pkg/jsoncopy" + "github.com/kyma-project/kyma/components/ui-api-layer/pkg/resource" + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/cache" +) + +type instanceService struct { + informer cache.SharedIndexInformer + client clientset.Interface + notifier notifier + instanceExt status.InstanceExtractor +} + +func newInstanceService(informer cache.SharedIndexInformer, client clientset.Interface) *instanceService { + instanceExt := status.InstanceExtractor{} + informer.AddIndexers(cache.Indexers{ + "relatedExternalClassName": func(obj interface{}) ([]string, error) { + serviceInstance, ok := obj.(*v1beta1.ServiceInstance) + if !ok { + return nil, fmt.Errorf("Cannot convert item") + } + + return []string{serviceInstance.Spec.PlanReference.ClusterServiceClassExternalName}, nil + }, + "relatedClassName": func(obj interface{}) ([]string, error) { + serviceInstance, ok := obj.(*v1beta1.ServiceInstance) + if !ok { + return nil, fmt.Errorf("Cannot convert item") + } + return []string{serviceInstance.Spec.PlanReference.ClusterServiceClassName}, nil + }, + "statusType": func(obj interface{}) ([]string, error) { + serviceInstance, ok := obj.(*v1beta1.ServiceInstance) + if !ok { + return nil, fmt.Errorf("Cannot convert item") + } + + key := fmt.Sprintf("%s/%s", serviceInstance.ObjectMeta.Namespace, instanceExt.Status(serviceInstance).Type) + return []string{key}, nil + }, + }) + + notifier := resource.NewNotifier() + informer.AddEventHandler(notifier) + + return &instanceService{ + informer: informer, + client: client, + notifier: notifier, + instanceExt: instanceExt, + } +} + +func (svc *instanceService) Find(name, environment string) (*v1beta1.ServiceInstance, error) { + key := fmt.Sprintf("%s/%s", environment, name) + + item, exists, err := svc.informer.GetStore().GetByKey(key) + if err != nil || !exists { + return nil, err + } + + serviceInstance, ok := item.(*v1beta1.ServiceInstance) + if !ok { + return nil, fmt.Errorf("Incorrect item type: %T, should be: *ServiceInstance", item) + } + + return serviceInstance, nil +} + +func (svc *instanceService) List(environment string, pagingParams pager.PagingParams) ([]*v1beta1.ServiceInstance, error) { + items, err := pager.FromIndexer(svc.informer.GetIndexer(), "namespace", environment).Limit(pagingParams) + if err != nil { + return nil, err + } + + var serviceInstances []*v1beta1.ServiceInstance + for _, item := range items { + serviceInstance, ok := item.(*v1beta1.ServiceInstance) + if !ok { + return nil, fmt.Errorf("Incorrect item type: %T, should be: *ServiceInstance", item) + } + serviceInstances = append(serviceInstances, serviceInstance) + } + + return serviceInstances, nil +} + +func (svc *instanceService) ListForStatus(environment string, pagingParams pager.PagingParams, status *status.ServiceInstanceStatusType) ([]*v1beta1.ServiceInstance, error) { + key := fmt.Sprintf("%s/%s", environment, *status) + items, err := pager.FromIndexer(svc.informer.GetIndexer(), "statusType", key).Limit(pagingParams) + if err != nil { + return nil, err + } + + var serviceInstances []*v1beta1.ServiceInstance + for _, item := range items { + serviceInstance, ok := item.(*v1beta1.ServiceInstance) + if !ok { + return nil, fmt.Errorf("Incorrect item type: %T, should be: *ServiceInstance", item) + } + serviceInstances = append(serviceInstances, serviceInstance) + } + + return serviceInstances, nil +} + +func (svc *instanceService) ListForClass(className, externalClassName string) ([]*v1beta1.ServiceInstance, error) { + indexer := svc.informer.GetIndexer() + itemsByClassName, err := indexer.ByIndex("relatedClassName", className) + if err != nil { + return nil, err + } + + itemsByExternalClassName, err := indexer.ByIndex("relatedExternalClassName", externalClassName) + if err != nil { + return nil, err + } + + items := append(itemsByClassName, itemsByExternalClassName...) + var serviceInstances []*v1beta1.ServiceInstance + for _, item := range items { + serviceInstance, ok := item.(*v1beta1.ServiceInstance) + if !ok { + return nil, fmt.Errorf("Incorrect item type: %T, should be: *ServiceInstance", item) + } + + serviceInstances = append(serviceInstances, serviceInstance) + } + + return svc.uniqueInstances(serviceInstances), nil +} + +type instanceCreateParameters struct { + Name string + Namespace string + Labels []string + ExternalServicePlanName string + ExternalServiceClassName string + Schema map[string]interface{} +} + +func (svc *instanceService) Create(params instanceCreateParameters) (*v1beta1.ServiceInstance, error) { + specParameters, err := svc.createInstanceParameters(params.Schema) + if err != nil { + return nil, errors.Wrap(err, "while creating spec parameters") + } + + filtered := svc.filterTags(params.Labels) + annotations := map[string]string{ + "tags": strings.Join(filtered, ","), + } + + instance := v1beta1.ServiceInstance{ + TypeMeta: v1.TypeMeta{ + APIVersion: "servicecatalog.k8s.io/v1beta1", + Kind: "ServiceInstance", + }, + ObjectMeta: v1.ObjectMeta{ + Name: params.Name, + Namespace: params.Namespace, + Annotations: annotations, + }, + Spec: v1beta1.ServiceInstanceSpec{ + PlanReference: v1beta1.PlanReference{ + ClusterServiceClassExternalName: params.ExternalServiceClassName, + ClusterServicePlanExternalName: params.ExternalServicePlanName, + }, + Parameters: specParameters, + }, + } + + return svc.client.ServicecatalogV1beta1().ServiceInstances(params.Namespace).Create(&instance) +} + +func (svc *instanceService) Delete(name, namespace string) error { + return svc.client.ServicecatalogV1beta1().ServiceInstances(namespace).Delete(name, nil) +} + +func (svc *instanceService) IsBindable(relatedClass *v1beta1.ClusterServiceClass, relatedPlan *v1beta1.ClusterServicePlan) bool { + if relatedPlan != nil && relatedPlan.Spec.Bindable != nil { + return *relatedPlan.Spec.Bindable + } + + if relatedClass != nil { + return relatedClass.Spec.Bindable + } + + return false +} + +func (svc *instanceService) Subscribe(listener resource.Listener) { + svc.notifier.AddListener(listener) +} + +func (svc *instanceService) Unsubscribe(listener resource.Listener) { + svc.notifier.DeleteListener(listener) +} + +func (svc *instanceService) createInstanceParameters(schema map[string]interface{}) (*runtime.RawExtension, error) { + parameters := jsoncopy.DeepCopyJSON(schema) + + byteArray, err := json.Marshal(parameters) + if err != nil { + return nil, errors.Wrap(err, "while marshalling parameters") + } + + return &runtime.RawExtension{ + Raw: byteArray, + }, nil +} + +func (svc *instanceService) uniqueInstances(items []*v1beta1.ServiceInstance) []*v1beta1.ServiceInstance { + keys := make(map[string]bool) + var uniqueItems []*v1beta1.ServiceInstance + + for _, item := range items { + if _, value := keys[item.Name]; !value { + keys[item.Name] = true + uniqueItems = append(uniqueItems, item) + } + } + + return uniqueItems +} + +func (svc *instanceService) filterTags(labels []string) []string { + r := regexp.MustCompile("[^a-zA-Z0-9 _-]") + + var filtered []string + for _, v := range labels { + clean := strings.TrimSpace(r.ReplaceAllString(v, "")) + + if len(clean) > 0 { + filtered = append(filtered, clean) + } + } + + return filtered +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/instance_service_test.go b/components/ui-api-layer/internal/domain/servicecatalog/instance_service_test.go new file mode 100644 index 000000000000..ae9dd8389f97 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/instance_service_test.go @@ -0,0 +1,384 @@ +package servicecatalog_test + +import ( + "fmt" + "testing" + "time" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kubernetes-incubator/service-catalog/pkg/client/clientset_generated/clientset/fake" + "github.com/kubernetes-incubator/service-catalog/pkg/client/informers_generated/externalversions" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog/status" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + testingUtils "github.com/kyma-project/kyma/components/ui-api-layer/internal/testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + testing2 "k8s.io/client-go/testing" + "k8s.io/client-go/tools/cache" +) + +func TestInstanceService_Find(t *testing.T) { + t.Run("Success", func(t *testing.T) { + instanceName := "testExample" + environment := "testEnv" + + serviceInstance := fixServiceInstance(instanceName, environment) + serviceInstanceInformer := fixInformer(serviceInstance) + + svc := servicecatalog.NewInstanceService(serviceInstanceInformer, nil) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, serviceInstanceInformer) + + instance, err := svc.Find(instanceName, environment) + require.NoError(t, err) + assert.Equal(t, serviceInstance, instance) + }) + + t.Run("NotFound", func(t *testing.T) { + client := fake.NewSimpleClientset() + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + serviceInstanceInformer := informerFactory.Servicecatalog().V1beta1().ServiceInstances().Informer() + + svc := servicecatalog.NewInstanceService(serviceInstanceInformer, nil) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, serviceInstanceInformer) + + instance, err := svc.Find("doesntExist", "notExistingEnv") + require.NoError(t, err) + assert.Nil(t, instance) + }) +} + +func TestInstanceService_List(t *testing.T) { + t.Run("Success", func(t *testing.T) { + environment := "env1" + serviceInstance1 := fixServiceInstance("1", environment) + serviceInstance2 := fixServiceInstance("2", environment) + serviceInstance3 := fixServiceInstance("3", "env2") + + serviceInstanceInformer := fixInformer(serviceInstance1, serviceInstance2, serviceInstance3) + + svc := servicecatalog.NewInstanceService(serviceInstanceInformer, nil) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, serviceInstanceInformer) + + instances, err := svc.List(environment, pager.PagingParams{}) + require.NoError(t, err) + assert.Equal(t, []*v1beta1.ServiceInstance{ + serviceInstance1, serviceInstance2, + }, instances) + }) + + t.Run("NotFound", func(t *testing.T) { + serviceInstanceInformer := fixInformer() + + svc := servicecatalog.NewInstanceService(serviceInstanceInformer, nil) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, serviceInstanceInformer) + + var emptyArray []*v1beta1.ServiceInstance + instances, err := svc.List("notExisting", pager.PagingParams{}) + require.NoError(t, err) + assert.Equal(t, emptyArray, instances) + }) +} + +func TestInstanceService_ListForStatus(t *testing.T) { + t.Run("Success", func(t *testing.T) { + environment := "env1" + status := status.ServiceInstanceStatusTypeRunning + + serviceInstance1 := fixServiceInstanceWithStatus("1", environment) + serviceInstance2 := fixServiceInstanceWithStatus("2", environment) + serviceInstance3 := fixServiceInstanceWithStatus("3", "env2") + + serviceInstanceInformer := fixInformer(serviceInstance1, serviceInstance2, serviceInstance3) + + svc := servicecatalog.NewInstanceService(serviceInstanceInformer, nil) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, serviceInstanceInformer) + + instances, err := svc.ListForStatus(environment, pager.PagingParams{}, &status) + require.NoError(t, err) + assert.Equal(t, []*v1beta1.ServiceInstance{ + serviceInstance1, serviceInstance2, + }, instances) + }) + + t.Run("NotFound", func(t *testing.T) { + status := status.ServiceInstanceStatusTypeRunning + + serviceInstanceInformer := fixInformer() + + svc := servicecatalog.NewInstanceService(serviceInstanceInformer, nil) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, serviceInstanceInformer) + + var emptyArray []*v1beta1.ServiceInstance + instances, err := svc.ListForStatus("notExisting", pager.PagingParams{}, &status) + require.NoError(t, err) + assert.Equal(t, emptyArray, instances) + }) +} + +func TestInstanceService_ListForClass(t *testing.T) { + t.Run("ServiceInstancesQuery exist", func(t *testing.T) { + className := "exampleClassName" + externalClassName := "exampleExternalClassName" + + environment := "env1" + serviceInstance1 := fixServiceInstanceWithPlanRef("1", environment, className, "") + serviceInstance2 := fixServiceInstanceWithPlanRef("2", environment, "", externalClassName) + serviceInstance3 := fixServiceInstanceWithPlanRef("3", "env2", className, externalClassName) + + serviceInstanceInformer := fixInformer(serviceInstance1, serviceInstance2, serviceInstance3) + + svc := servicecatalog.NewInstanceService(serviceInstanceInformer, nil) + testingUtils.WaitForInformerStartAtMost(t, time.Second, serviceInstanceInformer) + expected := []*v1beta1.ServiceInstance{ + serviceInstance1, serviceInstance2, serviceInstance3, + } + + instances, err := svc.ListForClass(className, externalClassName) + require.NoError(t, err) + assert.ElementsMatch(t, expected, instances) + }) + + t.Run("ServiceInstancesQuery don't exist", func(t *testing.T) { + className := "exampleClassName" + externalClassName := "exampleExternalClassName" + + testClassName := "test" + testExternalClassName := "test" + + serviceInstance1 := fixServiceInstanceWithPlanRef("1", "env1", className, "") + serviceInstance2 := fixServiceInstanceWithPlanRef("2", "env2", "", externalClassName) + serviceInstance3 := fixServiceInstanceWithPlanRef("3", "env3", className, externalClassName) + + serviceInstanceInformer := fixInformer(serviceInstance1, serviceInstance2, serviceInstance3) + + svc := servicecatalog.NewInstanceService(serviceInstanceInformer, nil) + testingUtils.WaitForInformerStartAtMost(t, time.Second, serviceInstanceInformer) + + var emptyArray []*v1beta1.ServiceInstance + instances, err := svc.ListForClass(testClassName, testExternalClassName) + require.NoError(t, err) + assert.Equal(t, emptyArray, instances) + }) +} + +func TestInstanceService_Create(t *testing.T) { + t.Run("Success", func(t *testing.T) { + expected := fixServiceInstance("test", "test") + client := fake.NewSimpleClientset(expected) + client.PrependReactor("*", "*", func(action testing2.Action) (handled bool, ret runtime.Object, err error) { + return true, expected, nil + }) + + svc := servicecatalog.NewInstanceService(fixInformer(), client) + + params := servicecatalog.NewInstanceCreateParameters("name", "environment", []string{"test", "label"}, "planName", "className", nil) + result, err := svc.Create(*params) + + assert.NoError(t, err) + assert.Equal(t, expected, result) + }) + +} + +func TestInstanceService_Delete(t *testing.T) { + t.Run("Success", func(t *testing.T) { + instance := fixServiceInstance("test", "test") + client := fake.NewSimpleClientset(instance) + svc := servicecatalog.NewInstanceService(fixInformer(), client) + + err := svc.Delete("test", "test") + + assert.NoError(t, err) + }) + + t.Run("Error", func(t *testing.T) { + testErr := fmt.Errorf("Test") + instance := fixServiceInstance("test", "test") + client := fake.NewSimpleClientset(instance) + client.PrependReactor("*", "*", func(action testing2.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, testErr + }) + svc := servicecatalog.NewInstanceService(fixInformer(), client) + + err := svc.Delete("test", "test") + + assert.Equal(t, testErr, err) + }) +} + +func TestInstanceService_IsBindable(t *testing.T) { + trueVal := true + falseVal := false + + for _, tc := range []struct { + classBindable bool + planBindable *bool + expected bool + }{ + {true, &trueVal, true}, + {false, &falseVal, false}, + {true, nil, true}, + {false, nil, false}, + {true, &falseVal, false}, + {false, &trueVal, true}, + } { + class := &v1beta1.ClusterServiceClass{ + Spec: v1beta1.ClusterServiceClassSpec{ + CommonServiceClassSpec: v1beta1.CommonServiceClassSpec{ + Bindable: tc.classBindable, + }, + }, + } + plan := &v1beta1.ClusterServicePlan{ + Spec: v1beta1.ClusterServicePlanSpec{ + CommonServicePlanSpec: v1beta1.CommonServicePlanSpec{ + Bindable: tc.planBindable, + }, + }, + } + svc := servicecatalog.NewInstanceService(fixInformer(), nil) + + result := svc.IsBindable(class, plan) + + assert.Equal(t, tc.expected, result) + } +} + +func TestInstanceService_Subscribe(t *testing.T) { + t.Run("Simple", func(t *testing.T) { + svc := servicecatalog.NewInstanceService(fixInformer(), nil) + listener := servicecatalog.NewInstanceListener(nil, nil, nil) + svc.Subscribe(listener) + }) + + t.Run("Duplicated", func(t *testing.T) { + svc := servicecatalog.NewInstanceService(fixInformer(), nil) + listener := servicecatalog.NewInstanceListener(nil, nil, nil) + + svc.Subscribe(listener) + svc.Subscribe(listener) + }) + + t.Run("Multiple", func(t *testing.T) { + svc := servicecatalog.NewInstanceService(fixInformer(), nil) + listenerA := servicecatalog.NewInstanceListener(nil, nil, nil) + listenerB := servicecatalog.NewInstanceListener(nil, nil, nil) + + svc.Subscribe(listenerA) + svc.Subscribe(listenerB) + }) + + t.Run("Nil", func(t *testing.T) { + svc := servicecatalog.NewInstanceService(fixInformer(), nil) + + svc.Subscribe(nil) + }) +} + +func TestInstanceService_Unsubscribe(t *testing.T) { + t.Run("Existing", func(t *testing.T) { + svc := servicecatalog.NewInstanceService(fixInformer(), nil) + listener := servicecatalog.NewInstanceListener(nil, nil, nil) + svc.Subscribe(listener) + + svc.Unsubscribe(listener) + }) + + t.Run("Duplicated", func(t *testing.T) { + svc := servicecatalog.NewInstanceService(fixInformer(), nil) + listener := servicecatalog.NewInstanceListener(nil, nil, nil) + svc.Subscribe(listener) + svc.Subscribe(listener) + + svc.Unsubscribe(listener) + }) + + t.Run("Multiple", func(t *testing.T) { + svc := servicecatalog.NewInstanceService(fixInformer(), nil) + listenerA := servicecatalog.NewInstanceListener(nil, nil, nil) + listenerB := servicecatalog.NewInstanceListener(nil, nil, nil) + svc.Subscribe(listenerA) + svc.Subscribe(listenerB) + + svc.Unsubscribe(listenerA) + }) + + t.Run("Nil", func(t *testing.T) { + svc := servicecatalog.NewInstanceService(fixInformer(), nil) + + svc.Unsubscribe(nil) + }) +} + +func fixServiceInstance(name, namespace string) *v1beta1.ServiceInstance { + instance := v1beta1.ServiceInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: v1beta1.ServiceInstanceSpec{ + PlanReference: v1beta1.PlanReference{ + ClusterServiceClassName: "", + ClusterServiceClassExternalName: "", + }, + }, + } + + return &instance +} + +func fixServiceInstanceWithPlanRef(name, namespace, className, externalClassName string) *v1beta1.ServiceInstance { + plan := v1beta1.ServiceInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: v1beta1.ServiceInstanceSpec{ + PlanReference: v1beta1.PlanReference{ + ClusterServiceClassName: className, + ClusterServiceClassExternalName: externalClassName, + }, + }, + } + + return &plan +} + +func fixServiceInstanceWithStatus(name, namespace string) *v1beta1.ServiceInstance { + plan := v1beta1.ServiceInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Status: v1beta1.ServiceInstanceStatus{ + AsyncOpInProgress: false, + Conditions: []v1beta1.ServiceInstanceCondition{ + { + Type: v1beta1.ServiceInstanceConditionReady, + Status: v1beta1.ConditionTrue, + Message: "Working", + Reason: "Testing", + }, + }, + }, + } + + return &plan +} + +func fixInformer(objects ...runtime.Object) cache.SharedIndexInformer { + client := fake.NewSimpleClientset(objects...) + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + informer := informerFactory.Servicecatalog().V1beta1().ServiceInstances().Informer() + + return informer +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/interfaces.go b/components/ui-api-layer/internal/domain/servicecatalog/interfaces.go new file mode 100644 index 000000000000..c6399442d235 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/interfaces.go @@ -0,0 +1,158 @@ +package servicecatalog + +import ( + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + api "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content/storage" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog/status" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + "github.com/kyma-project/kyma/components/ui-api-layer/pkg/resource" +) + +// Content + +//go:generate mockery -name=AsyncApiSpecGetter -output=automock -outpkg=automock -case=underscore +type AsyncApiSpecGetter interface { + Find(kind, id string) (*storage.AsyncApiSpec, error) +} + +//go:generate mockery -name=ApiSpecGetter -output=automock -outpkg=automock -case=underscore +type ApiSpecGetter interface { + Find(kind, id string) (*storage.ApiSpec, error) +} + +//go:generate mockery -name=ContentGetter -output=automock -outpkg=automock -case=underscore +type ContentGetter interface { + Find(kind, id string) (*storage.Content, error) +} + +// Class + +//go:generate mockery -name=classGetter -output=automock -outpkg=automock -case=underscore +type classGetter interface { + Find(name string) (*v1beta1.ClusterServiceClass, error) + FindByExternalName(externalName string) (*v1beta1.ClusterServiceClass, error) +} + +//go:generate mockery -name=classListGetter -output=automock -outpkg=automock -case=underscore +type classListGetter interface { + classGetter + List(pagingParams pager.PagingParams) ([]*v1beta1.ClusterServiceClass, error) +} + +//go:generate mockery -name=gqlClassConverter -output=automock -outpkg=automock -case=underscore +type gqlClassConverter interface { + ToGQL(in *v1beta1.ClusterServiceClass) (*gqlschema.ServiceClass, error) + ToGQLs(in []*v1beta1.ClusterServiceClass) ([]gqlschema.ServiceClass, error) +} + +// Instance + +//go:generate mockery -name=instanceGetter -output=automock -outpkg=automock -case=underscore +type instanceGetter interface { + Find(name, environment string) (*v1beta1.ServiceInstance, error) +} + +//go:generate mockery -name=instanceLister -output=automock -outpkg=automock -case=underscore +type instanceLister interface { + List(environment string, pagingParams pager.PagingParams) ([]*v1beta1.ServiceInstance, error) + ListForStatus(environment string, pagingParams pager.PagingParams, status *status.ServiceInstanceStatusType) ([]*v1beta1.ServiceInstance, error) +} + +//go:generate mockery -name=instanceSvc -inpkg -case=underscore +type instanceSvc interface { + instanceGetter + instanceLister + Create(params instanceCreateParameters) (*v1beta1.ServiceInstance, error) + Delete(name, namespace string) error + IsBindable(relatedClass *v1beta1.ClusterServiceClass, relatedPlan *v1beta1.ClusterServicePlan) bool + Subscribe(listener resource.Listener) + Unsubscribe(listener resource.Listener) +} + +//go:generate mockery -name=classInstanceLister -output=automock -outpkg=automock -case=underscore +type classInstanceLister interface { + ListForClass(className, externalClassName string) ([]*v1beta1.ServiceInstance, error) +} + +//go:generate mockery -name=gqlInstanceConverter -inpkg -case=underscore +type gqlInstanceConverter interface { + ToGQL(in *v1beta1.ServiceInstance) *gqlschema.ServiceInstance + ToGQLs(in []*v1beta1.ServiceInstance) []gqlschema.ServiceInstance + GQLCreateInputToInstanceCreateParameters(in *gqlschema.ServiceInstanceCreateInput) *instanceCreateParameters + ServiceStatusTypeToGQLStatusType(in status.ServiceInstanceStatusType) gqlschema.InstanceStatusType + GQLStatusTypeToServiceStatusType(in gqlschema.InstanceStatusType) status.ServiceInstanceStatusType + GQLStatusToServiceStatus(in *gqlschema.ServiceInstanceStatus) *status.ServiceInstanceStatus + ServiceStatusToGQLStatus(in *status.ServiceInstanceStatus) *gqlschema.ServiceInstanceStatus +} + +// Plan + +//go:generate mockery -name=planGetter -output=automock -outpkg=automock -case=underscore +type planGetter interface { + Find(name string) (*v1beta1.ClusterServicePlan, error) + FindByExternalNameForClass(planExternalName, className string) (*v1beta1.ClusterServicePlan, error) +} + +//go:generate mockery -name=planLister -output=automock -outpkg=automock -case=underscore +type planLister interface { + ListForClass(name string) ([]*v1beta1.ClusterServicePlan, error) +} + +//go:generate mockery -name=gqlPlanConverter -output=automock -outpkg=automock -case=underscore +type gqlPlanConverter interface { + ToGQL(item *v1beta1.ClusterServicePlan) (*gqlschema.ServicePlan, error) + ToGQLs(in []*v1beta1.ClusterServicePlan) ([]gqlschema.ServicePlan, error) +} + +// Broker + +//go:generate mockery -name=brokerGetter -output=automock -outpkg=automock -case=underscore +type brokerGetter interface { + Find(name string) (*v1beta1.ClusterServiceBroker, error) +} + +//go:generate mockery -name=brokerLister -output=automock -outpkg=automock -case=underscore +type brokerLister interface { + List(pagingParams pager.PagingParams) ([]*v1beta1.ClusterServiceBroker, error) +} + +//go:generate mockery -name=brokerListGetter -output=automock -outpkg=automock -case=underscore +type brokerListGetter interface { + brokerGetter + brokerLister +} + +//go:generate mockery -name=gqlBrokerConverter -output=automock -outpkg=automock -case=underscore +type gqlBrokerConverter interface { + ToGQL(in *v1beta1.ClusterServiceBroker) (*gqlschema.ServiceBroker, error) + ToGQLs(in []*v1beta1.ClusterServiceBroker) ([]gqlschema.ServiceBroker, error) +} + +// Notifier + +type notifier interface { + AddListener(observer resource.Listener) + DeleteListener(observer resource.Listener) +} + +// Binding + +//go:generate mockery -name=serviceBindingOperations -output=automock -outpkg=automock -case=underscore +type serviceBindingOperations interface { + Create(env string, sb *v1beta1.ServiceBinding) (*v1beta1.ServiceBinding, error) + Delete(env string, name string) error + Find(env string, name string) (*v1beta1.ServiceBinding, error) + ListForServiceInstance(env string, instanceName string) ([]*v1beta1.ServiceBinding, error) +} + +// Binding usage + +//go:generate mockery -name=serviceBindingUsageOperations -output=automock -outpkg=automock -case=underscore +type serviceBindingUsageOperations interface { + Create(env string, sb *api.ServiceBindingUsage) (*api.ServiceBindingUsage, error) + Delete(env string, name string) error + Find(env string, name string) (*api.ServiceBindingUsage, error) + ListForServiceInstance(env string, instanceName string) ([]*api.ServiceBindingUsage, error) +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/mock_gql_instance_converter.go b/components/ui-api-layer/internal/domain/servicecatalog/mock_gql_instance_converter.go new file mode 100644 index 000000000000..5bded2952d00 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/mock_gql_instance_converter.go @@ -0,0 +1,120 @@ +// Code generated by mockery v1.0.0 +package servicecatalog + +import gqlschema "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +import mock "github.com/stretchr/testify/mock" +import status "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog/status" +import v1beta1 "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + +// mockGqlInstanceConverter is an autogenerated mock type for the gqlInstanceConverter type +type mockGqlInstanceConverter struct { + mock.Mock +} + +// GQLCreateInputToInstanceCreateParameters provides a mock function with given fields: in +func (_m *mockGqlInstanceConverter) GQLCreateInputToInstanceCreateParameters(in *gqlschema.ServiceInstanceCreateInput) *instanceCreateParameters { + ret := _m.Called(in) + + var r0 *instanceCreateParameters + if rf, ok := ret.Get(0).(func(*gqlschema.ServiceInstanceCreateInput) *instanceCreateParameters); ok { + r0 = rf(in) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*instanceCreateParameters) + } + } + + return r0 +} + +// GQLStatusToServiceStatus provides a mock function with given fields: in +func (_m *mockGqlInstanceConverter) GQLStatusToServiceStatus(in *gqlschema.ServiceInstanceStatus) *status.ServiceInstanceStatus { + ret := _m.Called(in) + + var r0 *status.ServiceInstanceStatus + if rf, ok := ret.Get(0).(func(*gqlschema.ServiceInstanceStatus) *status.ServiceInstanceStatus); ok { + r0 = rf(in) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*status.ServiceInstanceStatus) + } + } + + return r0 +} + +// GQLStatusTypeToServiceStatusType provides a mock function with given fields: in +func (_m *mockGqlInstanceConverter) GQLStatusTypeToServiceStatusType(in gqlschema.InstanceStatusType) status.ServiceInstanceStatusType { + ret := _m.Called(in) + + var r0 status.ServiceInstanceStatusType + if rf, ok := ret.Get(0).(func(gqlschema.InstanceStatusType) status.ServiceInstanceStatusType); ok { + r0 = rf(in) + } else { + r0 = ret.Get(0).(status.ServiceInstanceStatusType) + } + + return r0 +} + +// ServiceStatusToGQLStatus provides a mock function with given fields: in +func (_m *mockGqlInstanceConverter) ServiceStatusToGQLStatus(in *status.ServiceInstanceStatus) *gqlschema.ServiceInstanceStatus { + ret := _m.Called(in) + + var r0 *gqlschema.ServiceInstanceStatus + if rf, ok := ret.Get(0).(func(*status.ServiceInstanceStatus) *gqlschema.ServiceInstanceStatus); ok { + r0 = rf(in) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*gqlschema.ServiceInstanceStatus) + } + } + + return r0 +} + +// ServiceStatusTypeToGQLStatusType provides a mock function with given fields: in +func (_m *mockGqlInstanceConverter) ServiceStatusTypeToGQLStatusType(in status.ServiceInstanceStatusType) gqlschema.InstanceStatusType { + ret := _m.Called(in) + + var r0 gqlschema.InstanceStatusType + if rf, ok := ret.Get(0).(func(status.ServiceInstanceStatusType) gqlschema.InstanceStatusType); ok { + r0 = rf(in) + } else { + r0 = ret.Get(0).(gqlschema.InstanceStatusType) + } + + return r0 +} + +// ToGQL provides a mock function with given fields: in +func (_m *mockGqlInstanceConverter) ToGQL(in *v1beta1.ServiceInstance) *gqlschema.ServiceInstance { + ret := _m.Called(in) + + var r0 *gqlschema.ServiceInstance + if rf, ok := ret.Get(0).(func(*v1beta1.ServiceInstance) *gqlschema.ServiceInstance); ok { + r0 = rf(in) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*gqlschema.ServiceInstance) + } + } + + return r0 +} + +// ToGQLs provides a mock function with given fields: in +func (_m *mockGqlInstanceConverter) ToGQLs(in []*v1beta1.ServiceInstance) []gqlschema.ServiceInstance { + ret := _m.Called(in) + + var r0 []gqlschema.ServiceInstance + if rf, ok := ret.Get(0).(func([]*v1beta1.ServiceInstance) []gqlschema.ServiceInstance); ok { + r0 = rf(in) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gqlschema.ServiceInstance) + } + } + + return r0 +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/mock_instance_svc.go b/components/ui-api-layer/internal/domain/servicecatalog/mock_instance_svc.go new file mode 100644 index 000000000000..fa79a0a43ddb --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/mock_instance_svc.go @@ -0,0 +1,143 @@ +// Code generated by mockery v1.0.0 +package servicecatalog + +import mock "github.com/stretchr/testify/mock" +import pager "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" +import resource "github.com/kyma-project/kyma/components/ui-api-layer/pkg/resource" +import status "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog/status" +import v1beta1 "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + +// mockInstanceSvc is an autogenerated mock type for the instanceSvc type +type mockInstanceSvc struct { + mock.Mock +} + +// Create provides a mock function with given fields: params +func (_m *mockInstanceSvc) Create(params instanceCreateParameters) (*v1beta1.ServiceInstance, error) { + ret := _m.Called(params) + + var r0 *v1beta1.ServiceInstance + if rf, ok := ret.Get(0).(func(instanceCreateParameters) *v1beta1.ServiceInstance); ok { + r0 = rf(params) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1beta1.ServiceInstance) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(instanceCreateParameters) error); ok { + r1 = rf(params) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: name, namespace +func (_m *mockInstanceSvc) Delete(name string, namespace string) error { + ret := _m.Called(name, namespace) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(name, namespace) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Find provides a mock function with given fields: name, environment +func (_m *mockInstanceSvc) Find(name string, environment string) (*v1beta1.ServiceInstance, error) { + ret := _m.Called(name, environment) + + var r0 *v1beta1.ServiceInstance + if rf, ok := ret.Get(0).(func(string, string) *v1beta1.ServiceInstance); ok { + r0 = rf(name, environment) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1beta1.ServiceInstance) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(name, environment) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// IsBindable provides a mock function with given fields: relatedClass, relatedPlan +func (_m *mockInstanceSvc) IsBindable(relatedClass *v1beta1.ClusterServiceClass, relatedPlan *v1beta1.ClusterServicePlan) bool { + ret := _m.Called(relatedClass, relatedPlan) + + var r0 bool + if rf, ok := ret.Get(0).(func(*v1beta1.ClusterServiceClass, *v1beta1.ClusterServicePlan) bool); ok { + r0 = rf(relatedClass, relatedPlan) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// List provides a mock function with given fields: environment, pagingParams +func (_m *mockInstanceSvc) List(environment string, pagingParams pager.PagingParams) ([]*v1beta1.ServiceInstance, error) { + ret := _m.Called(environment, pagingParams) + + var r0 []*v1beta1.ServiceInstance + if rf, ok := ret.Get(0).(func(string, pager.PagingParams) []*v1beta1.ServiceInstance); ok { + r0 = rf(environment, pagingParams) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1beta1.ServiceInstance) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, pager.PagingParams) error); ok { + r1 = rf(environment, pagingParams) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ListForStatus provides a mock function with given fields: environment, pagingParams, _a2 +func (_m *mockInstanceSvc) ListForStatus(environment string, pagingParams pager.PagingParams, _a2 *status.ServiceInstanceStatusType) ([]*v1beta1.ServiceInstance, error) { + ret := _m.Called(environment, pagingParams, _a2) + + var r0 []*v1beta1.ServiceInstance + if rf, ok := ret.Get(0).(func(string, pager.PagingParams, *status.ServiceInstanceStatusType) []*v1beta1.ServiceInstance); ok { + r0 = rf(environment, pagingParams, _a2) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1beta1.ServiceInstance) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, pager.PagingParams, *status.ServiceInstanceStatusType) error); ok { + r1 = rf(environment, pagingParams, _a2) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Subscribe provides a mock function with given fields: listener +func (_m *mockInstanceSvc) Subscribe(listener resource.Listener) { + _m.Called(listener) +} + +// Unsubscribe provides a mock function with given fields: listener +func (_m *mockInstanceSvc) Unsubscribe(listener resource.Listener) { + _m.Called(listener) +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/plan_converter.go b/components/ui-api-layer/internal/domain/servicecatalog/plan_converter.go new file mode 100644 index 000000000000..4de3a4881fda --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/plan_converter.go @@ -0,0 +1,126 @@ +package servicecatalog + +import ( + "encoding/base64" + "encoding/json" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/resource" + "github.com/pkg/errors" +) + +type planConverter struct{} + +func (p *planConverter) ToGQL(item *v1beta1.ClusterServicePlan) (*gqlschema.ServicePlan, error) { + if item == nil { + return nil, nil + } + + var externalMetadata map[string]interface{} + var err error + if item.Spec.ExternalMetadata != nil { + externalMetadata, err = resource.ExtractRawToMap("ExternalMetadata", item.Spec.ExternalMetadata.Raw) + if err != nil { + return nil, p.wrapConversionError(err, item.Name) + } + } + + var instanceCreateParameterSchema *gqlschema.JSON + if item.Spec.ServiceInstanceCreateParameterSchema != nil { + unpackedSchema, err := p.unpackInstanceCreateParameterSchema(item.Spec.ServiceInstanceCreateParameterSchema.Raw) + if err != nil { + return nil, p.wrapConversionError(err, item.Name) + } + instanceCreateParameterSchema = &unpackedSchema + } + + displayName := resource.ToStringPtr(externalMetadata["displayName"]) + plan := gqlschema.ServicePlan{ + Name: item.Name, + ExternalName: item.Spec.ExternalName, + DisplayName: displayName, + Description: item.Spec.Description, + RelatedServiceClassName: item.Spec.ClusterServiceClassRef.Name, + InstanceCreateParameterSchema: instanceCreateParameterSchema, + } + + return &plan, nil +} + +func (c *planConverter) ToGQLs(in []*v1beta1.ClusterServicePlan) ([]gqlschema.ServicePlan, error) { + var result []gqlschema.ServicePlan + for _, u := range in { + converted, err := c.ToGQL(u) + if err != nil { + return nil, err + } + + if converted != nil { + result = append(result, *converted) + } + } + return result, nil +} + +func (p *planConverter) wrapConversionError(err error, name string) error { + return errors.Wrapf(err, "while converting item %s to ServicePlan", name) +} + +func (p *planConverter) unpackInstanceCreateParameterSchema(raw []byte) (gqlschema.JSON, error) { + if len(raw) == 0 { + return nil, nil + } + + //TODO: Change it when fix for helm broker will be ready + encoded := p.omitQuotationMarksIfShould(raw) + if len(encoded) == 0 { + return nil, nil + } + + decoded := make([]byte, base64.StdEncoding.DecodedLen(len(encoded))) + _, err := base64.StdEncoding.Decode(decoded, encoded) + if err != nil { + return p.extractInstanceCreateSchema(raw) + } + + decoded = p.removeNullCharactersFromEndOfArray(decoded) + return p.extractInstanceCreateSchema(decoded) +} + +// TODO: Figure out why the instanceCreateParameterSchema has quotation marks +func (p *planConverter) omitQuotationMarksIfShould(input []byte) []byte { + const quotationMarkChar byte = 34 + inputLength := len(input) + + var result []byte + if input[inputLength-1] != quotationMarkChar { + return input + } + + result = append(result, input[1:len(input)-1]...) + return result +} + +// TODO: Investigate why instanceCreateParameterSchema has null character at the end +func (p *planConverter) removeNullCharactersFromEndOfArray(input []byte) []byte { + const nullChar byte = 0 + + sliceEnd := len(input) + for i := sliceEnd - 1; input[i] == nullChar; i-- { + sliceEnd = i + } + + return input[:sliceEnd] +} + +func (p *planConverter) extractInstanceCreateSchema(raw []byte) (map[string]interface{}, error) { + extracted := make(map[string]interface{}) + + err := json.Unmarshal(raw, &extracted) + if err != nil { + return nil, errors.Wrap(err, "while extracting instance creation parameter schema") + } + + return extracted, nil +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/plan_converter_test.go b/components/ui-api-layer/internal/domain/servicecatalog/plan_converter_test.go new file mode 100644 index 000000000000..7173930bcc21 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/plan_converter_test.go @@ -0,0 +1,243 @@ +package servicecatalog + +import ( + "encoding/base64" + "encoding/json" + "testing" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" +) + +func TestPlanConverter_ToGQL(t *testing.T) { + t.Run("Success", func(t *testing.T) { + converter := planConverter{} + metadata := map[string]string{ + "displayName": "ExampleDisplayName", + } + + metadataBytes, err := json.Marshal(metadata) + assert.Nil(t, err) + + parameterSchema := map[string]interface{}{ + "first": "1", + "second": map[string]interface{}{ + "value": "2", + }, + } + + parameterSchemaBytes, err := json.Marshal(parameterSchema) + encodedParameterSchemaBytes := make([]byte, base64.StdEncoding.EncodedLen(len(parameterSchemaBytes))) + base64.StdEncoding.Encode(encodedParameterSchemaBytes, parameterSchemaBytes) + assert.Nil(t, err) + + parameterSchemaJSON := new(gqlschema.JSON) + err = parameterSchemaJSON.UnmarshalGQL(parameterSchema) + assert.Nil(t, err) + + clusterServicePlan := v1beta1.ClusterServicePlan{ + Spec: v1beta1.ClusterServicePlanSpec{ + CommonServicePlanSpec: v1beta1.CommonServicePlanSpec{ + ExternalMetadata: &runtime.RawExtension{Raw: metadataBytes}, + ExternalName: "ExampleExternalName", + ServiceInstanceCreateParameterSchema: &runtime.RawExtension{ + Raw: encodedParameterSchemaBytes, + }, + }, + ClusterServiceClassRef: v1beta1.ClusterObjectReference{ + Name: "serviceClassRef", + }, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "ExampleName", + UID: types.UID("uid"), + }, + } + displayName := "ExampleDisplayName" + expected := gqlschema.ServicePlan{ + Name: "ExampleName", + RelatedServiceClassName: "serviceClassRef", + DisplayName: &displayName, + ExternalName: "ExampleExternalName", + InstanceCreateParameterSchema: parameterSchemaJSON, + } + + result, err := converter.ToGQL(&clusterServicePlan) + assert.Nil(t, err) + + assert.Equal(t, &expected, result) + }) + + t.Run("Empty", func(t *testing.T) { + converter := &planConverter{} + _, err := converter.ToGQL(&v1beta1.ClusterServicePlan{}) + require.NoError(t, err) + }) + + t.Run("Nil", func(t *testing.T) { + converter := &planConverter{} + item, err := converter.ToGQL(nil) + require.NoError(t, err) + assert.Nil(t, item) + }) +} + +func TestPlanConverter_ToGQLs(t *testing.T) { + t.Run("Success", func(t *testing.T) { + plans := []*v1beta1.ClusterServicePlan{ + fixServicePlan(t), + fixServicePlan(t), + } + + converter := planConverter{} + result, err := converter.ToGQLs(plans) + + require.NoError(t, err) + assert.Len(t, result, 2) + assert.Equal(t, "exampleName", result[0].Name) + }) + + t.Run("Empty", func(t *testing.T) { + var plans []*v1beta1.ClusterServicePlan + + converter := planConverter{} + result, err := converter.ToGQLs(plans) + + require.NoError(t, err) + assert.Empty(t, result) + }) + + t.Run("With nil", func(t *testing.T) { + plans := []*v1beta1.ClusterServicePlan{ + nil, + fixServicePlan(t), + nil, + } + + converter := planConverter{} + result, err := converter.ToGQLs(plans) + + require.NoError(t, err) + assert.Len(t, result, 1) + assert.Equal(t, "exampleName", result[0].Name) + }) +} + +func TestPlanConverter_OmitQuotationMarks(t *testing.T) { + converter := planConverter{} + const quotationMarkChar byte = 34 + + t.Run("Remove quotation marks from array", func(t *testing.T) { + input := []byte{ + quotationMarkChar, + 73, + 67, + 65, + quotationMarkChar, + } + expectedResult := []byte{ + 73, + 67, + 65, + } + + result := converter.omitQuotationMarksIfShould(input) + + assert.Equal(t, expectedResult, result) + }) + + t.Run("Skip arrays without quotation marks", func(t *testing.T) { + input := []byte{ + 73, + 67, + 65, + } + + result := converter.omitQuotationMarksIfShould(input) + + assert.Equal(t, input, result) + }) + +} + +func TestPlanConverter_RemoveNullCharactersFromEndOfArray(t *testing.T) { + converter := planConverter{} + const nullChar byte = 0 + + t.Run("Remove null characters array", func(t *testing.T) { + input := []byte{ + 73, + 67, + 65, + nullChar, + nullChar, + } + expectedResult := []byte{ + 73, + 67, + 65, + } + + result := converter.removeNullCharactersFromEndOfArray(input) + + assert.Equal(t, expectedResult, result) + }) + + t.Run("Skip arrays without null characters", func(t *testing.T) { + input := []byte{ + 73, + 67, + 65, + } + + result := converter.removeNullCharactersFromEndOfArray(input) + + assert.Equal(t, input, result) + }) + +} + +func fixServicePlan(t require.TestingT) *v1beta1.ClusterServicePlan { + metadata := map[string]string{ + "displayName": "ExampleDisplayName", + } + + metadataBytes, err := json.Marshal(metadata) + require.NoError(t, err) + + parameterSchema := map[string]interface{}{ + "first": "1", + "second": map[string]interface{}{ + "value": "2", + }, + } + + parameterSchemaBytes, err := json.Marshal(parameterSchema) + encodedParameterSchemaBytes := make([]byte, base64.StdEncoding.EncodedLen(len(parameterSchemaBytes))) + base64.StdEncoding.Encode(encodedParameterSchemaBytes, parameterSchemaBytes) + require.NoError(t, err) + + return &v1beta1.ClusterServicePlan{ + Spec: v1beta1.ClusterServicePlanSpec{ + CommonServicePlanSpec: v1beta1.CommonServicePlanSpec{ + ExternalMetadata: &runtime.RawExtension{Raw: metadataBytes}, + ExternalName: "ExampleExternalName", + ServiceInstanceCreateParameterSchema: &runtime.RawExtension{ + Raw: encodedParameterSchemaBytes, + }, + }, + ClusterServiceClassRef: v1beta1.ClusterObjectReference{ + Name: "serviceClassRef", + }, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "exampleName", + UID: types.UID("uid"), + }, + } +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/plan_service.go b/components/ui-api-layer/internal/domain/servicecatalog/plan_service.go new file mode 100644 index 000000000000..23e0784afa52 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/plan_service.go @@ -0,0 +1,99 @@ +package servicecatalog + +import ( + "errors" + "fmt" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "k8s.io/client-go/tools/cache" +) + +type planService struct { + informer cache.SharedIndexInformer +} + +func newPlanService(informer cache.SharedIndexInformer) *planService { + informer.AddIndexers(cache.Indexers{ + "relatedServiceClassName": func(obj interface{}) ([]string, error) { + servicePlan, ok := obj.(*v1beta1.ClusterServicePlan) + if !ok { + return nil, errors.New("Cannot convert item") + } + + return []string{servicePlan.Spec.ClusterServiceClassRef.Name}, nil + }, + "classNameAndPlanExternalName": func(obj interface{}) ([]string, error) { + servicePlan, ok := obj.(*v1beta1.ClusterServicePlan) + if !ok { + return nil, errors.New("Cannot convert item") + } + + str := planIndexKey(servicePlan.Spec.ClusterServiceClassRef.Name, servicePlan.Spec.ExternalName) + return []string{str}, nil + }, + }) + + return &planService{ + informer: informer, + } +} + +func (svc *planService) Find(name string) (*v1beta1.ClusterServicePlan, error) { + item, exists, err := svc.informer.GetStore().GetByKey(name) + if err != nil || !exists { + return nil, err + } + + servicePlan, ok := item.(*v1beta1.ClusterServicePlan) + if !ok { + return nil, fmt.Errorf("Incorrect item type: %T, should be: *ClusterServicePlan", item) + } + + return servicePlan, nil +} + +func (svc *planService) FindByExternalNameForClass(planExternalName, className string) (*v1beta1.ClusterServicePlan, error) { + items, err := svc.informer.GetIndexer().ByIndex("classNameAndPlanExternalName", planIndexKey(className, planExternalName)) + if err != nil { + return nil, err + } + + if len(items) == 0 { + return nil, nil + } + + if len(items) > 1 { + return nil, fmt.Errorf("Multiple ServicePlan resources with the same externalName %s", planExternalName) + } + + item := items[0] + servicePlan, ok := item.(*v1beta1.ClusterServicePlan) + if !ok { + return nil, fmt.Errorf("Incorrect item type: %T, should be: *ClusterServicePlan", item) + } + + return servicePlan, nil +} + +func (svc *planService) ListForClass(name string) ([]*v1beta1.ClusterServicePlan, error) { + plans, err := svc.informer.GetIndexer().ByIndex("relatedServiceClassName", name) + if err != nil { + return nil, err + } + + var servicePlans []*v1beta1.ClusterServicePlan + for _, item := range plans { + servicePlan, ok := item.(*v1beta1.ClusterServicePlan) + if !ok { + return nil, fmt.Errorf("Incorrect item type: %T, should be: *ClusterServicePlan", item) + } + + servicePlans = append(servicePlans, servicePlan) + } + + return servicePlans, nil +} + +func planIndexKey(planExternalName, className string) string { + return fmt.Sprintf("%s/%s", className, planExternalName) +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/plan_service_test.go b/components/ui-api-layer/internal/domain/servicecatalog/plan_service_test.go new file mode 100644 index 000000000000..1746d9151141 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/plan_service_test.go @@ -0,0 +1,165 @@ +package servicecatalog_test + +import ( + "testing" + "time" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kubernetes-incubator/service-catalog/pkg/client/clientset_generated/clientset/fake" + "github.com/kubernetes-incubator/service-catalog/pkg/client/informers_generated/externalversions" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/servicecatalog" + testingUtils "github.com/kyma-project/kyma/components/ui-api-layer/internal/testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestGetServicePlan(t *testing.T) { + t.Run("Success", func(t *testing.T) { + planName := "testExample" + servicePlan := fixServicePlan(planName, "test", planName) + client := fake.NewSimpleClientset(servicePlan) + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + servicePlanInformer := informerFactory.Servicecatalog().V1beta1().ClusterServicePlans().Informer() + + svc := servicecatalog.NewPlanService(servicePlanInformer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, servicePlanInformer) + + plan, err := svc.Find(planName) + require.NoError(t, err) + assert.Equal(t, servicePlan, plan) + }) + + t.Run("NotFound", func(t *testing.T) { + client := fake.NewSimpleClientset() + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + servicePlanInformer := informerFactory.Servicecatalog().V1beta1().ClusterServicePlans().Informer() + + svc := servicecatalog.NewPlanService(servicePlanInformer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, servicePlanInformer) + + plan, err := svc.Find("doesntExist") + require.NoError(t, err) + assert.Nil(t, plan) + }) +} + +func TestPlanService_FindByExternalName(t *testing.T) { + t.Run("Success", func(t *testing.T) { + className := "test" + planName := "testExample" + externalName := "testExternal" + servicePlan := fixServicePlan(planName, className, externalName) + client := fake.NewSimpleClientset(servicePlan) + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + servicePlanInformer := informerFactory.Servicecatalog().V1beta1().ClusterServicePlans().Informer() + + svc := servicecatalog.NewPlanService(servicePlanInformer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, servicePlanInformer) + + plan, err := svc.FindByExternalNameForClass(externalName, className) + require.NoError(t, err) + assert.Equal(t, servicePlan, plan) + }) + + t.Run("NotFound", func(t *testing.T) { + client := fake.NewSimpleClientset() + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + servicePlanInformer := informerFactory.Servicecatalog().V1beta1().ClusterServicePlans().Informer() + + svc := servicecatalog.NewPlanService(servicePlanInformer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, servicePlanInformer) + + plan, err := svc.FindByExternalNameForClass("doesntExist", "none") + + require.NoError(t, err) + assert.Nil(t, plan) + }) + + t.Run("Error", func(t *testing.T) { + className := "duplicateName" + externalName := "duplicateName" + client := fake.NewSimpleClientset( + fixServicePlan("1", className, externalName), + fixServicePlan("2", className, externalName), + fixServicePlan("3", className, externalName), + ) + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + servicePlanInformer := informerFactory.Servicecatalog().V1beta1().ClusterServicePlans().Informer() + + svc := servicecatalog.NewPlanService(servicePlanInformer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, servicePlanInformer) + + _, err := svc.FindByExternalNameForClass(externalName, className) + + assert.Error(t, err) + }) +} + +func TestListServicePlansForServiceClass(t *testing.T) { + t.Run("Success", func(t *testing.T) { + className := "testClassName" + + servicePlan1 := fixServicePlan("1", className, "1") + servicePlan2 := fixServicePlan("2", className, "2") + servicePlan3 := fixServicePlan("3", className, "3") + client := fake.NewSimpleClientset(servicePlan1, servicePlan2, servicePlan3) + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + servicePlanInformer := informerFactory.Servicecatalog().V1beta1().ClusterServicePlans().Informer() + + svc := servicecatalog.NewPlanService(servicePlanInformer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, servicePlanInformer) + + plans, err := svc.ListForClass(className) + require.NoError(t, err) + assert.Equal(t, []*v1beta1.ClusterServicePlan{ + servicePlan1, servicePlan2, servicePlan3, + }, plans) + }) + + t.Run("NotFound", func(t *testing.T) { + client := fake.NewSimpleClientset() + + informerFactory := externalversions.NewSharedInformerFactory(client, 0) + servicePlanInformer := informerFactory.Servicecatalog().V1beta1().ClusterServicePlans().Informer() + svc := servicecatalog.NewPlanService(servicePlanInformer) + + testingUtils.WaitForInformerStartAtMost(t, time.Second, servicePlanInformer) + + var emptyArray []*v1beta1.ClusterServicePlan + plans, err := svc.ListForClass("doesntExist") + require.NoError(t, err) + assert.Equal(t, emptyArray, plans) + }) + +} + +func fixServicePlan(name, relatedServiceClassName, externalName string) *v1beta1.ClusterServicePlan { + plan := v1beta1.ClusterServicePlan{ + Spec: v1beta1.ClusterServicePlanSpec{ + CommonServicePlanSpec: v1beta1.CommonServicePlanSpec{ + ExternalName: externalName, + }, + ClusterServiceClassRef: v1beta1.ClusterObjectReference{ + Name: relatedServiceClassName, + }, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } + + return &plan +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/servicecatalog.go b/components/ui-api-layer/internal/domain/servicecatalog/servicecatalog.go new file mode 100644 index 000000000000..91cd8a1c6f28 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/servicecatalog.go @@ -0,0 +1,82 @@ +package servicecatalog + +import ( + "time" + + bindingApi "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kubernetes-incubator/service-catalog/pkg/client/clientset_generated/clientset" + catalogInformers "github.com/kubernetes-incubator/service-catalog/pkg/client/informers_generated/externalversions" + bindingUsageApi "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + bindingUsageClientset "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/clientset/versioned" + bindingUsageInformers "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/client/informers/externalversions" + "github.com/pkg/errors" + "k8s.io/client-go/rest" +) + +type Container struct { + Resolver *Resolver + ServiceBindingUsageLister ServiceBindingUsageLister + ServiceBindingGetter ServiceBindingGetter +} + +type ServiceBindingUsageLister interface { + ListForDeployment(environment, kind, deploymentName string) ([]*bindingUsageApi.ServiceBindingUsage, error) +} + +type ServiceBindingGetter interface { + Find(env string, name string) (*bindingApi.ServiceBinding, error) +} + +type Resolver struct { + *classResolver + *instanceResolver + *brokerResolver + *serviceBindingResolver + *serviceBindingUsageResolver + + informerFactory catalogInformers.SharedInformerFactory + bindingUsageInformerFactory bindingUsageInformers.SharedInformerFactory +} + +func New(restConfig *rest.Config, informerResyncPeriod time.Duration, asyncApiSpecGetter AsyncApiSpecGetter, apiSpecGetter ApiSpecGetter, contentGetter ContentGetter) (*Container, error) { + client, err := clientset.NewForConfig(restConfig) + if err != nil { + return nil, errors.Wrap(err, "while initializing Clientset") + } + + informerFactory := catalogInformers.NewSharedInformerFactory(client, informerResyncPeriod) + instanceService := newInstanceService(informerFactory.Servicecatalog().V1beta1().ServiceInstances().Informer(), client) + classService := newClassService(informerFactory.Servicecatalog().V1beta1().ClusterServiceClasses().Informer()) + planService := newPlanService(informerFactory.Servicecatalog().V1beta1().ClusterServicePlans().Informer()) + brokerService := newBrokerService(informerFactory.Servicecatalog().V1beta1().ClusterServiceBrokers().Informer()) + bindingService := newServiceBindingService(client.ServicecatalogV1beta1(), informerFactory.Servicecatalog().V1beta1().ServiceBindings().Informer()) + + bindingUsageClient, err := bindingUsageClientset.NewForConfig(restConfig) + if err != nil { + return nil, errors.Wrap(err, "while initializing Binding Usage Clientset") + } + + bindingUsageInformerFactory := bindingUsageInformers.NewSharedInformerFactory(bindingUsageClient, informerResyncPeriod) + bindingUsageService := newServiceBindingUsageService(bindingUsageClient.ServicecatalogV1alpha1(), bindingUsageInformerFactory.Servicecatalog().V1alpha1().ServiceBindingUsages().Informer(), bindingService) + + return &Container{ + Resolver: &Resolver{ + informerFactory: informerFactory, + bindingUsageInformerFactory: bindingUsageInformerFactory, + instanceResolver: newInstanceResolver(instanceService, planService, classService), + brokerResolver: newBrokerResolver(brokerService), + classResolver: newClassResolver(classService, planService, instanceService, asyncApiSpecGetter, apiSpecGetter, contentGetter), + serviceBindingResolver: newServiceBindingResolver(bindingService), + serviceBindingUsageResolver: newServiceBindingUsageResolver(bindingUsageService), + }, + ServiceBindingUsageLister: bindingUsageService, + ServiceBindingGetter: bindingService, + }, nil +} + +func (r *Resolver) WaitForCacheSync(stopCh <-chan struct{}) { + r.informerFactory.Start(stopCh) + r.informerFactory.WaitForCacheSync(stopCh) + r.bindingUsageInformerFactory.Start(stopCh) + r.bindingUsageInformerFactory.WaitForCacheSync(stopCh) +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/status/binding.go b/components/ui-api-layer/internal/domain/servicecatalog/status/binding.go new file mode 100644 index 000000000000..245395a269a3 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/status/binding.go @@ -0,0 +1,71 @@ +package status + +import ( + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +) + +type BindingExtractor struct{} + +func (ext *BindingExtractor) Status(conditions []v1beta1.ServiceBindingCondition) gqlschema.ServiceBindingStatus { + activeConditions := ext.findActiveConditions(conditions) + + if len(conditions) == 0 { + return gqlschema.ServiceBindingStatus{ + Type: gqlschema.ServiceBindingStatusTypePending, + } + } + if cond, found := ext.findReadyCondition(activeConditions); found { + return gqlschema.ServiceBindingStatus{ + Type: gqlschema.ServiceBindingStatusTypeReady, + Message: cond.Message, + Reason: cond.Reason, + } + } + if cond, found := ext.findReadyCondition(conditions); found { + return gqlschema.ServiceBindingStatus{ + Type: gqlschema.ServiceBindingStatusTypeFailed, + Message: cond.Message, + Reason: cond.Reason, + } + } + if cond, found := ext.findFailedCondition(activeConditions); found { + return gqlschema.ServiceBindingStatus{ + Type: gqlschema.ServiceBindingStatusTypeFailed, + Message: cond.Message, + Reason: cond.Reason, + } + } + + return gqlschema.ServiceBindingStatus{ + Type: gqlschema.ServiceBindingStatusTypeUnknown, + } +} + +func (*BindingExtractor) findActiveConditions(conditions []v1beta1.ServiceBindingCondition) []v1beta1.ServiceBindingCondition { + var result []v1beta1.ServiceBindingCondition + for _, cond := range conditions { + if cond.Status == v1beta1.ConditionTrue { + result = append(result, cond) + } + } + return result +} + +func (*BindingExtractor) findReadyCondition(conditions []v1beta1.ServiceBindingCondition) (v1beta1.ServiceBindingCondition, bool) { + for _, item := range conditions { + if item.Type == v1beta1.ServiceBindingConditionReady { + return item, true + } + } + return v1beta1.ServiceBindingCondition{}, false +} + +func (*BindingExtractor) findFailedCondition(conditions []v1beta1.ServiceBindingCondition) (v1beta1.ServiceBindingCondition, bool) { + for _, item := range conditions { + if item.Type == v1beta1.ServiceBindingConditionFailed { + return item, true + } + } + return v1beta1.ServiceBindingCondition{}, false +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/status/binding_test.go b/components/ui-api-layer/internal/domain/servicecatalog/status/binding_test.go new file mode 100644 index 000000000000..4f145b3c1309 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/status/binding_test.go @@ -0,0 +1,69 @@ +package status + +import ( + "testing" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" +) + +func TestBindingExtractor_Status(t *testing.T) { + // GIVEN + ext := BindingExtractor{} + for tn, tc := range map[string]struct { + given []v1beta1.ServiceBindingCondition + expected gqlschema.ServiceBindingStatus + }{ + "ReadyStatus": { + given: []v1beta1.ServiceBindingCondition{ + { + Status: v1beta1.ConditionTrue, + Type: v1beta1.ServiceBindingConditionReady, + }, + }, + expected: gqlschema.ServiceBindingStatus{ + Type: gqlschema.ServiceBindingStatusTypeReady, + }, + }, + "FailedStatus": { + given: []v1beta1.ServiceBindingCondition{ + { + Status: v1beta1.ConditionFalse, + Reason: "error", + Message: "supa error", + Type: v1beta1.ServiceBindingConditionReady, + }, + }, + expected: gqlschema.ServiceBindingStatus{ + Type: gqlschema.ServiceBindingStatusTypeFailed, + Reason: "error", + Message: "supa error", + }, + }, + "EmptyStatus": { + given: []v1beta1.ServiceBindingCondition{}, + expected: gqlschema.ServiceBindingStatus{ + Type: gqlschema.ServiceBindingStatusTypePending, + }, + }, + "UnknownStatus": { + given: []v1beta1.ServiceBindingCondition{ + { + Type: "different", + }, + }, + expected: gqlschema.ServiceBindingStatus{ + Type: gqlschema.ServiceBindingStatusTypeUnknown, + }, + }, + } { + + t.Run(tn, func(t *testing.T) { + // WHEN + result := ext.Status(tc.given) + // THEN + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/status/binding_usage.go b/components/ui-api-layer/internal/domain/servicecatalog/status/binding_usage.go new file mode 100644 index 000000000000..3d02762efd51 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/status/binding_usage.go @@ -0,0 +1,55 @@ +package status + +import ( + "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +) + +type BindingUsageExtractor struct{} + +func (ext *BindingUsageExtractor) Status(conditions []v1alpha1.ServiceBindingUsageCondition) gqlschema.ServiceBindingUsageStatus { + activeConditions := ext.findActiveConditions(conditions) + + if len(conditions) == 0 { + return gqlschema.ServiceBindingUsageStatus{ + Type: gqlschema.ServiceBindingUsageStatusTypePending, + } + } + if cond, found := ext.findReadyCondition(activeConditions); found { + return gqlschema.ServiceBindingUsageStatus{ + Type: gqlschema.ServiceBindingUsageStatusTypeReady, + Message: cond.Message, + Reason: cond.Reason, + } + } + if cond, found := ext.findReadyCondition(conditions); found { + return gqlschema.ServiceBindingUsageStatus{ + Type: gqlschema.ServiceBindingUsageStatusTypeFailed, + Message: cond.Message, + Reason: cond.Reason, + } + } + + return gqlschema.ServiceBindingUsageStatus{ + Type: gqlschema.ServiceBindingUsageStatusTypeUnknown, + } +} + +func (*BindingUsageExtractor) findActiveConditions(conditions []v1alpha1.ServiceBindingUsageCondition) []v1alpha1.ServiceBindingUsageCondition { + var result []v1alpha1.ServiceBindingUsageCondition + for _, cond := range conditions { + if cond.Status == v1alpha1.ConditionTrue { + result = append(result, cond) + } + } + return result +} + +func (*BindingUsageExtractor) findReadyCondition(conditions []v1alpha1.ServiceBindingUsageCondition) (v1alpha1.ServiceBindingUsageCondition, bool) { + for _, item := range conditions { + if item.Type == v1alpha1.ServiceBindingUsageReady { + return item, true + } + } + return v1alpha1.ServiceBindingUsageCondition{}, false +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/status/binding_usage_test.go b/components/ui-api-layer/internal/domain/servicecatalog/status/binding_usage_test.go new file mode 100644 index 000000000000..ad84d3dcc816 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/status/binding_usage_test.go @@ -0,0 +1,69 @@ +package status + +import ( + "testing" + + "github.com/kyma-project/kyma/components/binding-usage-controller/pkg/apis/servicecatalog/v1alpha1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/stretchr/testify/assert" +) + +func TestBindingUsageExtractor_Status(t *testing.T) { + // GIVEN + ext := BindingUsageExtractor{} + for tn, tc := range map[string]struct { + given []v1alpha1.ServiceBindingUsageCondition + expected gqlschema.ServiceBindingUsageStatus + }{ + "ReadyStatus": { + given: []v1alpha1.ServiceBindingUsageCondition{ + { + Status: v1alpha1.ConditionTrue, + Type: v1alpha1.ServiceBindingUsageReady, + }, + }, + expected: gqlschema.ServiceBindingUsageStatus{ + Type: gqlschema.ServiceBindingUsageStatusTypeReady, + }, + }, + "FailedStatus": { + given: []v1alpha1.ServiceBindingUsageCondition{ + { + Status: v1alpha1.ConditionFalse, + Reason: "error", + Message: "supa error", + Type: v1alpha1.ServiceBindingUsageReady, + }, + }, + expected: gqlschema.ServiceBindingUsageStatus{ + Type: gqlschema.ServiceBindingUsageStatusTypeFailed, + Reason: "error", + Message: "supa error", + }, + }, + "EmptyStatus": { + given: []v1alpha1.ServiceBindingUsageCondition{}, + expected: gqlschema.ServiceBindingUsageStatus{ + Type: gqlschema.ServiceBindingUsageStatusTypePending, + }, + }, + "UnknownStatus": { + given: []v1alpha1.ServiceBindingUsageCondition{ + { + Type: "different", + }, + }, + expected: gqlschema.ServiceBindingUsageStatus{ + Type: gqlschema.ServiceBindingUsageStatusTypeUnknown, + }, + }, + } { + + t.Run(tn, func(t *testing.T) { + // WHEN + result := ext.Status(tc.given) + // THEN + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/status/instance.go b/components/ui-api-layer/internal/domain/servicecatalog/status/instance.go new file mode 100644 index 000000000000..1518edd7b2e3 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/status/instance.go @@ -0,0 +1,106 @@ +package status + +import ( + "strings" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" +) + +type InstanceExtractor struct{} + +type ServiceInstanceStatusType string + +const ( + ServiceInstanceStatusTypeRunning ServiceInstanceStatusType = "RUNNING" + ServiceInstanceStatusTypeProvisioning ServiceInstanceStatusType = "PROVISIONING" + ServiceInstanceStatusTypeDeprovisioning ServiceInstanceStatusType = "DEPROVISIONING" + ServiceInstanceStatusTypePending ServiceInstanceStatusType = "PENDING" + ServiceInstanceStatusTypeFailed ServiceInstanceStatusType = "FAILED" +) + +type ServiceInstanceStatus struct { + Type ServiceInstanceStatusType + Reason string + Message string +} + +func (ext *InstanceExtractor) Status(serviceInstance *v1beta1.ServiceInstance) *ServiceInstanceStatus { + if serviceInstance == nil { + return nil + } + + conditions := serviceInstance.Status.Conditions + activeCondition := ext.findActiveConditions(conditions) + + if len(conditions) == 0 { + return &ServiceInstanceStatus{ + Type: ServiceInstanceStatusTypePending, + } + } + if cond, found := ext.findConditionOfType(activeCondition, v1beta1.ServiceInstanceConditionReady); found { + return &ServiceInstanceStatus{ + Type: ServiceInstanceStatusTypeRunning, + Reason: cond.Reason, + Message: cond.Message, + } + } + if cond, found := ext.findConditionOfType(activeCondition, v1beta1.ServiceInstanceConditionFailed); found { + return &ServiceInstanceStatus{ + Type: ServiceInstanceStatusTypeFailed, + Reason: cond.Reason, + Message: cond.Message, + } + } + + condition, _ := ext.findConditionOfType(conditions, v1beta1.ServiceInstanceConditionReady) + return &ServiceInstanceStatus{ + Type: ext.getProvisioningStatus(condition.Reason), + Message: condition.Message, + Reason: condition.Reason, + } +} + +func (*InstanceExtractor) findActiveConditions(conditions []v1beta1.ServiceInstanceCondition) []v1beta1.ServiceInstanceCondition { + var result []v1beta1.ServiceInstanceCondition + for _, cond := range conditions { + if cond.Status == v1beta1.ConditionTrue { + result = append(result, cond) + } + } + return result +} + +func (*InstanceExtractor) findConditionOfType(conditions []v1beta1.ServiceInstanceCondition, typeOf v1beta1.ServiceInstanceConditionType) (v1beta1.ServiceInstanceCondition, bool) { + for _, cond := range conditions { + if cond.Type == typeOf { + return cond, true + } + } + return v1beta1.ServiceInstanceCondition{}, false +} + +func (ext *InstanceExtractor) getProvisioningStatus(reason string) ServiceInstanceStatusType { + failedStatus := []string{"Error", "Nonexistent", "Failed", "Deleted", "Invalid"} + provisionedStatus := []string{"Provision", "Updat"} + deprovisionedStatus := []string{"Deprovision"} + + switch { + case ext.containsReason(reason, failedStatus): + return ServiceInstanceStatusTypeFailed + case ext.containsReason(reason, provisionedStatus): + return ServiceInstanceStatusTypeProvisioning + case ext.containsReason(reason, deprovisionedStatus): + return ServiceInstanceStatusTypeDeprovisioning + default: + return ServiceInstanceStatusTypePending + } +} + +func (*InstanceExtractor) containsReason(reason string, subStrings []string) bool { + for _, subString := range subStrings { + if strings.Contains(reason, subString) { + return true + } + } + return false +} diff --git a/components/ui-api-layer/internal/domain/servicecatalog/status/instance_test.go b/components/ui-api-layer/internal/domain/servicecatalog/status/instance_test.go new file mode 100644 index 000000000000..c04962600e73 --- /dev/null +++ b/components/ui-api-layer/internal/domain/servicecatalog/status/instance_test.go @@ -0,0 +1,205 @@ +package status + +import ( + "testing" + + "github.com/kubernetes-incubator/service-catalog/pkg/apis/servicecatalog/v1beta1" + "github.com/stretchr/testify/assert" +) + +func TestInstanceExtractor_Status(t *testing.T) { + ext := InstanceExtractor{} + for tn, tc := range map[string]struct { + given []v1beta1.ServiceInstanceCondition + expected ServiceInstanceStatus + }{ + "ReadyStatus": { + given: []v1beta1.ServiceInstanceCondition{ + { + Type: v1beta1.ServiceInstanceConditionReady, + Status: v1beta1.ConditionTrue, + }, { + Type: v1beta1.ServiceInstanceConditionFailed, + Status: v1beta1.ConditionFalse, + }, + }, + expected: ServiceInstanceStatus{ + Type: ServiceInstanceStatusTypeRunning, + }, + }, + "FailedStatus": { + given: []v1beta1.ServiceInstanceCondition{ + { + Type: v1beta1.ServiceInstanceConditionReady, + Status: v1beta1.ConditionFalse, + }, { + Type: v1beta1.ServiceInstanceConditionFailed, + Status: v1beta1.ConditionTrue, + }, + }, + expected: ServiceInstanceStatus{ + Type: ServiceInstanceStatusTypeFailed, + }, + }, + "FailedStatusv2": { + given: []v1beta1.ServiceInstanceCondition{ + { + Type: v1beta1.ServiceInstanceConditionReady, + Status: v1beta1.ConditionFalse, + Reason: "ProvisionCallFailed", + }, + }, + expected: ServiceInstanceStatus{ + Type: ServiceInstanceStatusTypeFailed, + Reason: "ProvisionCallFailed", + }, + }, + "FailedStatusv3": { + given: []v1beta1.ServiceInstanceCondition{ + { + Type: v1beta1.ServiceInstanceConditionReady, + Status: v1beta1.ConditionFalse, + Reason: "ReferencesNonexistentServiceClass", + }, + }, + expected: ServiceInstanceStatus{ + Type: ServiceInstanceStatusTypeFailed, + Reason: "ReferencesNonexistentServiceClass", + }, + }, + "FailedStatusv4": { + given: []v1beta1.ServiceInstanceCondition{ + { + Type: v1beta1.ServiceInstanceConditionReady, + Status: v1beta1.ConditionFalse, + Reason: "ErrorFindingNamespaceForInstance", + }, + }, + expected: ServiceInstanceStatus{ + Type: ServiceInstanceStatusTypeFailed, + Reason: "ErrorFindingNamespaceForInstance", + }, + }, + "FailedStatusv5": { + given: []v1beta1.ServiceInstanceCondition{ + { + Type: v1beta1.ServiceInstanceConditionReady, + Status: v1beta1.ConditionFalse, + Reason: "InvalidDeprovisionStatus", + }, + }, + expected: ServiceInstanceStatus{ + Type: ServiceInstanceStatusTypeFailed, + Reason: "InvalidDeprovisionStatus", + }, + }, + "PendingStatus": { + given: []v1beta1.ServiceInstanceCondition{}, + expected: ServiceInstanceStatus{ + Type: ServiceInstanceStatusTypePending, + }, + }, + "PendingStatusv2": { + given: []v1beta1.ServiceInstanceCondition{ + { + Type: v1beta1.ServiceInstanceConditionReady, + Status: v1beta1.ConditionFalse, + Reason: "UnknownReason", + }, + }, + expected: ServiceInstanceStatus{ + Type: ServiceInstanceStatusTypePending, + Reason: "UnknownReason", + }, + }, + "ProvisioningStatus": { + given: []v1beta1.ServiceInstanceCondition{ + { + Type: v1beta1.ServiceInstanceConditionReady, + Status: v1beta1.ConditionFalse, + Reason: "Provisioning", + }, + }, + expected: ServiceInstanceStatus{ + Type: ServiceInstanceStatusTypeProvisioning, + Reason: "Provisioning", + }, + }, + "ProvisioningStatusv2": { + given: []v1beta1.ServiceInstanceCondition{ + { + Type: v1beta1.ServiceInstanceConditionReady, + Status: v1beta1.ConditionFalse, + Reason: "ProvisionRequestInFlight", + }, + }, + expected: ServiceInstanceStatus{ + Type: ServiceInstanceStatusTypeProvisioning, + Reason: "ProvisionRequestInFlight", + }, + }, + "ProvisioningStatusv3": { + given: []v1beta1.ServiceInstanceCondition{ + { + Type: v1beta1.ServiceInstanceConditionReady, + Status: v1beta1.ConditionFalse, + Reason: "UpdatingInstance", + }, + }, + expected: ServiceInstanceStatus{ + Type: ServiceInstanceStatusTypeProvisioning, + Reason: "UpdatingInstance", + }, + }, + "ProvisioningStatusv4": { + given: []v1beta1.ServiceInstanceCondition{ + { + Type: v1beta1.ServiceInstanceConditionReady, + Status: v1beta1.ConditionFalse, + Reason: "UpdateInstanceRequestInFlight", + }, + }, + expected: ServiceInstanceStatus{ + Type: ServiceInstanceStatusTypeProvisioning, + Reason: "UpdateInstanceRequestInFlight", + }, + }, + "DeprovisioningStatus": { + given: []v1beta1.ServiceInstanceCondition{ + { + Type: v1beta1.ServiceInstanceConditionReady, + Status: v1beta1.ConditionFalse, + Reason: "Deprovisioning", + }, + }, + expected: ServiceInstanceStatus{ + Type: ServiceInstanceStatusTypeDeprovisioning, + Reason: "Deprovisioning", + }, + }, + "DeprovisioningStatusv2": { + given: []v1beta1.ServiceInstanceCondition{ + { + Type: v1beta1.ServiceInstanceConditionReady, + Status: v1beta1.ConditionFalse, + Reason: "DeprovisionRequestInFlight", + }, + }, + expected: ServiceInstanceStatus{ + Type: ServiceInstanceStatusTypeDeprovisioning, + Reason: "DeprovisionRequestInFlight", + }, + }, + } { + instance := &v1beta1.ServiceInstance{ + Status: v1beta1.ServiceInstanceStatus{ + AsyncOpInProgress: false, + Conditions: tc.given, + }, + } + t.Run(tn, func(t *testing.T) { + result := ext.Status(instance) + assert.Equal(t, &tc.expected, result) + }) + } +} diff --git a/components/ui-api-layer/internal/domain/ui/automock/export.go b/components/ui-api-layer/internal/domain/ui/automock/export.go new file mode 100644 index 000000000000..514d7d3aa61d --- /dev/null +++ b/components/ui-api-layer/internal/domain/ui/automock/export.go @@ -0,0 +1,9 @@ +package automock + +func NewIDPPresetSvc() *idpPresetSvc { + return new(idpPresetSvc) +} + +func NewGQLIDPPresetConverter() *gqlIDPPresetConverter { + return new(gqlIDPPresetConverter) +} diff --git a/components/ui-api-layer/internal/domain/ui/automock/gql_idp_preset_converter.go b/components/ui-api-layer/internal/domain/ui/automock/gql_idp_preset_converter.go new file mode 100644 index 000000000000..12d7e9725328 --- /dev/null +++ b/components/ui-api-layer/internal/domain/ui/automock/gql_idp_preset_converter.go @@ -0,0 +1,26 @@ +// Code generated by mockery v1.0.0 +package automock + +import gqlschema "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +import mock "github.com/stretchr/testify/mock" + +import v1alpha1 "github.com/kyma-project/kyma/components/idppreset/pkg/apis/ui/v1alpha1" + +// gqlIDPPresetConverter is an autogenerated mock type for the gqlIDPPresetConverter type +type gqlIDPPresetConverter struct { + mock.Mock +} + +// ToGQL provides a mock function with given fields: in +func (_m *gqlIDPPresetConverter) ToGQL(in *v1alpha1.IDPPreset) gqlschema.IDPPreset { + ret := _m.Called(in) + + var r0 gqlschema.IDPPreset + if rf, ok := ret.Get(0).(func(*v1alpha1.IDPPreset) gqlschema.IDPPreset); ok { + r0 = rf(in) + } else { + r0 = ret.Get(0).(gqlschema.IDPPreset) + } + + return r0 +} diff --git a/components/ui-api-layer/internal/domain/ui/automock/idp_preset_svc.go b/components/ui-api-layer/internal/domain/ui/automock/idp_preset_svc.go new file mode 100644 index 000000000000..867cc626010a --- /dev/null +++ b/components/ui-api-layer/internal/domain/ui/automock/idp_preset_svc.go @@ -0,0 +1,95 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" +import pager "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + +import v1alpha1 "github.com/kyma-project/kyma/components/idppreset/pkg/apis/ui/v1alpha1" + +// idpPresetSvc is an autogenerated mock type for the idpPresetSvc type +type idpPresetSvc struct { + mock.Mock +} + +// Create provides a mock function with given fields: name, issuer, jwksUri +func (_m *idpPresetSvc) Create(name string, issuer string, jwksUri string) (*v1alpha1.IDPPreset, error) { + ret := _m.Called(name, issuer, jwksUri) + + var r0 *v1alpha1.IDPPreset + if rf, ok := ret.Get(0).(func(string, string, string) *v1alpha1.IDPPreset); ok { + r0 = rf(name, issuer, jwksUri) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1alpha1.IDPPreset) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string, string) error); ok { + r1 = rf(name, issuer, jwksUri) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: name +func (_m *idpPresetSvc) Delete(name string) error { + ret := _m.Called(name) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(name) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Find provides a mock function with given fields: name +func (_m *idpPresetSvc) Find(name string) (*v1alpha1.IDPPreset, error) { + ret := _m.Called(name) + + var r0 *v1alpha1.IDPPreset + if rf, ok := ret.Get(0).(func(string) *v1alpha1.IDPPreset); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1alpha1.IDPPreset) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// List provides a mock function with given fields: params +func (_m *idpPresetSvc) List(params pager.PagingParams) ([]*v1alpha1.IDPPreset, error) { + ret := _m.Called(params) + + var r0 []*v1alpha1.IDPPreset + if rf, ok := ret.Get(0).(func(pager.PagingParams) []*v1alpha1.IDPPreset); ok { + r0 = rf(params) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v1alpha1.IDPPreset) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(pager.PagingParams) error); ok { + r1 = rf(params) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/domain/ui/export_test.go b/components/ui-api-layer/internal/domain/ui/export_test.go new file mode 100644 index 000000000000..8799186e62ff --- /dev/null +++ b/components/ui-api-layer/internal/domain/ui/export_test.go @@ -0,0 +1,18 @@ +package ui + +import ( + idppresetv1alpha1 "github.com/kyma-project/kyma/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1" + "k8s.io/client-go/tools/cache" +) + +func (r *idpPresetResolver) SetIDPPresetConverter(c gqlIDPPresetConverter) { + r.idpPresetConverter = c +} + +func NewIDPPresetService(client idppresetv1alpha1.UiV1alpha1Interface, informer cache.SharedIndexInformer) *idpPresetService { + return newIDPPresetService(client, informer) +} + +func NewIDPPresetResolver(service idpPresetSvc) *idpPresetResolver { + return newIDPPresetResolver(service) +} diff --git a/components/ui-api-layer/internal/domain/ui/helpers_test.go b/components/ui-api-layer/internal/domain/ui/helpers_test.go new file mode 100644 index 000000000000..abb56d3741f9 --- /dev/null +++ b/components/ui-api-layer/internal/domain/ui/helpers_test.go @@ -0,0 +1,36 @@ +package ui_test + +import ( + "github.com/kyma-project/kyma/components/idppreset/pkg/apis/ui/v1alpha1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func fixIDPPreset() *v1alpha1.IDPPreset { + return &v1alpha1.IDPPreset{ + ObjectMeta: metaV1.ObjectMeta{ + Name: "fixIDPPreset", + }, + Spec: v1alpha1.IDPPresetSpec{ + Name: "fixIDPPreset", + JwksUri: "uri", + Issuer: "issuer", + }, + } +} + +func fixIDPPresets() []*v1alpha1.IDPPreset { + return []*v1alpha1.IDPPreset{fixIDPPreset()} +} + +func fixIDPPresetGQL() gqlschema.IDPPreset { + return gqlschema.IDPPreset{ + Name: "fixIDPPreset", + JwksUri: "uri", + Issuer: "issuer", + } +} + +func fixIDPPresetsGQL() []gqlschema.IDPPreset { + return []gqlschema.IDPPreset{fixIDPPresetGQL()} +} diff --git a/components/ui-api-layer/internal/domain/ui/idppreset_converter.go b/components/ui-api-layer/internal/domain/ui/idppreset_converter.go new file mode 100644 index 000000000000..636bf80f832a --- /dev/null +++ b/components/ui-api-layer/internal/domain/ui/idppreset_converter.go @@ -0,0 +1,20 @@ +package ui + +import ( + "github.com/kyma-project/kyma/components/idppreset/pkg/apis/ui/v1alpha1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" +) + +type idpPresetConverter struct{} + +func (c *idpPresetConverter) ToGQL(in *v1alpha1.IDPPreset) gqlschema.IDPPreset { + if in == nil { + return gqlschema.IDPPreset{} + } + + return gqlschema.IDPPreset{ + Name: in.Name, + Issuer: in.Spec.Issuer, + JwksUri: in.Spec.JwksUri, + } +} diff --git a/components/ui-api-layer/internal/domain/ui/idppreset_converter_test.go b/components/ui-api-layer/internal/domain/ui/idppreset_converter_test.go new file mode 100644 index 000000000000..48bf46c987cb --- /dev/null +++ b/components/ui-api-layer/internal/domain/ui/idppreset_converter_test.go @@ -0,0 +1,57 @@ +package ui + +import ( + "testing" + + "github.com/kyma-project/kyma/components/idppreset/pkg/apis/ui/v1alpha1" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestIDPPresetConverter_ToGQL(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // given + fix := v1alpha1.IDPPreset{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + }, + Spec: v1alpha1.IDPPresetSpec{ + Name: "name", + Issuer: "issuer", + JwksUri: "jwksUri", + }, + } + + converter := &idpPresetConverter{} + + // when + dto := converter.ToGQL(&fix) + + // then + assert.Equal(t, dto.Name, fix.Spec.Name) + assert.Equal(t, dto.Issuer, fix.Spec.Issuer) + assert.Equal(t, dto.JwksUri, fix.Spec.JwksUri) + }) + + t.Run("Empty", func(t *testing.T) { + // given + converter := &idpPresetConverter{} + + // when + result := converter.ToGQL(&v1alpha1.IDPPreset{}) + + // then + assert.Empty(t, result) + }) + + t.Run("Nil", func(t *testing.T) { + // given + converter := &idpPresetConverter{} + + // when + result := converter.ToGQL(nil) + + // then + assert.Empty(t, result) + }) +} diff --git a/components/ui-api-layer/internal/domain/ui/idppreset_resolver.go b/components/ui-api-layer/internal/domain/ui/idppreset_resolver.go new file mode 100644 index 000000000000..07c9ca3d2528 --- /dev/null +++ b/components/ui-api-layer/internal/domain/ui/idppreset_resolver.go @@ -0,0 +1,105 @@ +package ui + +import ( + "context" + "fmt" + + "github.com/golang/glog" + "github.com/kyma-project/kyma/components/idppreset/pkg/apis/ui/v1alpha1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + "github.com/pkg/errors" + apiErrors "k8s.io/apimachinery/pkg/api/errors" +) + +//go:generate mockery -name=idpPresetSvc -output=automock -outpkg=automock -case=underscore +type idpPresetSvc interface { + Create(name string, issuer string, jwksUri string) (*v1alpha1.IDPPreset, error) + Delete(name string) error + Find(name string) (*v1alpha1.IDPPreset, error) + List(params pager.PagingParams) ([]*v1alpha1.IDPPreset, error) +} + +//go:generate mockery -name=gqlIDPPresetConverter -output=automock -outpkg=automock -case=underscore +type gqlIDPPresetConverter interface { + ToGQL(in *v1alpha1.IDPPreset) gqlschema.IDPPreset +} + +type idpPresetResolver struct { + idpPresetSvc idpPresetSvc + idpPresetConverter gqlIDPPresetConverter +} + +func newIDPPresetResolver(idpPresetSvc idpPresetSvc) *idpPresetResolver { + return &idpPresetResolver{ + idpPresetSvc: idpPresetSvc, + idpPresetConverter: &idpPresetConverter{}, + } +} + +func (r *idpPresetResolver) CreateIDPPresetMutation(ctx context.Context, name string, issuer string, jwksUri string) (*gqlschema.IDPPreset, error) { + item, err := r.idpPresetSvc.Create(name, issuer, jwksUri) + switch { + case apiErrors.IsAlreadyExists(err): + return nil, fmt.Errorf("IDP Preset with the name `%s` already exists", name) + case err != nil: + glog.Error(errors.Wrapf(err, "while creating IDP Preset `%s`", name)) + return nil, fmt.Errorf("Cannot create IDP Preset `%s`", name) + } + + idpPreset := r.idpPresetConverter.ToGQL(item) + + return &idpPreset, nil +} + +func (r *idpPresetResolver) DeleteIDPPresetMutation(ctx context.Context, name string) (*gqlschema.IDPPreset, error) { + idpPreset, err := r.idpPresetSvc.Find(name) + if err != nil { + glog.Error(errors.Wrapf(err, "while finding IDP Preset `%s`", name)) + return nil, fmt.Errorf("Cannot delete IDP Preset `%s`", name) + } + if idpPreset == nil { + return nil, fmt.Errorf("Cannot find IDP Preset `%s`", name) + } + + idpPresetCopy := idpPreset.DeepCopy() + err = r.idpPresetSvc.Delete(name) + if err != nil { + glog.Error(errors.Wrapf(err, "while deleting IDP Preset `%s`", name)) + return nil, fmt.Errorf("Cannot delete IDP Preset `%s`", name) + } + + deletedIdpPreset := r.idpPresetConverter.ToGQL(idpPresetCopy) + + return &deletedIdpPreset, nil +} + +func (r *idpPresetResolver) IDPPresetQuery(ctx context.Context, name string) (*gqlschema.IDPPreset, error) { + idpObj, err := r.idpPresetSvc.Find(name) + if err != nil { + glog.Error(errors.Wrapf(err, "while getting IDP Preset")) + return nil, fmt.Errorf("Cannot query IDP Preset with name `%s`", name) + } + if idpObj == nil { + return nil, nil + } + + idpPreset := r.idpPresetConverter.ToGQL(idpObj) + + return &idpPreset, nil +} + +func (r *idpPresetResolver) IDPPresetsQuery(ctx context.Context, first *int, offset *int) ([]gqlschema.IDPPreset, error) { + items, err := r.idpPresetSvc.List(pager.PagingParams{First: first, Offset: offset}) + if err != nil { + glog.Error(errors.Wrapf(err, "while listing IDP Presets")) + return []gqlschema.IDPPreset{}, fmt.Errorf("Cannot query IDP Presets") + } + + idpPresets := make([]gqlschema.IDPPreset, 0, len(items)) + for _, item := range items { + idpPresets = append(idpPresets, r.idpPresetConverter.ToGQL(item)) + } + + return idpPresets, nil +} diff --git a/components/ui-api-layer/internal/domain/ui/idppreset_resolver_test.go b/components/ui-api-layer/internal/domain/ui/idppreset_resolver_test.go new file mode 100644 index 000000000000..0275e3d680f2 --- /dev/null +++ b/components/ui-api-layer/internal/domain/ui/idppreset_resolver_test.go @@ -0,0 +1,314 @@ +package ui_test + +import ( + "context" + "fmt" + "testing" + + "github.com/kyma-project/kyma/components/idppreset/pkg/apis/ui/v1alpha1" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/ui" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/ui/automock" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestIDPPresetResolver_CreateIDPPresetMutation(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // given + var ( + fixName = "testName" + fixIssuer = "testIssuer" + fixJwksURI = "testJwksURI" + fixIDPPresetObj = fixIDPPreset() + fixGQLIDPPresetObj = fixIDPPresetGQL() + ) + + svc := automock.NewIDPPresetSvc() + defer svc.AssertExpectations(t) + svc.On("Create", fixName, fixIssuer, fixJwksURI).Return(fixIDPPresetObj, nil) + + converterMock := automock.NewGQLIDPPresetConverter() + defer converterMock.AssertExpectations(t) + converterMock.On("ToGQL", fixIDPPresetObj).Return(fixGQLIDPPresetObj) + + resolver := ui.NewIDPPresetResolver(svc) + resolver.SetIDPPresetConverter(converterMock) + + // when + gotIDP, err := resolver.CreateIDPPresetMutation(context.Background(), fixName, fixIssuer, fixJwksURI) + + // then + require.NoError(t, err) + require.NotNil(t, gotIDP) + assert.Equal(t, fixGQLIDPPresetObj, *gotIDP) + }) + + t.Run("Already exists", func(t *testing.T) { + // given + var ( + fixName = "testName" + fixIssuer = "testIssuer" + fixJwksURI = "testJwksURI" + fixErr = apiErrors.NewAlreadyExists(schema.GroupResource{}, "exists") + ) + + svc := automock.NewIDPPresetSvc() + defer svc.AssertExpectations(t) + svc.On("Create", fixName, fixIssuer, fixJwksURI).Return(nil, fixErr) + + resolver := ui.NewIDPPresetResolver(svc) + + // when + gotIDP, err := resolver.CreateIDPPresetMutation(context.Background(), fixName, fixIssuer, fixJwksURI) + + // then + require.Error(t, err) + require.Nil(t, gotIDP) + assert.Equal(t, fmt.Sprintf("IDP Preset with the name `%s` already exists", fixName), err.Error()) + }) + + t.Run("Error", func(t *testing.T) { + // given + var ( + fixName = "testName" + fixIssuer = "testIssuer" + fixJwksURI = "testJwksURI" + fixErr = errors.New("something went wrong") + ) + + svc := automock.NewIDPPresetSvc() + defer svc.AssertExpectations(t) + svc.On("Create", fixName, fixIssuer, fixJwksURI).Return(nil, fixErr) + + resolver := ui.NewIDPPresetResolver(svc) + + // when + gotIDP, err := resolver.CreateIDPPresetMutation(context.Background(), fixName, fixIssuer, fixJwksURI) + + // then + require.Error(t, err) + require.Nil(t, gotIDP) + assert.Equal(t, fmt.Sprintf("Cannot create IDP Preset `%s`", fixName), err.Error()) + }) +} + +func TestIDPPresetResolver_DeleteIDPPresetMutation(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // given + var ( + fixName = "testName" + fixIDPPresetObj = fixIDPPreset() + fixGQLIDPPresetObj = fixIDPPresetGQL() + ) + + svc := automock.NewIDPPresetSvc() + defer svc.AssertExpectations(t) + svc.On("Find", fixName).Return(fixIDPPresetObj, nil) + svc.On("Delete", fixName).Return(nil, nil) + + converterMock := automock.NewGQLIDPPresetConverter() + defer converterMock.AssertExpectations(t) + converterMock.On("ToGQL", fixIDPPresetObj).Return(fixGQLIDPPresetObj) + + resolver := ui.NewIDPPresetResolver(svc) + resolver.SetIDPPresetConverter(converterMock) + + // when + gotIDP, err := resolver.DeleteIDPPresetMutation(context.Background(), fixName) + + // then + require.NoError(t, err) + require.NotNil(t, gotIDP) + assert.Equal(t, fixGQLIDPPresetObj, *gotIDP) + }) + + t.Run("Not found", func(t *testing.T) { + // given + fixName := "testName" + + svc := automock.NewIDPPresetSvc() + defer svc.AssertExpectations(t) + svc.On("Find", fixName).Return(nil, nil) + + resolver := ui.NewIDPPresetResolver(svc) + + // when + gotIDP, err := resolver.DeleteIDPPresetMutation(context.Background(), fixName) + + // then + require.Error(t, err) + require.Nil(t, gotIDP) + assert.Equal(t, fmt.Sprintf("Cannot find IDP Preset `%s`", fixName), err.Error()) + }) + + t.Run("Error", func(t *testing.T) { + // given + var ( + fixName = "testName" + fixIDPPresetObj = fixIDPPreset() + fixErr = errors.New("something went wrong") + ) + + svc := automock.NewIDPPresetSvc() + defer svc.AssertExpectations(t) + svc.On("Find", fixName).Return(fixIDPPresetObj, nil) + svc.On("Delete", fixName).Return(fixErr) + + resolver := ui.NewIDPPresetResolver(svc) + + // when + gotIDP, err := resolver.DeleteIDPPresetMutation(context.Background(), fixName) + + require.Error(t, err) + require.Nil(t, gotIDP) + assert.EqualError(t, err, fmt.Sprintf("Cannot delete IDP Preset `%s`", fixName)) + }) +} + +func TestIDPPresetResolver_IDPPresetQuery(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // given + var ( + fixName = "testName" + fixIDPPresetObj = fixIDPPreset() + fixGQLIDPPresetObj = fixIDPPresetGQL() + ) + + svc := automock.NewIDPPresetSvc() + defer svc.AssertExpectations(t) + svc.On("Find", fixName).Return(fixIDPPresetObj, nil) + + converterMock := automock.NewGQLIDPPresetConverter() + defer converterMock.AssertExpectations(t) + converterMock.On("ToGQL", fixIDPPresetObj).Return(fixGQLIDPPresetObj) + + resolver := ui.NewIDPPresetResolver(svc) + resolver.SetIDPPresetConverter(converterMock) + + // when + gotIDP, err := resolver.IDPPresetQuery(context.Background(), fixName) + + // then + require.NoError(t, err) + require.NotNil(t, gotIDP) + assert.Equal(t, fixGQLIDPPresetObj, *gotIDP) + }) + + t.Run("Not found", func(t *testing.T) { + // given + fixName := "testName" + + svc := automock.NewIDPPresetSvc() + defer svc.AssertExpectations(t) + svc.On("Find", fixName).Return(nil, nil) + + resolver := ui.NewIDPPresetResolver(svc) + + // when + gotIDP, err := resolver.IDPPresetQuery(context.Background(), fixName) + + // then + require.NoError(t, err) + require.Nil(t, gotIDP) + }) + + t.Run("Error", func(t *testing.T) { + // given + var ( + fixName = "testName" + fixErr = errors.New("something went wrong") + ) + + svc := automock.NewIDPPresetSvc() + defer svc.AssertExpectations(t) + svc.On("Find", fixName).Return(nil, fixErr) + + resolver := ui.NewIDPPresetResolver(svc) + + // when + gotIDP, err := resolver.IDPPresetQuery(context.Background(), fixName) + + require.Error(t, err) + require.Nil(t, gotIDP) + assert.Equal(t, fmt.Sprintf("Cannot query IDP Preset with name `%s`", fixName), err.Error()) + }) +} + +func TestIDPPresetResolver_IDPPresetsQuery(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // given + var ( + fixIDPPresets = fixIDPPresets() + fixIDPPreset = fixIDPPreset() + fixGQLIDPPreset = fixIDPPresetGQL() + fixGQLIDPPresets = fixIDPPresetsGQL() + ) + + svc := automock.NewIDPPresetSvc() + defer svc.AssertExpectations(t) + svc.On("List", pager.PagingParams{}).Return(fixIDPPresets, nil) + + converterMock := automock.NewGQLIDPPresetConverter() + defer converterMock.AssertExpectations(t) + converterMock.On("ToGQL", fixIDPPreset).Return(fixGQLIDPPreset) + + resolver := ui.NewIDPPresetResolver(svc) + resolver.SetIDPPresetConverter(converterMock) + + // when + gotIDPs, err := resolver.IDPPresetsQuery(context.Background(), nil, nil) + + // then + require.NoError(t, err) + assert.Equal(t, fixGQLIDPPresets, gotIDPs) + }) + + t.Run("Not found", func(t *testing.T) { + // given + var ( + fixEmptyIDPPresetsArray = []*v1alpha1.IDPPreset{} + fixEmptyGQLIDPPresetsArray = []gqlschema.IDPPreset{} + ) + + svc := automock.NewIDPPresetSvc() + defer svc.AssertExpectations(t) + svc.On("List", pager.PagingParams{}).Return(fixEmptyIDPPresetsArray, nil) + + resolver := ui.NewIDPPresetResolver(svc) + + // when + gotIDPs, err := resolver.IDPPresetsQuery(context.Background(), nil, nil) + + // then + require.NoError(t, err) + assert.Equal(t, fixEmptyGQLIDPPresetsArray, gotIDPs) + }) + + t.Run("Error", func(t *testing.T) { + // given + var ( + fixEmptyIDPPresetsArray = []*v1alpha1.IDPPreset{} + fixEmptyGQLIDPPresetsArray = []gqlschema.IDPPreset{} + fixErr = errors.New("something went wrong") + ) + + svc := automock.NewIDPPresetSvc() + defer svc.AssertExpectations(t) + svc.On("List", pager.PagingParams{}).Return(fixEmptyIDPPresetsArray, fixErr) + + resolver := ui.NewIDPPresetResolver(svc) + + // when + gotIDPs, err := resolver.IDPPresetsQuery(context.Background(), nil, nil) + + require.Error(t, err) + assert.Equal(t, fixEmptyGQLIDPPresetsArray, gotIDPs) + assert.Len(t, gotIDPs, 0) + assert.Equal(t, fmt.Sprintf("Cannot query IDP Presets"), err.Error()) + }) +} diff --git a/components/ui-api-layer/internal/domain/ui/idppreset_service.go b/components/ui-api-layer/internal/domain/ui/idppreset_service.go new file mode 100644 index 000000000000..9edf4905d3b9 --- /dev/null +++ b/components/ui-api-layer/internal/domain/ui/idppreset_service.go @@ -0,0 +1,84 @@ +package ui + +import ( + "fmt" + + "github.com/kyma-project/kyma/components/idppreset/pkg/apis/ui/v1alpha1" + idppresetv1alpha1 "github.com/kyma-project/kyma/components/idppreset/pkg/client/clientset/versioned/typed/ui/v1alpha1" + "github.com/pkg/errors" + "k8s.io/client-go/tools/cache" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type idpPresetService struct { + client idppresetv1alpha1.UiV1alpha1Interface + idpPresetInformer cache.SharedIndexInformer +} + +func newIDPPresetService(client idppresetv1alpha1.UiV1alpha1Interface, reInformer cache.SharedIndexInformer) *idpPresetService { + return &idpPresetService{ + idpPresetInformer: reInformer, + client: client, + } +} + +func (svc *idpPresetService) Create(name string, issuer string, jwksUri string) (*v1alpha1.IDPPreset, error) { + idpPreset := v1alpha1.IDPPreset{ + TypeMeta: v1.TypeMeta{ + APIVersion: "ui.kyma.cx/v1alpha1", + Kind: "IDPPreset", + }, + ObjectMeta: v1.ObjectMeta{ + Name: name, + }, + Spec: v1alpha1.IDPPresetSpec{ + Name: name, + Issuer: issuer, + JwksUri: jwksUri, + }, + } + + return svc.client.IDPPresets().Create(&idpPreset) +} + +func (svc *idpPresetService) Delete(name string) error { + return svc.client.IDPPresets().Delete(name, nil) +} + +func (svc *idpPresetService) Find(name string) (*v1alpha1.IDPPreset, error) { + idpObj, exists, err := svc.idpPresetInformer.GetStore().GetByKey(name) + if err != nil { + return nil, errors.Wrapf(err, "while getting IDPPreset %s", name) + } + if !exists { + return nil, nil + } + + res, ok := idpObj.(*v1alpha1.IDPPreset) + if !ok { + return nil, fmt.Errorf("Incorrect item type: %T, should be: *v1alpha1.IDPPreset", res) + } + + return res, nil +} + +func (svc *idpPresetService) List(params pager.PagingParams) ([]*v1alpha1.IDPPreset, error) { + items, err := pager.From(svc.idpPresetInformer.GetStore()).Limit(params) + if err != nil { + return nil, errors.Wrapf(err, "while listing IDP Presets with paging params [first: %v] [offset: %v]", params.First, params.Offset) + } + + idpPresets := make([]*v1alpha1.IDPPreset, 0, len(items)) + for _, item := range items { + re, ok := item.(*v1alpha1.IDPPreset) + if !ok { + return nil, fmt.Errorf("incorrect item type: %T, should be: 'IDP Preset' in version 'v1alpha1'", item) + } + + idpPresets = append(idpPresets, re) + } + + return idpPresets, nil +} diff --git a/components/ui-api-layer/internal/domain/ui/idppreset_service_test.go b/components/ui-api-layer/internal/domain/ui/idppreset_service_test.go new file mode 100644 index 000000000000..d7c6b922f4ed --- /dev/null +++ b/components/ui-api-layer/internal/domain/ui/idppreset_service_test.go @@ -0,0 +1,173 @@ +package ui_test + +import ( + "fmt" + "testing" + "time" + + "github.com/kyma-project/kyma/components/idppreset/pkg/apis/ui/v1alpha1" + "github.com/kyma-project/kyma/components/idppreset/pkg/client/clientset/versioned/fake" + "github.com/kyma-project/kyma/components/idppreset/pkg/client/informers/externalversions" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/ui" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager" + testingUtils "github.com/kyma-project/kyma/components/ui-api-layer/internal/testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + testingErrors "k8s.io/client-go/testing" + "k8s.io/client-go/tools/cache" +) + +func TestIDPPreset(t *testing.T) { + t.Run("Create - with success", func(t *testing.T) { + // given + var ( + fixName = "fixIDPPreset" + fixIssuer = "issuer" + fixJwksURI = "uri" + ) + + fakeClient := fake.NewSimpleClientset() + svc := ui.NewIDPPresetService(fakeClient.UiV1alpha1(), nil) + + // when + idp, err := svc.Create(fixName, fixIssuer, fixJwksURI) + + // then + require.NoError(t, err) + require.NotNil(t, idp) + assert.Equal(t, idp.Name, fixName) + assert.Equal(t, idp.Kind, "IDPPreset") + assert.Equal(t, idp.Spec.Name, fixName) + assert.Equal(t, idp.Spec.Issuer, fixIssuer) + assert.Equal(t, idp.Spec.JwksUri, fixJwksURI) + }) + + t.Run("Delete - with success", func(t *testing.T) { + // given + var ( + fixName = "fixIDPPreset" + fixIDPPresetObj = fixIDPPreset() + ) + + fakeClient := fake.NewSimpleClientset(fixIDPPresetObj) + svc := ui.NewIDPPresetService(fakeClient.UiV1alpha1(), nil) + + // when + errFromDelete := svc.Delete(fixName) + _, err := fakeClient.UiV1alpha1().IDPPresets().Get(fixName, v1.GetOptions{}) + + // then + require.NoError(t, errFromDelete) + assert.True(t, apiErrors.IsNotFound(err)) + }) + + t.Run("Delete - with no success", func(t *testing.T) { + // given + var ( + errorMsg = fmt.Errorf("Some error") + fixName = "fixIDPPreset" + fixIDPPresetObj = fixIDPPreset() + ) + + fakeClient := fake.NewSimpleClientset(fixIDPPresetObj) + failingReaction := func(action testingErrors.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, errorMsg + } + fakeClient.PrependReactor("delete", "idppresets", failingReaction) + svc := ui.NewIDPPresetService(fakeClient.UiV1alpha1(), nil) + + // when + err := svc.Delete(fixName) + + // then + assert.Equal(t, errorMsg, err) + }) + + t.Run("Find - with success", func(t *testing.T) { + // given + var ( + fixName = "fixIDPPreset" + fixIssuer = "issuer" + fixJwksUri = "uri" + fixIDPPresetObj = fixIDPPreset() + ) + + fakeClient := fake.NewSimpleClientset(fixIDPPresetObj) + informer := fixIDPPresetInformer(fakeClient) + svc := ui.NewIDPPresetService(nil, informer) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + + // when + idp, err := svc.Find(fixName) + + // then + require.NoError(t, err) + require.NotNil(t, idp) + assert.Equal(t, idp.Name, fixName) + assert.Equal(t, idp.Spec.JwksUri, fixJwksUri) + assert.Equal(t, idp.Spec.Issuer, fixIssuer) + }) + + t.Run("Find - with no success - returns nil", func(t *testing.T) { + // given + fixName := "fixIDPPreset" + + fakeClient := fake.NewSimpleClientset() + informer := fixIDPPresetInformer(fakeClient) + svc := ui.NewIDPPresetService(nil, informer) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + + // when + idp, err := svc.Find(fixName) + + // then + require.NoError(t, err) + assert.Nil(t, idp) + }) + + t.Run("List", func(t *testing.T) { + // given + fixIDPPresetObj := fixIDPPreset() + + fakeClient := fake.NewSimpleClientset(fixIDPPresetObj) + informer := fixIDPPresetInformer(fakeClient) + svc := ui.NewIDPPresetService(nil, informer) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + + // when + idps, err := svc.List(pager.PagingParams{}) + + // then + require.NoError(t, err) + assert.Equal(t, []*v1alpha1.IDPPreset{ + fixIDPPresetObj, + }, idps) + }) + + t.Run("List - with no success - returns empty array", func(t *testing.T) { + // given + emptyIDPPresetsArray := []*v1alpha1.IDPPreset{} + + fakeClient := fake.NewSimpleClientset() + informer := fixIDPPresetInformer(fakeClient) + svc := ui.NewIDPPresetService(nil, informer) + testingUtils.WaitForInformerStartAtMost(t, time.Second, informer) + + // when + idps, err := svc.List(pager.PagingParams{}) + + // then + require.NoError(t, err) + assert.Equal(t, emptyIDPPresetsArray, idps) + }) +} + +func fixIDPPresetInformer(fakeClient *fake.Clientset) cache.SharedIndexInformer { + informerFactory := externalversions.NewSharedInformerFactory(fakeClient, 0) + informer := informerFactory.Ui().V1alpha1().IDPPresets().Informer() + + return informer +} diff --git a/components/ui-api-layer/internal/domain/ui/ui.go b/components/ui-api-layer/internal/domain/ui/ui.go new file mode 100644 index 000000000000..8e520d402728 --- /dev/null +++ b/components/ui-api-layer/internal/domain/ui/ui.go @@ -0,0 +1,38 @@ +package ui + +import ( + "time" + + "github.com/kyma-project/kyma/components/idppreset/pkg/client/clientset/versioned" + "github.com/kyma-project/kyma/components/idppreset/pkg/client/informers/externalversions" + "github.com/pkg/errors" + "k8s.io/client-go/rest" +) + +type Resolver struct { + *idpPresetResolver + + informerFactory externalversions.SharedInformerFactory +} + +func New(restConfig *rest.Config, informerResyncPeriod time.Duration) (*Resolver, error) { + client, err := versioned.NewForConfig(restConfig) + if err != nil { + return nil, errors.Wrap(err, "while initializing Clientset") + } + + informerFactory := externalversions.NewSharedInformerFactory(client, informerResyncPeriod) + idpPresetGroup := informerFactory.Ui().V1alpha1() + + svc := newIDPPresetService(client.UiV1alpha1(), idpPresetGroup.IDPPresets().Informer()) + + return &Resolver{ + idpPresetResolver: newIDPPresetResolver(svc), + informerFactory: informerFactory, + }, nil +} + +func (r *Resolver) WaitForCacheSync(stopCh <-chan struct{}) { + r.informerFactory.Start(stopCh) + r.informerFactory.WaitForCacheSync(stopCh) +} diff --git a/components/ui-api-layer/internal/gqlschema/Section.go b/components/ui-api-layer/internal/gqlschema/Section.go new file mode 100644 index 000000000000..ad6344ec5fd3 --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/Section.go @@ -0,0 +1,6 @@ +package gqlschema + +type Section struct { + TopicType string + Titles []Title +} diff --git a/components/ui-api-layer/internal/gqlschema/Title.go b/components/ui-api-layer/internal/gqlschema/Title.go new file mode 100644 index 000000000000..949ba5a4fc69 --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/Title.go @@ -0,0 +1,7 @@ +package gqlschema + +type Title struct { + Name string + Anchor string + Titles []Title +} diff --git a/components/ui-api-layer/internal/gqlschema/TopicEntry.go b/components/ui-api-layer/internal/gqlschema/TopicEntry.go new file mode 100644 index 000000000000..7983e84a6db7 --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/TopicEntry.go @@ -0,0 +1,7 @@ +package gqlschema + +type TopicEntry struct { + ContentType string + ID string + Sections []Section +} diff --git a/components/ui-api-layer/internal/gqlschema/api.go b/components/ui-api-layer/internal/gqlschema/api.go new file mode 100644 index 000000000000..036f2c5e71a6 --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/api.go @@ -0,0 +1,8 @@ +package gqlschema + +type API struct { + Name string + Hostname string + Service Service + AuthenticationPolicies []AuthenticationPolicy +} diff --git a/components/ui-api-layer/internal/gqlschema/deployment.go b/components/ui-api-layer/internal/gqlschema/deployment.go new file mode 100644 index 000000000000..627e06279f4f --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/deployment.go @@ -0,0 +1,12 @@ +package gqlschema + +import "time" + +type Deployment struct { + Name string + Environment string + CreationTimestamp time.Time + Status DeploymentStatus + Labels JSON + Containers []Container +} diff --git a/components/ui-api-layer/internal/gqlschema/deploymentstatus.go b/components/ui-api-layer/internal/gqlschema/deploymentstatus.go new file mode 100644 index 000000000000..83a12c179c76 --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/deploymentstatus.go @@ -0,0 +1,9 @@ +package gqlschema + +type DeploymentStatus struct { + Replicas int + UpdatedReplicas int + ReadyReplicas int + AvailableReplicas int + Conditions []DeploymentCondition +} diff --git a/components/ui-api-layer/internal/gqlschema/eventactivation.go b/components/ui-api-layer/internal/gqlschema/eventactivation.go new file mode 100644 index 000000000000..ee204fd3d67c --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/eventactivation.go @@ -0,0 +1,7 @@ +package gqlschema + +type EventActivation struct { + Name string + DisplayName string + Source EventActivationSource +} diff --git a/components/ui-api-layer/internal/gqlschema/json.go b/components/ui-api-layer/internal/gqlschema/json.go new file mode 100644 index 000000000000..b13768dc56e3 --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/json.go @@ -0,0 +1,26 @@ +package gqlschema + +import ( + "encoding/json" + "io" + + "github.com/golang/glog" + "github.com/pkg/errors" +) + +type JSON map[string]interface{} + +func (y *JSON) UnmarshalGQL(v interface{}) error { + value := v.(map[string]interface{}) + *y = value + return nil +} + +// MarshalGQL implements the graphql.Marshaler interface +func (y JSON) MarshalGQL(w io.Writer) { + bytes, err := json.Marshal(y) + if err != nil { + glog.Error(errors.Wrapf(err, "while marshalling %+v scalar object", y)) + } + w.Write(bytes) +} diff --git a/components/ui-api-layer/internal/gqlschema/limitrange.go b/components/ui-api-layer/internal/gqlschema/limitrange.go new file mode 100644 index 000000000000..3fda6b9ea3bb --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/limitrange.go @@ -0,0 +1,13 @@ +package gqlschema + +type LimitRange struct { + Name string `json:"name"` + Limits []LimitRangeItem `json:"limits"` +} + +type LimitRangeItem struct { + LimitType LimitType `json:"limitType"` + Max ResourceType `json:"max"` + Default ResourceType `json:"default"` + DefaultRequest ResourceType `json:"defaultRequest"` +} diff --git a/components/ui-api-layer/internal/gqlschema/models_gen.go b/components/ui-api-layer/internal/gqlschema/models_gen.go new file mode 100644 index 000000000000..2a226875236d --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/models_gen.go @@ -0,0 +1,457 @@ +// This file was generated by github.com/vektah/gqlgen, DO NOT EDIT + +package gqlschema + +import ( + fmt "fmt" + io "io" + strconv "strconv" + time "time" +) + +type AuthenticationPolicy struct { + Type AuthenticationPolicyType `json:"type"` + Issuer string `json:"issuer"` + JwksURI string `json:"jwksURI"` +} +type ConnectorService struct { + Url string `json:"url"` +} +type Container struct { + Name string `json:"name"` + Image string `json:"image"` +} +type CreateServiceBindingOutput struct { + Name string `json:"name"` + ServiceInstanceName string `json:"serviceInstanceName"` + Environment string `json:"environment"` +} +type CreateServiceBindingUsageInput struct { + Name string `json:"name"` + Environment string `json:"environment"` + ServiceBindingRef ServiceBindingRefInput `json:"serviceBindingRef"` + UsedBy LocalObjectReferenceInput `json:"usedBy"` + Parameters *ServiceBindingUsageParametersInput `json:"parameters"` +} +type DeleteServiceBindingOutput struct { + Name string `json:"name"` + Environment string `json:"environment"` +} +type DeleteServiceBindingUsageOutput struct { + Name string `json:"name"` + Environment string `json:"environment"` +} +type DeploymentCondition struct { + Status string `json:"status"` + Type string `json:"type"` + LastTransitionTimestamp time.Time `json:"lastTransitionTimestamp"` + LastUpdateTimestamp time.Time `json:"lastUpdateTimestamp"` + Message string `json:"message"` + Reason string `json:"reason"` +} +type EnvPrefixInput struct { + Name string `json:"name"` +} +type Environment struct { + Name string `json:"name"` + RemoteEnvironments []string `json:"remoteEnvironments"` +} +type EnvironmentMapping struct { + Environment string `json:"environment"` + RemoteEnvironment string `json:"remoteEnvironment"` +} +type EventActivationEvent struct { + EventType string `json:"eventType"` + Version string `json:"version"` + Description string `json:"description"` +} +type EventActivationSource struct { + Environment string `json:"environment"` + Type string `json:"type"` + Namespace string `json:"namespace"` +} +type Function struct { + Name string `json:"name"` + Trigger string `json:"trigger"` + CreationTimestamp time.Time `json:"creationTimestamp"` + Labels JSON `json:"labels"` + Environment string `json:"environment"` +} +type IDPPreset struct { + Name string `json:"name"` + Issuer string `json:"issuer"` + JwksUri string `json:"jwksUri"` +} +type InputTopic struct { + ID string `json:"id"` + Type string `json:"type"` +} +type LocalObjectReferenceInput struct { + Kind BindingUsageReferenceType `json:"kind"` + Name string `json:"name"` +} +type RemoteEnvironmentEntry struct { + Type string `json:"type"` + GatewayUrl *string `json:"gatewayUrl"` + AccessLabel *string `json:"accessLabel"` +} +type RemoteEnvironmentSource struct { + Environment string `json:"environment"` + Type string `json:"type"` + Namespace string `json:"namespace"` +} +type ResourceType struct { + Memory *string `json:"memory"` + Cpu *string `json:"cpu"` +} +type ResourceValues struct { + Memory *string `json:"memory"` + Cpu *string `json:"cpu"` +} +type Secret struct { + Name string `json:"name"` + Environment string `json:"environment"` + Data JSON `json:"data"` +} +type Service struct { + Name string `json:"name"` + Port int `json:"port"` +} +type ServiceBindingRefInput struct { + Name string `json:"name"` +} +type ServiceBindingStatus struct { + Type ServiceBindingStatusType `json:"type"` + Reason string `json:"reason"` + Message string `json:"message"` +} +type ServiceBindingUsageParametersInput struct { + EnvPrefix *EnvPrefixInput `json:"envPrefix"` +} +type ServiceBindingUsageStatus struct { + Type ServiceBindingUsageStatusType `json:"type"` + Reason string `json:"reason"` + Message string `json:"message"` +} +type ServiceBrokerStatus struct { + Ready bool `json:"ready"` + Reason string `json:"reason"` + Message string `json:"message"` +} +type ServiceInstanceCreateInput struct { + Name string `json:"name"` + Environment string `json:"environment"` + ExternalServiceClassName string `json:"externalServiceClassName"` + ExternalPlanName string `json:"externalPlanName"` + Labels []string `json:"labels"` + ParameterSchema *JSON `json:"parameterSchema"` +} +type ServiceInstanceStatus struct { + Type InstanceStatusType `json:"type"` + Reason string `json:"reason"` + Message string `json:"message"` +} +type ServicePlan struct { + Name string `json:"name"` + DisplayName *string `json:"displayName"` + ExternalName string `json:"externalName"` + Description string `json:"description"` + RelatedServiceClassName string `json:"relatedServiceClassName"` + InstanceCreateParameterSchema *JSON `json:"instanceCreateParameterSchema"` +} + +type AuthenticationPolicyType string + +const ( + AuthenticationPolicyTypeJwt AuthenticationPolicyType = "JWT" +) + +func (e AuthenticationPolicyType) IsValid() bool { + switch e { + case AuthenticationPolicyTypeJwt: + return true + } + return false +} + +func (e AuthenticationPolicyType) String() string { + return string(e) +} + +func (e *AuthenticationPolicyType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = AuthenticationPolicyType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid AuthenticationPolicyType", str) + } + return nil +} + +func (e AuthenticationPolicyType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +type BindingUsageReferenceType string + +const ( + BindingUsageReferenceTypeDeployment BindingUsageReferenceType = "DEPLOYMENT" + BindingUsageReferenceTypeFunction BindingUsageReferenceType = "FUNCTION" +) + +func (e BindingUsageReferenceType) IsValid() bool { + switch e { + case BindingUsageReferenceTypeDeployment, BindingUsageReferenceTypeFunction: + return true + } + return false +} + +func (e BindingUsageReferenceType) String() string { + return string(e) +} + +func (e *BindingUsageReferenceType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = BindingUsageReferenceType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid BindingUsageReferenceType", str) + } + return nil +} + +func (e BindingUsageReferenceType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +type InstanceStatusType string + +const ( + InstanceStatusTypeRunning InstanceStatusType = "RUNNING" + InstanceStatusTypeProvisioning InstanceStatusType = "PROVISIONING" + InstanceStatusTypeDeprovisioning InstanceStatusType = "DEPROVISIONING" + InstanceStatusTypePending InstanceStatusType = "PENDING" + InstanceStatusTypeFailed InstanceStatusType = "FAILED" +) + +func (e InstanceStatusType) IsValid() bool { + switch e { + case InstanceStatusTypeRunning, InstanceStatusTypeProvisioning, InstanceStatusTypeDeprovisioning, InstanceStatusTypePending, InstanceStatusTypeFailed: + return true + } + return false +} + +func (e InstanceStatusType) String() string { + return string(e) +} + +func (e *InstanceStatusType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = InstanceStatusType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid InstanceStatusType", str) + } + return nil +} + +func (e InstanceStatusType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +type LimitType string + +const ( + LimitTypeContainer LimitType = "Container" + LimitTypePod LimitType = "Pod" +) + +func (e LimitType) IsValid() bool { + switch e { + case LimitTypeContainer, LimitTypePod: + return true + } + return false +} + +func (e LimitType) String() string { + return string(e) +} + +func (e *LimitType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = LimitType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid LimitType", str) + } + return nil +} + +func (e LimitType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +type RemoteEnvironmentStatus string + +const ( + RemoteEnvironmentStatusServing RemoteEnvironmentStatus = "SERVING" + RemoteEnvironmentStatusNotServing RemoteEnvironmentStatus = "NOT_SERVING" + RemoteEnvironmentStatusGatewayNotConfigured RemoteEnvironmentStatus = "GATEWAY_NOT_CONFIGURED" +) + +func (e RemoteEnvironmentStatus) IsValid() bool { + switch e { + case RemoteEnvironmentStatusServing, RemoteEnvironmentStatusNotServing, RemoteEnvironmentStatusGatewayNotConfigured: + return true + } + return false +} + +func (e RemoteEnvironmentStatus) String() string { + return string(e) +} + +func (e *RemoteEnvironmentStatus) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = RemoteEnvironmentStatus(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid RemoteEnvironmentStatus", str) + } + return nil +} + +func (e RemoteEnvironmentStatus) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +type ServiceBindingStatusType string + +const ( + ServiceBindingStatusTypeReady ServiceBindingStatusType = "READY" + ServiceBindingStatusTypePending ServiceBindingStatusType = "PENDING" + ServiceBindingStatusTypeFailed ServiceBindingStatusType = "FAILED" + ServiceBindingStatusTypeUnknown ServiceBindingStatusType = "UNKNOWN" +) + +func (e ServiceBindingStatusType) IsValid() bool { + switch e { + case ServiceBindingStatusTypeReady, ServiceBindingStatusTypePending, ServiceBindingStatusTypeFailed, ServiceBindingStatusTypeUnknown: + return true + } + return false +} + +func (e ServiceBindingStatusType) String() string { + return string(e) +} + +func (e *ServiceBindingStatusType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ServiceBindingStatusType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ServiceBindingStatusType", str) + } + return nil +} + +func (e ServiceBindingStatusType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +type ServiceBindingUsageStatusType string + +const ( + ServiceBindingUsageStatusTypeReady ServiceBindingUsageStatusType = "READY" + ServiceBindingUsageStatusTypePending ServiceBindingUsageStatusType = "PENDING" + ServiceBindingUsageStatusTypeFailed ServiceBindingUsageStatusType = "FAILED" + ServiceBindingUsageStatusTypeUnknown ServiceBindingUsageStatusType = "UNKNOWN" +) + +func (e ServiceBindingUsageStatusType) IsValid() bool { + switch e { + case ServiceBindingUsageStatusTypeReady, ServiceBindingUsageStatusTypePending, ServiceBindingUsageStatusTypeFailed, ServiceBindingUsageStatusTypeUnknown: + return true + } + return false +} + +func (e ServiceBindingUsageStatusType) String() string { + return string(e) +} + +func (e *ServiceBindingUsageStatusType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ServiceBindingUsageStatusType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ServiceBindingUsageStatusType", str) + } + return nil +} + +func (e ServiceBindingUsageStatusType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +type ServiceInstanceEventType string + +const ( + ServiceInstanceEventTypeAdd ServiceInstanceEventType = "ADD" + ServiceInstanceEventTypeUpdate ServiceInstanceEventType = "UPDATE" + ServiceInstanceEventTypeDelete ServiceInstanceEventType = "DELETE" +) + +func (e ServiceInstanceEventType) IsValid() bool { + switch e { + case ServiceInstanceEventTypeAdd, ServiceInstanceEventTypeUpdate, ServiceInstanceEventTypeDelete: + return true + } + return false +} + +func (e ServiceInstanceEventType) String() string { + return string(e) +} + +func (e *ServiceInstanceEventType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ServiceInstanceEventType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ServiceInstanceEventType", str) + } + return nil +} + +func (e ServiceInstanceEventType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} diff --git a/components/ui-api-layer/internal/gqlschema/remoteenvironment.go b/components/ui-api-layer/internal/gqlschema/remoteenvironment.go new file mode 100644 index 000000000000..6b51b70ab432 --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/remoteenvironment.go @@ -0,0 +1,9 @@ +package gqlschema + +type RemoteEnvironment struct { + Name string + Description string + Source RemoteEnvironmentSource + Services []RemoteEnvironmentService + enabledInEnvironments []string +} diff --git a/components/ui-api-layer/internal/gqlschema/remoteenvironmentservice.go b/components/ui-api-layer/internal/gqlschema/remoteenvironmentservice.go new file mode 100644 index 000000000000..ea658dbfb571 --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/remoteenvironmentservice.go @@ -0,0 +1,10 @@ +package gqlschema + +type RemoteEnvironmentService struct { + ID string + DisplayName string + LongDescription string + ProviderDisplayName string + Tags []string + Entries []RemoteEnvironmentEntry +} diff --git a/components/ui-api-layer/internal/gqlschema/resourcequota.go b/components/ui-api-layer/internal/gqlschema/resourcequota.go new file mode 100644 index 000000000000..49969fbd170a --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/resourcequota.go @@ -0,0 +1,8 @@ +package gqlschema + +type ResourceQuota struct { + Name string + Pods *string + Limits ResourceValues + Requests ResourceValues +} diff --git a/components/ui-api-layer/internal/gqlschema/schema.graphql b/components/ui-api-layer/internal/gqlschema/schema.graphql new file mode 100644 index 000000000000..b08b3bd48309 --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/schema.graphql @@ -0,0 +1,477 @@ +# Scalars + +scalar JSON + +scalar Timestamp + +# Content + + +type Title { + name: String! + anchor: String! + titles: [Title!] +} + +type Section { + titles: [Title!]! + topicType: String! +} + +type TopicEntry { + contentType: String! + id: String! + sections: [Section!]! +} + +# Service Catalog + +type ServiceInstance { + name: String! + environment: String! + serviceClassName: String + ServiceClassDisplayName: String! + servicePlanName: String + servicePlanDisplayName: String! + creationTimestamp: Timestamp! + labels: [String]! + status: ServiceInstanceStatus + servicePlan: ServicePlan + serviceClass: ServiceClass + bindable: Boolean! + serviceBindings: [ServiceBinding]! + serviceBindingUsages: [ServiceBindingUsage]! +} + +type ServiceInstanceStatus { + type: InstanceStatusType! + reason: String! + message: String! +} + +enum InstanceStatusType { + RUNNING + PROVISIONING + DEPROVISIONING + PENDING + FAILED +} + +type ServiceInstanceEvent { + type: ServiceInstanceEventType! + instance: ServiceInstance +} + +enum ServiceInstanceEventType { + ADD + UPDATE + DELETE +} + +input ServiceInstanceCreateInput { + name: String! + environment: String! + externalServiceClassName: String! + externalPlanName: String! + labels: [String]! + parameterSchema: JSON +} + +type ServiceClass { + name: String! + externalName: String! + displayName: String + creationTimestamp: Timestamp! + description: String! + imageUrl: String + documentationUrl: String + providerDisplayName: String + tags: [String]! + plans: [ServicePlan]! + activated: Boolean! + apiSpec: JSON + asyncApiSpec: JSON + content: JSON +} + +type ServicePlan { + name: String! + displayName: String + externalName: String! + description: String! + relatedServiceClassName: String! + instanceCreateParameterSchema: JSON +} + +type ServiceBroker { + name: String! + status: ServiceBrokerStatus! + creationTimestamp: Timestamp! + url: String! + labels: JSON! +} + +type ServiceBrokerStatus { + ready: Boolean! + reason: String! + message: String! +} + +type ServiceBinding { + name: String! + serviceInstanceName: String! + environment: String! + secret: Secret + status: ServiceBindingStatus! +} + +type ServiceBindingStatus { + type: ServiceBindingStatusType! + reason: String! + message: String! +} + +enum ServiceBindingStatusType { + READY + PENDING + FAILED + UNKNOWN +} + +# We cannot use ServiceBinding as a result of create action +# because secret at the moment of mutation execution is not available. +type CreateServiceBindingOutput { + name: String! + serviceInstanceName: String! + environment: String! +} + +type Secret { + name: String! + environment: String! + data: JSON! +} + +type DeleteServiceBindingOutput { + name: String! + environment: String! +} + +type DeleteServiceBindingUsageOutput { + name: String! + environment: String! +} + +type ServiceBindingUsage { + name: String! + environment: String! + serviceBinding: ServiceBinding + usedBy: LocalObjectReference! + parameters: ServiceBindingUsageParameters + status: ServiceBindingUsageStatus! +} + +type ServiceBindingUsageStatus { + type: ServiceBindingUsageStatusType! + reason: String! + message: String! +} + +enum ServiceBindingUsageStatusType { + READY + PENDING + FAILED + UNKNOWN +} + +type LocalObjectReference { + kind: BindingUsageReferenceType! + name: String! +} + +type ServiceBindingUsageParameters { + envPrefix: EnvPrefix +} + +type EnvPrefix { + name: String! +} + +type LimitRange { + name: String! + limits: [LimitRangeItem]! +} + +type LimitRangeItem { + limitType: LimitType! + max: ResourceType! + default: ResourceType! + defaultRequest: ResourceType! +} + +enum LimitType { + Container + Pod +} + +type ResourceType { + memory: String + cpu: String +} + +input CreateServiceBindingUsageInput { + name: String! + environment: String! + serviceBindingRef: ServiceBindingRefInput! + usedBy: LocalObjectReferenceInput! + parameters: ServiceBindingUsageParametersInput +} + +input ServiceBindingRefInput { + name: String! +} + +input LocalObjectReferenceInput { + kind: BindingUsageReferenceType! + name: String! +} + +enum BindingUsageReferenceType { + DEPLOYMENT + FUNCTION +} + +input ServiceBindingUsageParametersInput { + envPrefix: EnvPrefixInput +} + +input EnvPrefixInput { + name: String! +} + +type Container { + name: String! + image: String! +} + +type DeploymentStatus { + replicas: Int! + updatedReplicas: Int! + readyReplicas: Int! + availableReplicas: Int! + conditions: DeploymentCondition! +} + +type DeploymentCondition { + status: String! + type: String! + lastTransitionTimestamp: Timestamp! + lastUpdateTimestamp: Timestamp! + message: String! + reason: String! +} + +type Deployment { + name: String! + environment: String! + creationTimestamp: Timestamp! + status: DeploymentStatus! + labels: JSON! + containers: [Container]! + boundServiceInstanceNames: [String]! +} + +type ResourceQuota { + name: String! + pods: String + limits: ResourceValues! + requests: ResourceValues! +} + +type ResourceValues { + memory: String + cpu: String +} + +# Remote Environments + +type Environment { + name: String! + remoteEnvironments: [String]! +} + +type RemoteEnvironment { + name: String! + description: String! + source: RemoteEnvironmentSource! + services: [RemoteEnvironmentService]! + enabledInEnvironments: [String]! + status: RemoteEnvironmentStatus! +} + +type ConnectorService { + url: String! +} + +type EnvironmentMapping { + environment: String! + remoteEnvironment: String! +} + +type RemoteEnvironmentSource { + environment: String! + type: String! + namespace: String! +} + +type RemoteEnvironmentService { + id: String! + displayName: String! + longDescription: String! + providerDisplayName: String! + tags: [String]! + entries: [RemoteEnvironmentEntry]! +} + +type RemoteEnvironmentEntry { + type: String! + gatewayUrl: String + accessLabel: String +} + +enum RemoteEnvironmentStatus { + SERVING + NOT_SERVING + GATEWAY_NOT_CONFIGURED +} + +type EventActivationSource { + environment: String! + type: String! + namespace: String! +} + +type EventActivationEvent { + eventType: String! + version: String! + description: String! +} + +type EventActivation { + name: String! + displayName: String! + source: EventActivationSource! + events: [EventActivationEvent]! +} + +# IDP PRESETS + +type IDPPreset { + name: String! + issuer: String! + jwksUri: String! +} + +# Kubeless + +type Function { + name: String! + trigger: String! + creationTimestamp: Timestamp! + labels: JSON! + environment: String! +} + +input InputTopic { + id: String! + type: String! +} + +# API controller + +type Service { + name: String! + port: Int! +} + +enum AuthenticationPolicyType { + JWT +} + +type AuthenticationPolicy { + type: AuthenticationPolicyType! + issuer: String! + jwksURI: String! +} + +type API { + name: String! + hostname: String! + service: Service! + authenticationPolicies: [AuthenticationPolicy]! +} + +# Queries + +type Query { + serviceInstance(name: String!, environment: String!): ServiceInstance + serviceInstances(environment: String!, first: Int, offset: Int, status: InstanceStatusType): [ServiceInstance]! + serviceClasses(first: Int, offset: Int): [ServiceClass]! + serviceClass(name: String!): ServiceClass + serviceBrokers(first: Int, offset: Int): [ServiceBroker]! + serviceBroker(name: String!): ServiceBroker + serviceBindingUsage(name: String!, environment: String!): ServiceBindingUsage + serviceBinding(name: String!, environment: String!): ServiceBinding + + apis(environment: String!, serviceName: String, hostname: String): [API!]! + + remoteEnvironment(name: String!): RemoteEnvironment + remoteEnvironments(environment: String, first: Int, offset: Int): [RemoteEnvironment]! + connectorService(remoteEnvironment: String!): ConnectorService! + + environments(remoteEnvironment: String): [Environment]! + deployments(environment: String!, excludeFunctions: Boolean): [Deployment]! + resourceQuotas(environment: String!): [ResourceQuota]! + + functions(environment: String!, first: Int, offset: Int): [Function]! + + #TODO: it is not possible to define 'type' as argument name + content(contentType: String!, id: String!): JSON + topics(input: [InputTopic]!, internal: Boolean): [TopicEntry] + eventActivations(environment: String!): [EventActivation]! + + limitRanges(environment: String!): [LimitRange!]! + + IDPPreset(name: String!): IDPPreset + IDPPresets(first: Int, offset: Int): [IDPPreset!]! +} + +# Mutations + +type Mutation { + createServiceInstance(params: ServiceInstanceCreateInput!): ServiceInstance + deleteServiceInstance(name: String!, environment: String!): ServiceInstance + createServiceBinding(serviceBindingName: String!, serviceInstanceName: String!, environment: String!): CreateServiceBindingOutput + deleteServiceBinding(serviceBindingName: String!, environment: String!): DeleteServiceBindingOutput + createServiceBindingUsage(createServiceBindingUsageInput: CreateServiceBindingUsageInput): ServiceBindingUsage + deleteServiceBindingUsage(serviceBindingUsageName: String!, environment: String!): DeleteServiceBindingUsageOutput + + enableRemoteEnvironment(remoteEnvironment: String!, environment: String!): EnvironmentMapping + disableRemoteEnvironment(remoteEnvironment: String!, environment: String!): EnvironmentMapping + + createIDPPreset(name: String!, issuer: String!, jwksUri: String!): IDPPreset + deleteIDPPreset(name: String!): IDPPreset +} + +# Subscriptions + +type Subscription { + serviceInstanceEvent(environment: String!): ServiceInstanceEvent! +} + +# Schema + +schema { + query: Query + mutation: Mutation +} diff --git a/components/ui-api-layer/internal/gqlschema/schema_gen.go b/components/ui-api-layer/internal/gqlschema/schema_gen.go new file mode 100644 index 000000000000..51737cfcaf00 --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/schema_gen.go @@ -0,0 +1,7272 @@ +// This file was generated by github.com/vektah/gqlgen, DO NOT EDIT + +package gqlschema + +import ( + "bytes" + context "context" + strconv "strconv" + + graphql "github.com/vektah/gqlgen/graphql" + introspection "github.com/vektah/gqlgen/neelance/introspection" + query "github.com/vektah/gqlgen/neelance/query" + schema "github.com/vektah/gqlgen/neelance/schema" +) + +func MakeExecutableSchema(resolvers Resolvers) graphql.ExecutableSchema { + return &executableSchema{resolvers: resolvers} +} + +type Resolvers interface { + Deployment_boundServiceInstanceNames(ctx context.Context, obj *Deployment) ([]string, error) + + EventActivation_events(ctx context.Context, obj *EventActivation) ([]EventActivationEvent, error) + + Mutation_createServiceInstance(ctx context.Context, params ServiceInstanceCreateInput) (*ServiceInstance, error) + Mutation_deleteServiceInstance(ctx context.Context, name string, environment string) (*ServiceInstance, error) + Mutation_createServiceBinding(ctx context.Context, serviceBindingName string, serviceInstanceName string, environment string) (*CreateServiceBindingOutput, error) + Mutation_deleteServiceBinding(ctx context.Context, serviceBindingName string, environment string) (*DeleteServiceBindingOutput, error) + Mutation_createServiceBindingUsage(ctx context.Context, createServiceBindingUsageInput *CreateServiceBindingUsageInput) (*ServiceBindingUsage, error) + Mutation_deleteServiceBindingUsage(ctx context.Context, serviceBindingUsageName string, environment string) (*DeleteServiceBindingUsageOutput, error) + Mutation_enableRemoteEnvironment(ctx context.Context, remoteEnvironment string, environment string) (*EnvironmentMapping, error) + Mutation_disableRemoteEnvironment(ctx context.Context, remoteEnvironment string, environment string) (*EnvironmentMapping, error) + Mutation_createIDPPreset(ctx context.Context, name string, issuer string, jwksUri string) (*IDPPreset, error) + Mutation_deleteIDPPreset(ctx context.Context, name string) (*IDPPreset, error) + Query_serviceInstance(ctx context.Context, name string, environment string) (*ServiceInstance, error) + Query_serviceInstances(ctx context.Context, environment string, first *int, offset *int, status *InstanceStatusType) ([]ServiceInstance, error) + Query_serviceClasses(ctx context.Context, first *int, offset *int) ([]ServiceClass, error) + Query_serviceClass(ctx context.Context, name string) (*ServiceClass, error) + Query_serviceBrokers(ctx context.Context, first *int, offset *int) ([]ServiceBroker, error) + Query_serviceBroker(ctx context.Context, name string) (*ServiceBroker, error) + Query_serviceBindingUsage(ctx context.Context, name string, environment string) (*ServiceBindingUsage, error) + Query_serviceBinding(ctx context.Context, name string, environment string) (*ServiceBinding, error) + Query_apis(ctx context.Context, environment string, serviceName *string, hostname *string) ([]API, error) + Query_remoteEnvironment(ctx context.Context, name string) (*RemoteEnvironment, error) + Query_remoteEnvironments(ctx context.Context, environment *string, first *int, offset *int) ([]RemoteEnvironment, error) + Query_connectorService(ctx context.Context, remoteEnvironment string) (ConnectorService, error) + Query_environments(ctx context.Context, remoteEnvironment *string) ([]Environment, error) + Query_deployments(ctx context.Context, environment string, excludeFunctions *bool) ([]Deployment, error) + Query_resourceQuotas(ctx context.Context, environment string) ([]ResourceQuota, error) + Query_functions(ctx context.Context, environment string, first *int, offset *int) ([]Function, error) + Query_content(ctx context.Context, contentType string, id string) (*JSON, error) + Query_topics(ctx context.Context, input []InputTopic, internal *bool) ([]TopicEntry, error) + Query_eventActivations(ctx context.Context, environment string) ([]EventActivation, error) + Query_limitRanges(ctx context.Context, environment string) ([]LimitRange, error) + Query_IDPPreset(ctx context.Context, name string) (*IDPPreset, error) + Query_IDPPresets(ctx context.Context, first *int, offset *int) ([]IDPPreset, error) + + RemoteEnvironment_enabledInEnvironments(ctx context.Context, obj *RemoteEnvironment) ([]string, error) + RemoteEnvironment_status(ctx context.Context, obj *RemoteEnvironment) (RemoteEnvironmentStatus, error) + + ServiceBinding_secret(ctx context.Context, obj *ServiceBinding) (*Secret, error) + + ServiceBindingUsage_serviceBinding(ctx context.Context, obj *ServiceBindingUsage) (*ServiceBinding, error) + + ServiceClass_plans(ctx context.Context, obj *ServiceClass) ([]ServicePlan, error) + ServiceClass_activated(ctx context.Context, obj *ServiceClass) (bool, error) + ServiceClass_apiSpec(ctx context.Context, obj *ServiceClass) (*JSON, error) + ServiceClass_asyncApiSpec(ctx context.Context, obj *ServiceClass) (*JSON, error) + ServiceClass_content(ctx context.Context, obj *ServiceClass) (*JSON, error) + + ServiceInstance_servicePlan(ctx context.Context, obj *ServiceInstance) (*ServicePlan, error) + ServiceInstance_serviceClass(ctx context.Context, obj *ServiceInstance) (*ServiceClass, error) + ServiceInstance_bindable(ctx context.Context, obj *ServiceInstance) (bool, error) + ServiceInstance_serviceBindings(ctx context.Context, obj *ServiceInstance) ([]ServiceBinding, error) + ServiceInstance_serviceBindingUsages(ctx context.Context, obj *ServiceInstance) ([]ServiceBindingUsage, error) + + Subscription_serviceInstanceEvent(ctx context.Context, environment string) (<-chan ServiceInstanceEvent, error) +} + +type executableSchema struct { + resolvers Resolvers +} + +func (e *executableSchema) Schema() *schema.Schema { + return parsedSchema +} + +func (e *executableSchema) Query(ctx context.Context, op *query.Operation) *graphql.Response { + ec := executionContext{graphql.GetRequestContext(ctx), e.resolvers} + + buf := ec.RequestMiddleware(ctx, func(ctx context.Context) []byte { + data := ec._Query(ctx, op.Selections) + var buf bytes.Buffer + data.MarshalGQL(&buf) + return buf.Bytes() + }) + + return &graphql.Response{ + Data: buf, + Errors: ec.Errors, + } +} + +func (e *executableSchema) Mutation(ctx context.Context, op *query.Operation) *graphql.Response { + ec := executionContext{graphql.GetRequestContext(ctx), e.resolvers} + + buf := ec.RequestMiddleware(ctx, func(ctx context.Context) []byte { + data := ec._Mutation(ctx, op.Selections) + var buf bytes.Buffer + data.MarshalGQL(&buf) + return buf.Bytes() + }) + + return &graphql.Response{ + Data: buf, + Errors: ec.Errors, + } +} + +func (e *executableSchema) Subscription(ctx context.Context, op *query.Operation) func() *graphql.Response { + ec := executionContext{graphql.GetRequestContext(ctx), e.resolvers} + + next := ec._Subscription(ctx, op.Selections) + if ec.Errors != nil { + return graphql.OneShot(&graphql.Response{Data: []byte("null"), Errors: ec.Errors}) + } + + var buf bytes.Buffer + return func() *graphql.Response { + buf := ec.RequestMiddleware(ctx, func(ctx context.Context) []byte { + buf.Reset() + data := next() + + if data == nil { + return nil + } + data.MarshalGQL(&buf) + return buf.Bytes() + }) + + return &graphql.Response{ + Data: buf, + Errors: ec.Errors, + } + } +} + +type executionContext struct { + *graphql.RequestContext + + resolvers Resolvers +} + +var aPIImplementors = []string{"API"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _API(ctx context.Context, sel []query.Selection, obj *API) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, aPIImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("API") + case "name": + out.Values[i] = ec._API_name(ctx, field, obj) + case "hostname": + out.Values[i] = ec._API_hostname(ctx, field, obj) + case "service": + out.Values[i] = ec._API_service(ctx, field, obj) + case "authenticationPolicies": + out.Values[i] = ec._API_authenticationPolicies(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _API_name(ctx context.Context, field graphql.CollectedField, obj *API) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "API" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _API_hostname(ctx context.Context, field graphql.CollectedField, obj *API) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "API" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Hostname + return graphql.MarshalString(res) +} + +func (ec *executionContext) _API_service(ctx context.Context, field graphql.CollectedField, obj *API) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "API" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Service + return ec._Service(ctx, field.Selections, &res) +} + +func (ec *executionContext) _API_authenticationPolicies(ctx context.Context, field graphql.CollectedField, obj *API) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "API" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.AuthenticationPolicies + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._AuthenticationPolicy(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 +} + +var authenticationPolicyImplementors = []string{"AuthenticationPolicy"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _AuthenticationPolicy(ctx context.Context, sel []query.Selection, obj *AuthenticationPolicy) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, authenticationPolicyImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("AuthenticationPolicy") + case "type": + out.Values[i] = ec._AuthenticationPolicy_type(ctx, field, obj) + case "issuer": + out.Values[i] = ec._AuthenticationPolicy_issuer(ctx, field, obj) + case "jwksURI": + out.Values[i] = ec._AuthenticationPolicy_jwksURI(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _AuthenticationPolicy_type(ctx context.Context, field graphql.CollectedField, obj *AuthenticationPolicy) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "AuthenticationPolicy" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Type + return res +} + +func (ec *executionContext) _AuthenticationPolicy_issuer(ctx context.Context, field graphql.CollectedField, obj *AuthenticationPolicy) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "AuthenticationPolicy" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Issuer + return graphql.MarshalString(res) +} + +func (ec *executionContext) _AuthenticationPolicy_jwksURI(ctx context.Context, field graphql.CollectedField, obj *AuthenticationPolicy) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "AuthenticationPolicy" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.JwksURI + return graphql.MarshalString(res) +} + +var connectorServiceImplementors = []string{"ConnectorService"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _ConnectorService(ctx context.Context, sel []query.Selection, obj *ConnectorService) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, connectorServiceImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ConnectorService") + case "url": + out.Values[i] = ec._ConnectorService_url(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _ConnectorService_url(ctx context.Context, field graphql.CollectedField, obj *ConnectorService) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ConnectorService" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Url + return graphql.MarshalString(res) +} + +var containerImplementors = []string{"Container"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _Container(ctx context.Context, sel []query.Selection, obj *Container) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, containerImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Container") + case "name": + out.Values[i] = ec._Container_name(ctx, field, obj) + case "image": + out.Values[i] = ec._Container_image(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _Container_name(ctx context.Context, field graphql.CollectedField, obj *Container) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Container" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _Container_image(ctx context.Context, field graphql.CollectedField, obj *Container) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Container" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Image + return graphql.MarshalString(res) +} + +var createServiceBindingOutputImplementors = []string{"CreateServiceBindingOutput"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _CreateServiceBindingOutput(ctx context.Context, sel []query.Selection, obj *CreateServiceBindingOutput) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, createServiceBindingOutputImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("CreateServiceBindingOutput") + case "name": + out.Values[i] = ec._CreateServiceBindingOutput_name(ctx, field, obj) + case "serviceInstanceName": + out.Values[i] = ec._CreateServiceBindingOutput_serviceInstanceName(ctx, field, obj) + case "environment": + out.Values[i] = ec._CreateServiceBindingOutput_environment(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _CreateServiceBindingOutput_name(ctx context.Context, field graphql.CollectedField, obj *CreateServiceBindingOutput) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "CreateServiceBindingOutput" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _CreateServiceBindingOutput_serviceInstanceName(ctx context.Context, field graphql.CollectedField, obj *CreateServiceBindingOutput) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "CreateServiceBindingOutput" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.ServiceInstanceName + return graphql.MarshalString(res) +} + +func (ec *executionContext) _CreateServiceBindingOutput_environment(ctx context.Context, field graphql.CollectedField, obj *CreateServiceBindingOutput) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "CreateServiceBindingOutput" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Environment + return graphql.MarshalString(res) +} + +var deleteServiceBindingOutputImplementors = []string{"DeleteServiceBindingOutput"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _DeleteServiceBindingOutput(ctx context.Context, sel []query.Selection, obj *DeleteServiceBindingOutput) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, deleteServiceBindingOutputImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("DeleteServiceBindingOutput") + case "name": + out.Values[i] = ec._DeleteServiceBindingOutput_name(ctx, field, obj) + case "environment": + out.Values[i] = ec._DeleteServiceBindingOutput_environment(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _DeleteServiceBindingOutput_name(ctx context.Context, field graphql.CollectedField, obj *DeleteServiceBindingOutput) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "DeleteServiceBindingOutput" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _DeleteServiceBindingOutput_environment(ctx context.Context, field graphql.CollectedField, obj *DeleteServiceBindingOutput) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "DeleteServiceBindingOutput" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Environment + return graphql.MarshalString(res) +} + +var deleteServiceBindingUsageOutputImplementors = []string{"DeleteServiceBindingUsageOutput"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _DeleteServiceBindingUsageOutput(ctx context.Context, sel []query.Selection, obj *DeleteServiceBindingUsageOutput) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, deleteServiceBindingUsageOutputImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("DeleteServiceBindingUsageOutput") + case "name": + out.Values[i] = ec._DeleteServiceBindingUsageOutput_name(ctx, field, obj) + case "environment": + out.Values[i] = ec._DeleteServiceBindingUsageOutput_environment(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _DeleteServiceBindingUsageOutput_name(ctx context.Context, field graphql.CollectedField, obj *DeleteServiceBindingUsageOutput) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "DeleteServiceBindingUsageOutput" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _DeleteServiceBindingUsageOutput_environment(ctx context.Context, field graphql.CollectedField, obj *DeleteServiceBindingUsageOutput) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "DeleteServiceBindingUsageOutput" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Environment + return graphql.MarshalString(res) +} + +var deploymentImplementors = []string{"Deployment"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _Deployment(ctx context.Context, sel []query.Selection, obj *Deployment) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, deploymentImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Deployment") + case "name": + out.Values[i] = ec._Deployment_name(ctx, field, obj) + case "environment": + out.Values[i] = ec._Deployment_environment(ctx, field, obj) + case "creationTimestamp": + out.Values[i] = ec._Deployment_creationTimestamp(ctx, field, obj) + case "status": + out.Values[i] = ec._Deployment_status(ctx, field, obj) + case "labels": + out.Values[i] = ec._Deployment_labels(ctx, field, obj) + case "containers": + out.Values[i] = ec._Deployment_containers(ctx, field, obj) + case "boundServiceInstanceNames": + out.Values[i] = ec._Deployment_boundServiceInstanceNames(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _Deployment_name(ctx context.Context, field graphql.CollectedField, obj *Deployment) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Deployment" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _Deployment_environment(ctx context.Context, field graphql.CollectedField, obj *Deployment) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Deployment" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Environment + return graphql.MarshalString(res) +} + +func (ec *executionContext) _Deployment_creationTimestamp(ctx context.Context, field graphql.CollectedField, obj *Deployment) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Deployment" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.CreationTimestamp + return MarshalTimestamp(res) +} + +func (ec *executionContext) _Deployment_status(ctx context.Context, field graphql.CollectedField, obj *Deployment) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Deployment" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Status + return ec._DeploymentStatus(ctx, field.Selections, &res) +} + +func (ec *executionContext) _Deployment_labels(ctx context.Context, field graphql.CollectedField, obj *Deployment) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Deployment" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Labels + return res +} + +func (ec *executionContext) _Deployment_containers(ctx context.Context, field graphql.CollectedField, obj *Deployment) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Deployment" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Containers + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._Container(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 +} + +func (ec *executionContext) _Deployment_boundServiceInstanceNames(ctx context.Context, field graphql.CollectedField, obj *Deployment) graphql.Marshaler { + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Deployment", + Args: nil, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Deployment_boundServiceInstanceNames(ctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]string) + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return graphql.MarshalString(res[idx1]) + }()) + } + return arr1 + }) +} + +var deploymentConditionImplementors = []string{"DeploymentCondition"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _DeploymentCondition(ctx context.Context, sel []query.Selection, obj *DeploymentCondition) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, deploymentConditionImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("DeploymentCondition") + case "status": + out.Values[i] = ec._DeploymentCondition_status(ctx, field, obj) + case "type": + out.Values[i] = ec._DeploymentCondition_type(ctx, field, obj) + case "lastTransitionTimestamp": + out.Values[i] = ec._DeploymentCondition_lastTransitionTimestamp(ctx, field, obj) + case "lastUpdateTimestamp": + out.Values[i] = ec._DeploymentCondition_lastUpdateTimestamp(ctx, field, obj) + case "message": + out.Values[i] = ec._DeploymentCondition_message(ctx, field, obj) + case "reason": + out.Values[i] = ec._DeploymentCondition_reason(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _DeploymentCondition_status(ctx context.Context, field graphql.CollectedField, obj *DeploymentCondition) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "DeploymentCondition" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Status + return graphql.MarshalString(res) +} + +func (ec *executionContext) _DeploymentCondition_type(ctx context.Context, field graphql.CollectedField, obj *DeploymentCondition) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "DeploymentCondition" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Type + return graphql.MarshalString(res) +} + +func (ec *executionContext) _DeploymentCondition_lastTransitionTimestamp(ctx context.Context, field graphql.CollectedField, obj *DeploymentCondition) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "DeploymentCondition" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.LastTransitionTimestamp + return MarshalTimestamp(res) +} + +func (ec *executionContext) _DeploymentCondition_lastUpdateTimestamp(ctx context.Context, field graphql.CollectedField, obj *DeploymentCondition) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "DeploymentCondition" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.LastUpdateTimestamp + return MarshalTimestamp(res) +} + +func (ec *executionContext) _DeploymentCondition_message(ctx context.Context, field graphql.CollectedField, obj *DeploymentCondition) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "DeploymentCondition" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Message + return graphql.MarshalString(res) +} + +func (ec *executionContext) _DeploymentCondition_reason(ctx context.Context, field graphql.CollectedField, obj *DeploymentCondition) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "DeploymentCondition" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Reason + return graphql.MarshalString(res) +} + +var deploymentStatusImplementors = []string{"DeploymentStatus"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _DeploymentStatus(ctx context.Context, sel []query.Selection, obj *DeploymentStatus) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, deploymentStatusImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("DeploymentStatus") + case "replicas": + out.Values[i] = ec._DeploymentStatus_replicas(ctx, field, obj) + case "updatedReplicas": + out.Values[i] = ec._DeploymentStatus_updatedReplicas(ctx, field, obj) + case "readyReplicas": + out.Values[i] = ec._DeploymentStatus_readyReplicas(ctx, field, obj) + case "availableReplicas": + out.Values[i] = ec._DeploymentStatus_availableReplicas(ctx, field, obj) + case "conditions": + out.Values[i] = ec._DeploymentStatus_conditions(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _DeploymentStatus_replicas(ctx context.Context, field graphql.CollectedField, obj *DeploymentStatus) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "DeploymentStatus" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Replicas + return graphql.MarshalInt(res) +} + +func (ec *executionContext) _DeploymentStatus_updatedReplicas(ctx context.Context, field graphql.CollectedField, obj *DeploymentStatus) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "DeploymentStatus" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.UpdatedReplicas + return graphql.MarshalInt(res) +} + +func (ec *executionContext) _DeploymentStatus_readyReplicas(ctx context.Context, field graphql.CollectedField, obj *DeploymentStatus) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "DeploymentStatus" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.ReadyReplicas + return graphql.MarshalInt(res) +} + +func (ec *executionContext) _DeploymentStatus_availableReplicas(ctx context.Context, field graphql.CollectedField, obj *DeploymentStatus) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "DeploymentStatus" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.AvailableReplicas + return graphql.MarshalInt(res) +} + +func (ec *executionContext) _DeploymentStatus_conditions(ctx context.Context, field graphql.CollectedField, obj *DeploymentStatus) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "DeploymentStatus" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Conditions + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._DeploymentCondition(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 +} + +var envPrefixImplementors = []string{"EnvPrefix"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _EnvPrefix(ctx context.Context, sel []query.Selection, obj *EnvPrefix) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, envPrefixImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("EnvPrefix") + case "name": + out.Values[i] = ec._EnvPrefix_name(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _EnvPrefix_name(ctx context.Context, field graphql.CollectedField, obj *EnvPrefix) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "EnvPrefix" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +var environmentImplementors = []string{"Environment"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _Environment(ctx context.Context, sel []query.Selection, obj *Environment) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, environmentImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Environment") + case "name": + out.Values[i] = ec._Environment_name(ctx, field, obj) + case "remoteEnvironments": + out.Values[i] = ec._Environment_remoteEnvironments(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _Environment_name(ctx context.Context, field graphql.CollectedField, obj *Environment) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Environment" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _Environment_remoteEnvironments(ctx context.Context, field graphql.CollectedField, obj *Environment) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Environment" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.RemoteEnvironments + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return graphql.MarshalString(res[idx1]) + }()) + } + return arr1 +} + +var environmentMappingImplementors = []string{"EnvironmentMapping"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _EnvironmentMapping(ctx context.Context, sel []query.Selection, obj *EnvironmentMapping) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, environmentMappingImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("EnvironmentMapping") + case "environment": + out.Values[i] = ec._EnvironmentMapping_environment(ctx, field, obj) + case "remoteEnvironment": + out.Values[i] = ec._EnvironmentMapping_remoteEnvironment(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _EnvironmentMapping_environment(ctx context.Context, field graphql.CollectedField, obj *EnvironmentMapping) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "EnvironmentMapping" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Environment + return graphql.MarshalString(res) +} + +func (ec *executionContext) _EnvironmentMapping_remoteEnvironment(ctx context.Context, field graphql.CollectedField, obj *EnvironmentMapping) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "EnvironmentMapping" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.RemoteEnvironment + return graphql.MarshalString(res) +} + +var eventActivationImplementors = []string{"EventActivation"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _EventActivation(ctx context.Context, sel []query.Selection, obj *EventActivation) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, eventActivationImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("EventActivation") + case "name": + out.Values[i] = ec._EventActivation_name(ctx, field, obj) + case "displayName": + out.Values[i] = ec._EventActivation_displayName(ctx, field, obj) + case "source": + out.Values[i] = ec._EventActivation_source(ctx, field, obj) + case "events": + out.Values[i] = ec._EventActivation_events(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _EventActivation_name(ctx context.Context, field graphql.CollectedField, obj *EventActivation) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "EventActivation" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _EventActivation_displayName(ctx context.Context, field graphql.CollectedField, obj *EventActivation) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "EventActivation" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.DisplayName + return graphql.MarshalString(res) +} + +func (ec *executionContext) _EventActivation_source(ctx context.Context, field graphql.CollectedField, obj *EventActivation) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "EventActivation" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Source + return ec._EventActivationSource(ctx, field.Selections, &res) +} + +func (ec *executionContext) _EventActivation_events(ctx context.Context, field graphql.CollectedField, obj *EventActivation) graphql.Marshaler { + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "EventActivation", + Args: nil, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.EventActivation_events(ctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]EventActivationEvent) + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._EventActivationEvent(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 + }) +} + +var eventActivationEventImplementors = []string{"EventActivationEvent"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _EventActivationEvent(ctx context.Context, sel []query.Selection, obj *EventActivationEvent) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, eventActivationEventImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("EventActivationEvent") + case "eventType": + out.Values[i] = ec._EventActivationEvent_eventType(ctx, field, obj) + case "version": + out.Values[i] = ec._EventActivationEvent_version(ctx, field, obj) + case "description": + out.Values[i] = ec._EventActivationEvent_description(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _EventActivationEvent_eventType(ctx context.Context, field graphql.CollectedField, obj *EventActivationEvent) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "EventActivationEvent" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.EventType + return graphql.MarshalString(res) +} + +func (ec *executionContext) _EventActivationEvent_version(ctx context.Context, field graphql.CollectedField, obj *EventActivationEvent) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "EventActivationEvent" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Version + return graphql.MarshalString(res) +} + +func (ec *executionContext) _EventActivationEvent_description(ctx context.Context, field graphql.CollectedField, obj *EventActivationEvent) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "EventActivationEvent" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Description + return graphql.MarshalString(res) +} + +var eventActivationSourceImplementors = []string{"EventActivationSource"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _EventActivationSource(ctx context.Context, sel []query.Selection, obj *EventActivationSource) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, eventActivationSourceImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("EventActivationSource") + case "environment": + out.Values[i] = ec._EventActivationSource_environment(ctx, field, obj) + case "type": + out.Values[i] = ec._EventActivationSource_type(ctx, field, obj) + case "namespace": + out.Values[i] = ec._EventActivationSource_namespace(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _EventActivationSource_environment(ctx context.Context, field graphql.CollectedField, obj *EventActivationSource) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "EventActivationSource" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Environment + return graphql.MarshalString(res) +} + +func (ec *executionContext) _EventActivationSource_type(ctx context.Context, field graphql.CollectedField, obj *EventActivationSource) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "EventActivationSource" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Type + return graphql.MarshalString(res) +} + +func (ec *executionContext) _EventActivationSource_namespace(ctx context.Context, field graphql.CollectedField, obj *EventActivationSource) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "EventActivationSource" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Namespace + return graphql.MarshalString(res) +} + +var functionImplementors = []string{"Function"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _Function(ctx context.Context, sel []query.Selection, obj *Function) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, functionImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Function") + case "name": + out.Values[i] = ec._Function_name(ctx, field, obj) + case "trigger": + out.Values[i] = ec._Function_trigger(ctx, field, obj) + case "creationTimestamp": + out.Values[i] = ec._Function_creationTimestamp(ctx, field, obj) + case "labels": + out.Values[i] = ec._Function_labels(ctx, field, obj) + case "environment": + out.Values[i] = ec._Function_environment(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _Function_name(ctx context.Context, field graphql.CollectedField, obj *Function) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Function" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _Function_trigger(ctx context.Context, field graphql.CollectedField, obj *Function) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Function" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Trigger + return graphql.MarshalString(res) +} + +func (ec *executionContext) _Function_creationTimestamp(ctx context.Context, field graphql.CollectedField, obj *Function) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Function" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.CreationTimestamp + return MarshalTimestamp(res) +} + +func (ec *executionContext) _Function_labels(ctx context.Context, field graphql.CollectedField, obj *Function) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Function" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Labels + return res +} + +func (ec *executionContext) _Function_environment(ctx context.Context, field graphql.CollectedField, obj *Function) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Function" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Environment + return graphql.MarshalString(res) +} + +var iDPPresetImplementors = []string{"IDPPreset"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _IDPPreset(ctx context.Context, sel []query.Selection, obj *IDPPreset) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, iDPPresetImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("IDPPreset") + case "name": + out.Values[i] = ec._IDPPreset_name(ctx, field, obj) + case "issuer": + out.Values[i] = ec._IDPPreset_issuer(ctx, field, obj) + case "jwksUri": + out.Values[i] = ec._IDPPreset_jwksUri(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _IDPPreset_name(ctx context.Context, field graphql.CollectedField, obj *IDPPreset) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "IDPPreset" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _IDPPreset_issuer(ctx context.Context, field graphql.CollectedField, obj *IDPPreset) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "IDPPreset" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Issuer + return graphql.MarshalString(res) +} + +func (ec *executionContext) _IDPPreset_jwksUri(ctx context.Context, field graphql.CollectedField, obj *IDPPreset) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "IDPPreset" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.JwksUri + return graphql.MarshalString(res) +} + +var limitRangeImplementors = []string{"LimitRange"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _LimitRange(ctx context.Context, sel []query.Selection, obj *LimitRange) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, limitRangeImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("LimitRange") + case "name": + out.Values[i] = ec._LimitRange_name(ctx, field, obj) + case "limits": + out.Values[i] = ec._LimitRange_limits(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _LimitRange_name(ctx context.Context, field graphql.CollectedField, obj *LimitRange) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "LimitRange" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _LimitRange_limits(ctx context.Context, field graphql.CollectedField, obj *LimitRange) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "LimitRange" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Limits + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._LimitRangeItem(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 +} + +var limitRangeItemImplementors = []string{"LimitRangeItem"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _LimitRangeItem(ctx context.Context, sel []query.Selection, obj *LimitRangeItem) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, limitRangeItemImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("LimitRangeItem") + case "limitType": + out.Values[i] = ec._LimitRangeItem_limitType(ctx, field, obj) + case "max": + out.Values[i] = ec._LimitRangeItem_max(ctx, field, obj) + case "default": + out.Values[i] = ec._LimitRangeItem_default(ctx, field, obj) + case "defaultRequest": + out.Values[i] = ec._LimitRangeItem_defaultRequest(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _LimitRangeItem_limitType(ctx context.Context, field graphql.CollectedField, obj *LimitRangeItem) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "LimitRangeItem" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.LimitType + return res +} + +func (ec *executionContext) _LimitRangeItem_max(ctx context.Context, field graphql.CollectedField, obj *LimitRangeItem) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "LimitRangeItem" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Max + return ec._ResourceType(ctx, field.Selections, &res) +} + +func (ec *executionContext) _LimitRangeItem_default(ctx context.Context, field graphql.CollectedField, obj *LimitRangeItem) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "LimitRangeItem" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Default + return ec._ResourceType(ctx, field.Selections, &res) +} + +func (ec *executionContext) _LimitRangeItem_defaultRequest(ctx context.Context, field graphql.CollectedField, obj *LimitRangeItem) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "LimitRangeItem" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.DefaultRequest + return ec._ResourceType(ctx, field.Selections, &res) +} + +var localObjectReferenceImplementors = []string{"LocalObjectReference"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _LocalObjectReference(ctx context.Context, sel []query.Selection, obj *LocalObjectReference) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, localObjectReferenceImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("LocalObjectReference") + case "kind": + out.Values[i] = ec._LocalObjectReference_kind(ctx, field, obj) + case "name": + out.Values[i] = ec._LocalObjectReference_name(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _LocalObjectReference_kind(ctx context.Context, field graphql.CollectedField, obj *LocalObjectReference) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "LocalObjectReference" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Kind + return res +} + +func (ec *executionContext) _LocalObjectReference_name(ctx context.Context, field graphql.CollectedField, obj *LocalObjectReference) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "LocalObjectReference" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +var mutationImplementors = []string{"Mutation"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _Mutation(ctx context.Context, sel []query.Selection) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, mutationImplementors, ec.Variables) + + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Mutation", + }) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Mutation") + case "createServiceInstance": + out.Values[i] = ec._Mutation_createServiceInstance(ctx, field) + case "deleteServiceInstance": + out.Values[i] = ec._Mutation_deleteServiceInstance(ctx, field) + case "createServiceBinding": + out.Values[i] = ec._Mutation_createServiceBinding(ctx, field) + case "deleteServiceBinding": + out.Values[i] = ec._Mutation_deleteServiceBinding(ctx, field) + case "createServiceBindingUsage": + out.Values[i] = ec._Mutation_createServiceBindingUsage(ctx, field) + case "deleteServiceBindingUsage": + out.Values[i] = ec._Mutation_deleteServiceBindingUsage(ctx, field) + case "enableRemoteEnvironment": + out.Values[i] = ec._Mutation_enableRemoteEnvironment(ctx, field) + case "disableRemoteEnvironment": + out.Values[i] = ec._Mutation_disableRemoteEnvironment(ctx, field) + case "createIDPPreset": + out.Values[i] = ec._Mutation_createIDPPreset(ctx, field) + case "deleteIDPPreset": + out.Values[i] = ec._Mutation_deleteIDPPreset(ctx, field) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _Mutation_createServiceInstance(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 ServiceInstanceCreateInput + if tmp, ok := field.Args["params"]; ok { + var err error + arg0, err = UnmarshalServiceInstanceCreateInput(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["params"] = arg0 + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Mutation" + rctx.Args = args + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Mutation_createServiceInstance(ctx, args["params"].(ServiceInstanceCreateInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*ServiceInstance) + if res == nil { + return graphql.Null + } + return ec._ServiceInstance(ctx, field.Selections, res) +} + +func (ec *executionContext) _Mutation_deleteServiceInstance(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["name"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["name"] = arg0 + var arg1 string + if tmp, ok := field.Args["environment"]; ok { + var err error + arg1, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["environment"] = arg1 + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Mutation" + rctx.Args = args + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Mutation_deleteServiceInstance(ctx, args["name"].(string), args["environment"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*ServiceInstance) + if res == nil { + return graphql.Null + } + return ec._ServiceInstance(ctx, field.Selections, res) +} + +func (ec *executionContext) _Mutation_createServiceBinding(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["serviceBindingName"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["serviceBindingName"] = arg0 + var arg1 string + if tmp, ok := field.Args["serviceInstanceName"]; ok { + var err error + arg1, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["serviceInstanceName"] = arg1 + var arg2 string + if tmp, ok := field.Args["environment"]; ok { + var err error + arg2, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["environment"] = arg2 + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Mutation" + rctx.Args = args + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Mutation_createServiceBinding(ctx, args["serviceBindingName"].(string), args["serviceInstanceName"].(string), args["environment"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*CreateServiceBindingOutput) + if res == nil { + return graphql.Null + } + return ec._CreateServiceBindingOutput(ctx, field.Selections, res) +} + +func (ec *executionContext) _Mutation_deleteServiceBinding(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["serviceBindingName"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["serviceBindingName"] = arg0 + var arg1 string + if tmp, ok := field.Args["environment"]; ok { + var err error + arg1, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["environment"] = arg1 + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Mutation" + rctx.Args = args + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Mutation_deleteServiceBinding(ctx, args["serviceBindingName"].(string), args["environment"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*DeleteServiceBindingOutput) + if res == nil { + return graphql.Null + } + return ec._DeleteServiceBindingOutput(ctx, field.Selections, res) +} + +func (ec *executionContext) _Mutation_createServiceBindingUsage(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 *CreateServiceBindingUsageInput + if tmp, ok := field.Args["createServiceBindingUsageInput"]; ok { + var err error + var ptr1 CreateServiceBindingUsageInput + if tmp != nil { + ptr1, err = UnmarshalCreateServiceBindingUsageInput(tmp) + arg0 = &ptr1 + } + + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["createServiceBindingUsageInput"] = arg0 + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Mutation" + rctx.Args = args + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Mutation_createServiceBindingUsage(ctx, args["createServiceBindingUsageInput"].(*CreateServiceBindingUsageInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*ServiceBindingUsage) + if res == nil { + return graphql.Null + } + return ec._ServiceBindingUsage(ctx, field.Selections, res) +} + +func (ec *executionContext) _Mutation_deleteServiceBindingUsage(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["serviceBindingUsageName"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["serviceBindingUsageName"] = arg0 + var arg1 string + if tmp, ok := field.Args["environment"]; ok { + var err error + arg1, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["environment"] = arg1 + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Mutation" + rctx.Args = args + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Mutation_deleteServiceBindingUsage(ctx, args["serviceBindingUsageName"].(string), args["environment"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*DeleteServiceBindingUsageOutput) + if res == nil { + return graphql.Null + } + return ec._DeleteServiceBindingUsageOutput(ctx, field.Selections, res) +} + +func (ec *executionContext) _Mutation_enableRemoteEnvironment(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["remoteEnvironment"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["remoteEnvironment"] = arg0 + var arg1 string + if tmp, ok := field.Args["environment"]; ok { + var err error + arg1, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["environment"] = arg1 + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Mutation" + rctx.Args = args + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Mutation_enableRemoteEnvironment(ctx, args["remoteEnvironment"].(string), args["environment"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*EnvironmentMapping) + if res == nil { + return graphql.Null + } + return ec._EnvironmentMapping(ctx, field.Selections, res) +} + +func (ec *executionContext) _Mutation_disableRemoteEnvironment(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["remoteEnvironment"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["remoteEnvironment"] = arg0 + var arg1 string + if tmp, ok := field.Args["environment"]; ok { + var err error + arg1, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["environment"] = arg1 + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Mutation" + rctx.Args = args + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Mutation_disableRemoteEnvironment(ctx, args["remoteEnvironment"].(string), args["environment"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*EnvironmentMapping) + if res == nil { + return graphql.Null + } + return ec._EnvironmentMapping(ctx, field.Selections, res) +} + +func (ec *executionContext) _Mutation_createIDPPreset(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["name"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["name"] = arg0 + var arg1 string + if tmp, ok := field.Args["issuer"]; ok { + var err error + arg1, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["issuer"] = arg1 + var arg2 string + if tmp, ok := field.Args["jwksUri"]; ok { + var err error + arg2, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["jwksUri"] = arg2 + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Mutation" + rctx.Args = args + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Mutation_createIDPPreset(ctx, args["name"].(string), args["issuer"].(string), args["jwksUri"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*IDPPreset) + if res == nil { + return graphql.Null + } + return ec._IDPPreset(ctx, field.Selections, res) +} + +func (ec *executionContext) _Mutation_deleteIDPPreset(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["name"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["name"] = arg0 + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Mutation" + rctx.Args = args + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Mutation_deleteIDPPreset(ctx, args["name"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*IDPPreset) + if res == nil { + return graphql.Null + } + return ec._IDPPreset(ctx, field.Selections, res) +} + +var queryImplementors = []string{"Query"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _Query(ctx context.Context, sel []query.Selection) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, queryImplementors, ec.Variables) + + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + }) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Query") + case "serviceInstance": + out.Values[i] = ec._Query_serviceInstance(ctx, field) + case "serviceInstances": + out.Values[i] = ec._Query_serviceInstances(ctx, field) + case "serviceClasses": + out.Values[i] = ec._Query_serviceClasses(ctx, field) + case "serviceClass": + out.Values[i] = ec._Query_serviceClass(ctx, field) + case "serviceBrokers": + out.Values[i] = ec._Query_serviceBrokers(ctx, field) + case "serviceBroker": + out.Values[i] = ec._Query_serviceBroker(ctx, field) + case "serviceBindingUsage": + out.Values[i] = ec._Query_serviceBindingUsage(ctx, field) + case "serviceBinding": + out.Values[i] = ec._Query_serviceBinding(ctx, field) + case "apis": + out.Values[i] = ec._Query_apis(ctx, field) + case "remoteEnvironment": + out.Values[i] = ec._Query_remoteEnvironment(ctx, field) + case "remoteEnvironments": + out.Values[i] = ec._Query_remoteEnvironments(ctx, field) + case "connectorService": + out.Values[i] = ec._Query_connectorService(ctx, field) + case "environments": + out.Values[i] = ec._Query_environments(ctx, field) + case "deployments": + out.Values[i] = ec._Query_deployments(ctx, field) + case "resourceQuotas": + out.Values[i] = ec._Query_resourceQuotas(ctx, field) + case "functions": + out.Values[i] = ec._Query_functions(ctx, field) + case "content": + out.Values[i] = ec._Query_content(ctx, field) + case "topics": + out.Values[i] = ec._Query_topics(ctx, field) + case "eventActivations": + out.Values[i] = ec._Query_eventActivations(ctx, field) + case "limitRanges": + out.Values[i] = ec._Query_limitRanges(ctx, field) + case "IDPPreset": + out.Values[i] = ec._Query_IDPPreset(ctx, field) + case "IDPPresets": + out.Values[i] = ec._Query_IDPPresets(ctx, field) + case "__schema": + out.Values[i] = ec._Query___schema(ctx, field) + case "__type": + out.Values[i] = ec._Query___type(ctx, field) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _Query_serviceInstance(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["name"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["name"] = arg0 + var arg1 string + if tmp, ok := field.Args["environment"]; ok { + var err error + arg1, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["environment"] = arg1 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_serviceInstance(ctx, args["name"].(string), args["environment"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*ServiceInstance) + if res == nil { + return graphql.Null + } + return ec._ServiceInstance(ctx, field.Selections, res) + }) +} + +func (ec *executionContext) _Query_serviceInstances(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["environment"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["environment"] = arg0 + var arg1 *int + if tmp, ok := field.Args["first"]; ok { + var err error + var ptr1 int + if tmp != nil { + ptr1, err = graphql.UnmarshalInt(tmp) + arg1 = &ptr1 + } + + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["first"] = arg1 + var arg2 *int + if tmp, ok := field.Args["offset"]; ok { + var err error + var ptr1 int + if tmp != nil { + ptr1, err = graphql.UnmarshalInt(tmp) + arg2 = &ptr1 + } + + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["offset"] = arg2 + var arg3 *InstanceStatusType + if tmp, ok := field.Args["status"]; ok { + var err error + var ptr1 InstanceStatusType + if tmp != nil { + err = (&ptr1).UnmarshalGQL(tmp) + arg3 = &ptr1 + } + + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["status"] = arg3 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_serviceInstances(ctx, args["environment"].(string), args["first"].(*int), args["offset"].(*int), args["status"].(*InstanceStatusType)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]ServiceInstance) + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._ServiceInstance(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 + }) +} + +func (ec *executionContext) _Query_serviceClasses(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 *int + if tmp, ok := field.Args["first"]; ok { + var err error + var ptr1 int + if tmp != nil { + ptr1, err = graphql.UnmarshalInt(tmp) + arg0 = &ptr1 + } + + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["first"] = arg0 + var arg1 *int + if tmp, ok := field.Args["offset"]; ok { + var err error + var ptr1 int + if tmp != nil { + ptr1, err = graphql.UnmarshalInt(tmp) + arg1 = &ptr1 + } + + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["offset"] = arg1 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_serviceClasses(ctx, args["first"].(*int), args["offset"].(*int)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]ServiceClass) + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._ServiceClass(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 + }) +} + +func (ec *executionContext) _Query_serviceClass(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["name"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["name"] = arg0 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_serviceClass(ctx, args["name"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*ServiceClass) + if res == nil { + return graphql.Null + } + return ec._ServiceClass(ctx, field.Selections, res) + }) +} + +func (ec *executionContext) _Query_serviceBrokers(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 *int + if tmp, ok := field.Args["first"]; ok { + var err error + var ptr1 int + if tmp != nil { + ptr1, err = graphql.UnmarshalInt(tmp) + arg0 = &ptr1 + } + + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["first"] = arg0 + var arg1 *int + if tmp, ok := field.Args["offset"]; ok { + var err error + var ptr1 int + if tmp != nil { + ptr1, err = graphql.UnmarshalInt(tmp) + arg1 = &ptr1 + } + + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["offset"] = arg1 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_serviceBrokers(ctx, args["first"].(*int), args["offset"].(*int)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]ServiceBroker) + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._ServiceBroker(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 + }) +} + +func (ec *executionContext) _Query_serviceBroker(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["name"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["name"] = arg0 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_serviceBroker(ctx, args["name"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*ServiceBroker) + if res == nil { + return graphql.Null + } + return ec._ServiceBroker(ctx, field.Selections, res) + }) +} + +func (ec *executionContext) _Query_serviceBindingUsage(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["name"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["name"] = arg0 + var arg1 string + if tmp, ok := field.Args["environment"]; ok { + var err error + arg1, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["environment"] = arg1 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_serviceBindingUsage(ctx, args["name"].(string), args["environment"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*ServiceBindingUsage) + if res == nil { + return graphql.Null + } + return ec._ServiceBindingUsage(ctx, field.Selections, res) + }) +} + +func (ec *executionContext) _Query_serviceBinding(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["name"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["name"] = arg0 + var arg1 string + if tmp, ok := field.Args["environment"]; ok { + var err error + arg1, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["environment"] = arg1 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_serviceBinding(ctx, args["name"].(string), args["environment"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*ServiceBinding) + if res == nil { + return graphql.Null + } + return ec._ServiceBinding(ctx, field.Selections, res) + }) +} + +func (ec *executionContext) _Query_apis(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["environment"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["environment"] = arg0 + var arg1 *string + if tmp, ok := field.Args["serviceName"]; ok { + var err error + var ptr1 string + if tmp != nil { + ptr1, err = graphql.UnmarshalString(tmp) + arg1 = &ptr1 + } + + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["serviceName"] = arg1 + var arg2 *string + if tmp, ok := field.Args["hostname"]; ok { + var err error + var ptr1 string + if tmp != nil { + ptr1, err = graphql.UnmarshalString(tmp) + arg2 = &ptr1 + } + + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["hostname"] = arg2 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_apis(ctx, args["environment"].(string), args["serviceName"].(*string), args["hostname"].(*string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]API) + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._API(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 + }) +} + +func (ec *executionContext) _Query_remoteEnvironment(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["name"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["name"] = arg0 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_remoteEnvironment(ctx, args["name"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*RemoteEnvironment) + if res == nil { + return graphql.Null + } + return ec._RemoteEnvironment(ctx, field.Selections, res) + }) +} + +func (ec *executionContext) _Query_remoteEnvironments(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 *string + if tmp, ok := field.Args["environment"]; ok { + var err error + var ptr1 string + if tmp != nil { + ptr1, err = graphql.UnmarshalString(tmp) + arg0 = &ptr1 + } + + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["environment"] = arg0 + var arg1 *int + if tmp, ok := field.Args["first"]; ok { + var err error + var ptr1 int + if tmp != nil { + ptr1, err = graphql.UnmarshalInt(tmp) + arg1 = &ptr1 + } + + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["first"] = arg1 + var arg2 *int + if tmp, ok := field.Args["offset"]; ok { + var err error + var ptr1 int + if tmp != nil { + ptr1, err = graphql.UnmarshalInt(tmp) + arg2 = &ptr1 + } + + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["offset"] = arg2 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_remoteEnvironments(ctx, args["environment"].(*string), args["first"].(*int), args["offset"].(*int)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]RemoteEnvironment) + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._RemoteEnvironment(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 + }) +} + +func (ec *executionContext) _Query_connectorService(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["remoteEnvironment"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["remoteEnvironment"] = arg0 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_connectorService(ctx, args["remoteEnvironment"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(ConnectorService) + return ec._ConnectorService(ctx, field.Selections, &res) + }) +} + +func (ec *executionContext) _Query_environments(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 *string + if tmp, ok := field.Args["remoteEnvironment"]; ok { + var err error + var ptr1 string + if tmp != nil { + ptr1, err = graphql.UnmarshalString(tmp) + arg0 = &ptr1 + } + + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["remoteEnvironment"] = arg0 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_environments(ctx, args["remoteEnvironment"].(*string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]Environment) + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._Environment(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 + }) +} + +func (ec *executionContext) _Query_deployments(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["environment"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["environment"] = arg0 + var arg1 *bool + if tmp, ok := field.Args["excludeFunctions"]; ok { + var err error + var ptr1 bool + if tmp != nil { + ptr1, err = graphql.UnmarshalBoolean(tmp) + arg1 = &ptr1 + } + + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["excludeFunctions"] = arg1 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_deployments(ctx, args["environment"].(string), args["excludeFunctions"].(*bool)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]Deployment) + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._Deployment(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 + }) +} + +func (ec *executionContext) _Query_resourceQuotas(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["environment"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["environment"] = arg0 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_resourceQuotas(ctx, args["environment"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]ResourceQuota) + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._ResourceQuota(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 + }) +} + +func (ec *executionContext) _Query_functions(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["environment"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["environment"] = arg0 + var arg1 *int + if tmp, ok := field.Args["first"]; ok { + var err error + var ptr1 int + if tmp != nil { + ptr1, err = graphql.UnmarshalInt(tmp) + arg1 = &ptr1 + } + + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["first"] = arg1 + var arg2 *int + if tmp, ok := field.Args["offset"]; ok { + var err error + var ptr1 int + if tmp != nil { + ptr1, err = graphql.UnmarshalInt(tmp) + arg2 = &ptr1 + } + + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["offset"] = arg2 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_functions(ctx, args["environment"].(string), args["first"].(*int), args["offset"].(*int)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]Function) + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._Function(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 + }) +} + +func (ec *executionContext) _Query_content(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["contentType"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["contentType"] = arg0 + var arg1 string + if tmp, ok := field.Args["id"]; ok { + var err error + arg1, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["id"] = arg1 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_content(ctx, args["contentType"].(string), args["id"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*JSON) + if res == nil { + return graphql.Null + } + return *res + }) +} + +func (ec *executionContext) _Query_topics(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 []InputTopic + if tmp, ok := field.Args["input"]; ok { + var err error + var rawIf1 []interface{} + if tmp != nil { + rawIf1 = tmp.([]interface{}) + } + arg0 = make([]InputTopic, len(rawIf1)) + for idx1 := range rawIf1 { + arg0[idx1], err = UnmarshalInputTopic(rawIf1[idx1]) + } + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["input"] = arg0 + var arg1 *bool + if tmp, ok := field.Args["internal"]; ok { + var err error + var ptr1 bool + if tmp != nil { + ptr1, err = graphql.UnmarshalBoolean(tmp) + arg1 = &ptr1 + } + + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["internal"] = arg1 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_topics(ctx, args["input"].([]InputTopic), args["internal"].(*bool)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]TopicEntry) + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._TopicEntry(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 + }) +} + +func (ec *executionContext) _Query_eventActivations(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["environment"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["environment"] = arg0 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_eventActivations(ctx, args["environment"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]EventActivation) + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._EventActivation(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 + }) +} + +func (ec *executionContext) _Query_limitRanges(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["environment"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["environment"] = arg0 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_limitRanges(ctx, args["environment"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]LimitRange) + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._LimitRange(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 + }) +} + +func (ec *executionContext) _Query_IDPPreset(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["name"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["name"] = arg0 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_IDPPreset(ctx, args["name"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*IDPPreset) + if res == nil { + return graphql.Null + } + return ec._IDPPreset(ctx, field.Selections, res) + }) +} + +func (ec *executionContext) _Query_IDPPresets(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 *int + if tmp, ok := field.Args["first"]; ok { + var err error + var ptr1 int + if tmp != nil { + ptr1, err = graphql.UnmarshalInt(tmp) + arg0 = &ptr1 + } + + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["first"] = arg0 + var arg1 *int + if tmp, ok := field.Args["offset"]; ok { + var err error + var ptr1 int + if tmp != nil { + ptr1, err = graphql.UnmarshalInt(tmp) + arg1 = &ptr1 + } + + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["offset"] = arg1 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Query", + Args: args, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.Query_IDPPresets(ctx, args["first"].(*int), args["offset"].(*int)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]IDPPreset) + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._IDPPreset(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 + }) +} + +func (ec *executionContext) _Query___schema(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Query" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := ec.introspectSchema() + if res == nil { + return graphql.Null + } + return ec.___Schema(ctx, field.Selections, res) +} + +func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["name"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["name"] = arg0 + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Query" + rctx.Args = args + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := ec.introspectType(args["name"].(string)) + if res == nil { + return graphql.Null + } + return ec.___Type(ctx, field.Selections, res) +} + +var remoteEnvironmentImplementors = []string{"RemoteEnvironment"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _RemoteEnvironment(ctx context.Context, sel []query.Selection, obj *RemoteEnvironment) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, remoteEnvironmentImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("RemoteEnvironment") + case "name": + out.Values[i] = ec._RemoteEnvironment_name(ctx, field, obj) + case "description": + out.Values[i] = ec._RemoteEnvironment_description(ctx, field, obj) + case "source": + out.Values[i] = ec._RemoteEnvironment_source(ctx, field, obj) + case "services": + out.Values[i] = ec._RemoteEnvironment_services(ctx, field, obj) + case "enabledInEnvironments": + out.Values[i] = ec._RemoteEnvironment_enabledInEnvironments(ctx, field, obj) + case "status": + out.Values[i] = ec._RemoteEnvironment_status(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _RemoteEnvironment_name(ctx context.Context, field graphql.CollectedField, obj *RemoteEnvironment) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "RemoteEnvironment" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _RemoteEnvironment_description(ctx context.Context, field graphql.CollectedField, obj *RemoteEnvironment) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "RemoteEnvironment" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Description + return graphql.MarshalString(res) +} + +func (ec *executionContext) _RemoteEnvironment_source(ctx context.Context, field graphql.CollectedField, obj *RemoteEnvironment) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "RemoteEnvironment" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Source + return ec._RemoteEnvironmentSource(ctx, field.Selections, &res) +} + +func (ec *executionContext) _RemoteEnvironment_services(ctx context.Context, field graphql.CollectedField, obj *RemoteEnvironment) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "RemoteEnvironment" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Services + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._RemoteEnvironmentService(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 +} + +func (ec *executionContext) _RemoteEnvironment_enabledInEnvironments(ctx context.Context, field graphql.CollectedField, obj *RemoteEnvironment) graphql.Marshaler { + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "RemoteEnvironment", + Args: nil, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.RemoteEnvironment_enabledInEnvironments(ctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]string) + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return graphql.MarshalString(res[idx1]) + }()) + } + return arr1 + }) +} + +func (ec *executionContext) _RemoteEnvironment_status(ctx context.Context, field graphql.CollectedField, obj *RemoteEnvironment) graphql.Marshaler { + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "RemoteEnvironment", + Args: nil, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.RemoteEnvironment_status(ctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(RemoteEnvironmentStatus) + return res + }) +} + +var remoteEnvironmentEntryImplementors = []string{"RemoteEnvironmentEntry"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _RemoteEnvironmentEntry(ctx context.Context, sel []query.Selection, obj *RemoteEnvironmentEntry) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, remoteEnvironmentEntryImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("RemoteEnvironmentEntry") + case "type": + out.Values[i] = ec._RemoteEnvironmentEntry_type(ctx, field, obj) + case "gatewayUrl": + out.Values[i] = ec._RemoteEnvironmentEntry_gatewayUrl(ctx, field, obj) + case "accessLabel": + out.Values[i] = ec._RemoteEnvironmentEntry_accessLabel(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _RemoteEnvironmentEntry_type(ctx context.Context, field graphql.CollectedField, obj *RemoteEnvironmentEntry) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "RemoteEnvironmentEntry" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Type + return graphql.MarshalString(res) +} + +func (ec *executionContext) _RemoteEnvironmentEntry_gatewayUrl(ctx context.Context, field graphql.CollectedField, obj *RemoteEnvironmentEntry) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "RemoteEnvironmentEntry" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.GatewayUrl + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +func (ec *executionContext) _RemoteEnvironmentEntry_accessLabel(ctx context.Context, field graphql.CollectedField, obj *RemoteEnvironmentEntry) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "RemoteEnvironmentEntry" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.AccessLabel + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +var remoteEnvironmentServiceImplementors = []string{"RemoteEnvironmentService"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _RemoteEnvironmentService(ctx context.Context, sel []query.Selection, obj *RemoteEnvironmentService) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, remoteEnvironmentServiceImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("RemoteEnvironmentService") + case "id": + out.Values[i] = ec._RemoteEnvironmentService_id(ctx, field, obj) + case "displayName": + out.Values[i] = ec._RemoteEnvironmentService_displayName(ctx, field, obj) + case "longDescription": + out.Values[i] = ec._RemoteEnvironmentService_longDescription(ctx, field, obj) + case "providerDisplayName": + out.Values[i] = ec._RemoteEnvironmentService_providerDisplayName(ctx, field, obj) + case "tags": + out.Values[i] = ec._RemoteEnvironmentService_tags(ctx, field, obj) + case "entries": + out.Values[i] = ec._RemoteEnvironmentService_entries(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _RemoteEnvironmentService_id(ctx context.Context, field graphql.CollectedField, obj *RemoteEnvironmentService) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "RemoteEnvironmentService" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.ID + return graphql.MarshalString(res) +} + +func (ec *executionContext) _RemoteEnvironmentService_displayName(ctx context.Context, field graphql.CollectedField, obj *RemoteEnvironmentService) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "RemoteEnvironmentService" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.DisplayName + return graphql.MarshalString(res) +} + +func (ec *executionContext) _RemoteEnvironmentService_longDescription(ctx context.Context, field graphql.CollectedField, obj *RemoteEnvironmentService) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "RemoteEnvironmentService" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.LongDescription + return graphql.MarshalString(res) +} + +func (ec *executionContext) _RemoteEnvironmentService_providerDisplayName(ctx context.Context, field graphql.CollectedField, obj *RemoteEnvironmentService) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "RemoteEnvironmentService" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.ProviderDisplayName + return graphql.MarshalString(res) +} + +func (ec *executionContext) _RemoteEnvironmentService_tags(ctx context.Context, field graphql.CollectedField, obj *RemoteEnvironmentService) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "RemoteEnvironmentService" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Tags + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return graphql.MarshalString(res[idx1]) + }()) + } + return arr1 +} + +func (ec *executionContext) _RemoteEnvironmentService_entries(ctx context.Context, field graphql.CollectedField, obj *RemoteEnvironmentService) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "RemoteEnvironmentService" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Entries + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._RemoteEnvironmentEntry(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 +} + +var remoteEnvironmentSourceImplementors = []string{"RemoteEnvironmentSource"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _RemoteEnvironmentSource(ctx context.Context, sel []query.Selection, obj *RemoteEnvironmentSource) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, remoteEnvironmentSourceImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("RemoteEnvironmentSource") + case "environment": + out.Values[i] = ec._RemoteEnvironmentSource_environment(ctx, field, obj) + case "type": + out.Values[i] = ec._RemoteEnvironmentSource_type(ctx, field, obj) + case "namespace": + out.Values[i] = ec._RemoteEnvironmentSource_namespace(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _RemoteEnvironmentSource_environment(ctx context.Context, field graphql.CollectedField, obj *RemoteEnvironmentSource) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "RemoteEnvironmentSource" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Environment + return graphql.MarshalString(res) +} + +func (ec *executionContext) _RemoteEnvironmentSource_type(ctx context.Context, field graphql.CollectedField, obj *RemoteEnvironmentSource) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "RemoteEnvironmentSource" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Type + return graphql.MarshalString(res) +} + +func (ec *executionContext) _RemoteEnvironmentSource_namespace(ctx context.Context, field graphql.CollectedField, obj *RemoteEnvironmentSource) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "RemoteEnvironmentSource" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Namespace + return graphql.MarshalString(res) +} + +var resourceQuotaImplementors = []string{"ResourceQuota"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _ResourceQuota(ctx context.Context, sel []query.Selection, obj *ResourceQuota) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, resourceQuotaImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ResourceQuota") + case "name": + out.Values[i] = ec._ResourceQuota_name(ctx, field, obj) + case "pods": + out.Values[i] = ec._ResourceQuota_pods(ctx, field, obj) + case "limits": + out.Values[i] = ec._ResourceQuota_limits(ctx, field, obj) + case "requests": + out.Values[i] = ec._ResourceQuota_requests(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _ResourceQuota_name(ctx context.Context, field graphql.CollectedField, obj *ResourceQuota) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ResourceQuota" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ResourceQuota_pods(ctx context.Context, field graphql.CollectedField, obj *ResourceQuota) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ResourceQuota" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Pods + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +func (ec *executionContext) _ResourceQuota_limits(ctx context.Context, field graphql.CollectedField, obj *ResourceQuota) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ResourceQuota" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Limits + return ec._ResourceValues(ctx, field.Selections, &res) +} + +func (ec *executionContext) _ResourceQuota_requests(ctx context.Context, field graphql.CollectedField, obj *ResourceQuota) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ResourceQuota" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Requests + return ec._ResourceValues(ctx, field.Selections, &res) +} + +var resourceTypeImplementors = []string{"ResourceType"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _ResourceType(ctx context.Context, sel []query.Selection, obj *ResourceType) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, resourceTypeImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ResourceType") + case "memory": + out.Values[i] = ec._ResourceType_memory(ctx, field, obj) + case "cpu": + out.Values[i] = ec._ResourceType_cpu(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _ResourceType_memory(ctx context.Context, field graphql.CollectedField, obj *ResourceType) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ResourceType" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Memory + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +func (ec *executionContext) _ResourceType_cpu(ctx context.Context, field graphql.CollectedField, obj *ResourceType) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ResourceType" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Cpu + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +var resourceValuesImplementors = []string{"ResourceValues"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _ResourceValues(ctx context.Context, sel []query.Selection, obj *ResourceValues) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, resourceValuesImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ResourceValues") + case "memory": + out.Values[i] = ec._ResourceValues_memory(ctx, field, obj) + case "cpu": + out.Values[i] = ec._ResourceValues_cpu(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _ResourceValues_memory(ctx context.Context, field graphql.CollectedField, obj *ResourceValues) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ResourceValues" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Memory + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +func (ec *executionContext) _ResourceValues_cpu(ctx context.Context, field graphql.CollectedField, obj *ResourceValues) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ResourceValues" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Cpu + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +var secretImplementors = []string{"Secret"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _Secret(ctx context.Context, sel []query.Selection, obj *Secret) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, secretImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Secret") + case "name": + out.Values[i] = ec._Secret_name(ctx, field, obj) + case "environment": + out.Values[i] = ec._Secret_environment(ctx, field, obj) + case "data": + out.Values[i] = ec._Secret_data(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _Secret_name(ctx context.Context, field graphql.CollectedField, obj *Secret) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Secret" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _Secret_environment(ctx context.Context, field graphql.CollectedField, obj *Secret) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Secret" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Environment + return graphql.MarshalString(res) +} + +func (ec *executionContext) _Secret_data(ctx context.Context, field graphql.CollectedField, obj *Secret) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Secret" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Data + return res +} + +var sectionImplementors = []string{"Section"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _Section(ctx context.Context, sel []query.Selection, obj *Section) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, sectionImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Section") + case "titles": + out.Values[i] = ec._Section_titles(ctx, field, obj) + case "topicType": + out.Values[i] = ec._Section_topicType(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _Section_titles(ctx context.Context, field graphql.CollectedField, obj *Section) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Section" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Titles + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._Title(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 +} + +func (ec *executionContext) _Section_topicType(ctx context.Context, field graphql.CollectedField, obj *Section) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Section" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.TopicType + return graphql.MarshalString(res) +} + +var serviceImplementors = []string{"Service"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _Service(ctx context.Context, sel []query.Selection, obj *Service) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, serviceImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Service") + case "name": + out.Values[i] = ec._Service_name(ctx, field, obj) + case "port": + out.Values[i] = ec._Service_port(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _Service_name(ctx context.Context, field graphql.CollectedField, obj *Service) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Service" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _Service_port(ctx context.Context, field graphql.CollectedField, obj *Service) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Service" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Port + return graphql.MarshalInt(res) +} + +var serviceBindingImplementors = []string{"ServiceBinding"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _ServiceBinding(ctx context.Context, sel []query.Selection, obj *ServiceBinding) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, serviceBindingImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ServiceBinding") + case "name": + out.Values[i] = ec._ServiceBinding_name(ctx, field, obj) + case "serviceInstanceName": + out.Values[i] = ec._ServiceBinding_serviceInstanceName(ctx, field, obj) + case "environment": + out.Values[i] = ec._ServiceBinding_environment(ctx, field, obj) + case "secret": + out.Values[i] = ec._ServiceBinding_secret(ctx, field, obj) + case "status": + out.Values[i] = ec._ServiceBinding_status(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _ServiceBinding_name(ctx context.Context, field graphql.CollectedField, obj *ServiceBinding) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBinding" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServiceBinding_serviceInstanceName(ctx context.Context, field graphql.CollectedField, obj *ServiceBinding) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBinding" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.ServiceInstanceName + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServiceBinding_environment(ctx context.Context, field graphql.CollectedField, obj *ServiceBinding) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBinding" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Environment + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServiceBinding_secret(ctx context.Context, field graphql.CollectedField, obj *ServiceBinding) graphql.Marshaler { + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "ServiceBinding", + Args: nil, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.ServiceBinding_secret(ctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*Secret) + if res == nil { + return graphql.Null + } + return ec._Secret(ctx, field.Selections, res) + }) +} + +func (ec *executionContext) _ServiceBinding_status(ctx context.Context, field graphql.CollectedField, obj *ServiceBinding) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBinding" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Status + return ec._ServiceBindingStatus(ctx, field.Selections, &res) +} + +var serviceBindingStatusImplementors = []string{"ServiceBindingStatus"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _ServiceBindingStatus(ctx context.Context, sel []query.Selection, obj *ServiceBindingStatus) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, serviceBindingStatusImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ServiceBindingStatus") + case "type": + out.Values[i] = ec._ServiceBindingStatus_type(ctx, field, obj) + case "reason": + out.Values[i] = ec._ServiceBindingStatus_reason(ctx, field, obj) + case "message": + out.Values[i] = ec._ServiceBindingStatus_message(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _ServiceBindingStatus_type(ctx context.Context, field graphql.CollectedField, obj *ServiceBindingStatus) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBindingStatus" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Type + return res +} + +func (ec *executionContext) _ServiceBindingStatus_reason(ctx context.Context, field graphql.CollectedField, obj *ServiceBindingStatus) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBindingStatus" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Reason + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServiceBindingStatus_message(ctx context.Context, field graphql.CollectedField, obj *ServiceBindingStatus) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBindingStatus" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Message + return graphql.MarshalString(res) +} + +var serviceBindingUsageImplementors = []string{"ServiceBindingUsage"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _ServiceBindingUsage(ctx context.Context, sel []query.Selection, obj *ServiceBindingUsage) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, serviceBindingUsageImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ServiceBindingUsage") + case "name": + out.Values[i] = ec._ServiceBindingUsage_name(ctx, field, obj) + case "environment": + out.Values[i] = ec._ServiceBindingUsage_environment(ctx, field, obj) + case "serviceBinding": + out.Values[i] = ec._ServiceBindingUsage_serviceBinding(ctx, field, obj) + case "usedBy": + out.Values[i] = ec._ServiceBindingUsage_usedBy(ctx, field, obj) + case "parameters": + out.Values[i] = ec._ServiceBindingUsage_parameters(ctx, field, obj) + case "status": + out.Values[i] = ec._ServiceBindingUsage_status(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _ServiceBindingUsage_name(ctx context.Context, field graphql.CollectedField, obj *ServiceBindingUsage) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBindingUsage" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServiceBindingUsage_environment(ctx context.Context, field graphql.CollectedField, obj *ServiceBindingUsage) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBindingUsage" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Environment + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServiceBindingUsage_serviceBinding(ctx context.Context, field graphql.CollectedField, obj *ServiceBindingUsage) graphql.Marshaler { + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "ServiceBindingUsage", + Args: nil, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.ServiceBindingUsage_serviceBinding(ctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*ServiceBinding) + if res == nil { + return graphql.Null + } + return ec._ServiceBinding(ctx, field.Selections, res) + }) +} + +func (ec *executionContext) _ServiceBindingUsage_usedBy(ctx context.Context, field graphql.CollectedField, obj *ServiceBindingUsage) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBindingUsage" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.UsedBy + return ec._LocalObjectReference(ctx, field.Selections, &res) +} + +func (ec *executionContext) _ServiceBindingUsage_parameters(ctx context.Context, field graphql.CollectedField, obj *ServiceBindingUsage) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBindingUsage" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Parameters + if res == nil { + return graphql.Null + } + return ec._ServiceBindingUsageParameters(ctx, field.Selections, res) +} + +func (ec *executionContext) _ServiceBindingUsage_status(ctx context.Context, field graphql.CollectedField, obj *ServiceBindingUsage) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBindingUsage" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Status + return ec._ServiceBindingUsageStatus(ctx, field.Selections, &res) +} + +var serviceBindingUsageParametersImplementors = []string{"ServiceBindingUsageParameters"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _ServiceBindingUsageParameters(ctx context.Context, sel []query.Selection, obj *ServiceBindingUsageParameters) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, serviceBindingUsageParametersImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ServiceBindingUsageParameters") + case "envPrefix": + out.Values[i] = ec._ServiceBindingUsageParameters_envPrefix(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _ServiceBindingUsageParameters_envPrefix(ctx context.Context, field graphql.CollectedField, obj *ServiceBindingUsageParameters) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBindingUsageParameters" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.EnvPrefix + if res == nil { + return graphql.Null + } + return ec._EnvPrefix(ctx, field.Selections, res) +} + +var serviceBindingUsageStatusImplementors = []string{"ServiceBindingUsageStatus"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _ServiceBindingUsageStatus(ctx context.Context, sel []query.Selection, obj *ServiceBindingUsageStatus) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, serviceBindingUsageStatusImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ServiceBindingUsageStatus") + case "type": + out.Values[i] = ec._ServiceBindingUsageStatus_type(ctx, field, obj) + case "reason": + out.Values[i] = ec._ServiceBindingUsageStatus_reason(ctx, field, obj) + case "message": + out.Values[i] = ec._ServiceBindingUsageStatus_message(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _ServiceBindingUsageStatus_type(ctx context.Context, field graphql.CollectedField, obj *ServiceBindingUsageStatus) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBindingUsageStatus" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Type + return res +} + +func (ec *executionContext) _ServiceBindingUsageStatus_reason(ctx context.Context, field graphql.CollectedField, obj *ServiceBindingUsageStatus) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBindingUsageStatus" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Reason + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServiceBindingUsageStatus_message(ctx context.Context, field graphql.CollectedField, obj *ServiceBindingUsageStatus) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBindingUsageStatus" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Message + return graphql.MarshalString(res) +} + +var serviceBrokerImplementors = []string{"ServiceBroker"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _ServiceBroker(ctx context.Context, sel []query.Selection, obj *ServiceBroker) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, serviceBrokerImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ServiceBroker") + case "name": + out.Values[i] = ec._ServiceBroker_name(ctx, field, obj) + case "status": + out.Values[i] = ec._ServiceBroker_status(ctx, field, obj) + case "creationTimestamp": + out.Values[i] = ec._ServiceBroker_creationTimestamp(ctx, field, obj) + case "url": + out.Values[i] = ec._ServiceBroker_url(ctx, field, obj) + case "labels": + out.Values[i] = ec._ServiceBroker_labels(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _ServiceBroker_name(ctx context.Context, field graphql.CollectedField, obj *ServiceBroker) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBroker" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServiceBroker_status(ctx context.Context, field graphql.CollectedField, obj *ServiceBroker) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBroker" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Status + return ec._ServiceBrokerStatus(ctx, field.Selections, &res) +} + +func (ec *executionContext) _ServiceBroker_creationTimestamp(ctx context.Context, field graphql.CollectedField, obj *ServiceBroker) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBroker" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.CreationTimestamp + return MarshalTimestamp(res) +} + +func (ec *executionContext) _ServiceBroker_url(ctx context.Context, field graphql.CollectedField, obj *ServiceBroker) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBroker" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Url + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServiceBroker_labels(ctx context.Context, field graphql.CollectedField, obj *ServiceBroker) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBroker" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Labels + return res +} + +var serviceBrokerStatusImplementors = []string{"ServiceBrokerStatus"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _ServiceBrokerStatus(ctx context.Context, sel []query.Selection, obj *ServiceBrokerStatus) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, serviceBrokerStatusImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ServiceBrokerStatus") + case "ready": + out.Values[i] = ec._ServiceBrokerStatus_ready(ctx, field, obj) + case "reason": + out.Values[i] = ec._ServiceBrokerStatus_reason(ctx, field, obj) + case "message": + out.Values[i] = ec._ServiceBrokerStatus_message(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _ServiceBrokerStatus_ready(ctx context.Context, field graphql.CollectedField, obj *ServiceBrokerStatus) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBrokerStatus" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Ready + return graphql.MarshalBoolean(res) +} + +func (ec *executionContext) _ServiceBrokerStatus_reason(ctx context.Context, field graphql.CollectedField, obj *ServiceBrokerStatus) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBrokerStatus" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Reason + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServiceBrokerStatus_message(ctx context.Context, field graphql.CollectedField, obj *ServiceBrokerStatus) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceBrokerStatus" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Message + return graphql.MarshalString(res) +} + +var serviceClassImplementors = []string{"ServiceClass"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _ServiceClass(ctx context.Context, sel []query.Selection, obj *ServiceClass) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, serviceClassImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ServiceClass") + case "name": + out.Values[i] = ec._ServiceClass_name(ctx, field, obj) + case "externalName": + out.Values[i] = ec._ServiceClass_externalName(ctx, field, obj) + case "displayName": + out.Values[i] = ec._ServiceClass_displayName(ctx, field, obj) + case "creationTimestamp": + out.Values[i] = ec._ServiceClass_creationTimestamp(ctx, field, obj) + case "description": + out.Values[i] = ec._ServiceClass_description(ctx, field, obj) + case "imageUrl": + out.Values[i] = ec._ServiceClass_imageUrl(ctx, field, obj) + case "documentationUrl": + out.Values[i] = ec._ServiceClass_documentationUrl(ctx, field, obj) + case "providerDisplayName": + out.Values[i] = ec._ServiceClass_providerDisplayName(ctx, field, obj) + case "tags": + out.Values[i] = ec._ServiceClass_tags(ctx, field, obj) + case "plans": + out.Values[i] = ec._ServiceClass_plans(ctx, field, obj) + case "activated": + out.Values[i] = ec._ServiceClass_activated(ctx, field, obj) + case "apiSpec": + out.Values[i] = ec._ServiceClass_apiSpec(ctx, field, obj) + case "asyncApiSpec": + out.Values[i] = ec._ServiceClass_asyncApiSpec(ctx, field, obj) + case "content": + out.Values[i] = ec._ServiceClass_content(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _ServiceClass_name(ctx context.Context, field graphql.CollectedField, obj *ServiceClass) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceClass" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServiceClass_externalName(ctx context.Context, field graphql.CollectedField, obj *ServiceClass) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceClass" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.ExternalName + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServiceClass_displayName(ctx context.Context, field graphql.CollectedField, obj *ServiceClass) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceClass" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.DisplayName + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +func (ec *executionContext) _ServiceClass_creationTimestamp(ctx context.Context, field graphql.CollectedField, obj *ServiceClass) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceClass" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.CreationTimestamp + return MarshalTimestamp(res) +} + +func (ec *executionContext) _ServiceClass_description(ctx context.Context, field graphql.CollectedField, obj *ServiceClass) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceClass" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Description + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServiceClass_imageUrl(ctx context.Context, field graphql.CollectedField, obj *ServiceClass) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceClass" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.ImageUrl + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +func (ec *executionContext) _ServiceClass_documentationUrl(ctx context.Context, field graphql.CollectedField, obj *ServiceClass) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceClass" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.DocumentationUrl + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +func (ec *executionContext) _ServiceClass_providerDisplayName(ctx context.Context, field graphql.CollectedField, obj *ServiceClass) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceClass" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.ProviderDisplayName + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +func (ec *executionContext) _ServiceClass_tags(ctx context.Context, field graphql.CollectedField, obj *ServiceClass) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceClass" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Tags + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return graphql.MarshalString(res[idx1]) + }()) + } + return arr1 +} + +func (ec *executionContext) _ServiceClass_plans(ctx context.Context, field graphql.CollectedField, obj *ServiceClass) graphql.Marshaler { + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "ServiceClass", + Args: nil, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.ServiceClass_plans(ctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]ServicePlan) + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._ServicePlan(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 + }) +} + +func (ec *executionContext) _ServiceClass_activated(ctx context.Context, field graphql.CollectedField, obj *ServiceClass) graphql.Marshaler { + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "ServiceClass", + Args: nil, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.ServiceClass_activated(ctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(bool) + return graphql.MarshalBoolean(res) + }) +} + +func (ec *executionContext) _ServiceClass_apiSpec(ctx context.Context, field graphql.CollectedField, obj *ServiceClass) graphql.Marshaler { + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "ServiceClass", + Args: nil, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.ServiceClass_apiSpec(ctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*JSON) + if res == nil { + return graphql.Null + } + return *res + }) +} + +func (ec *executionContext) _ServiceClass_asyncApiSpec(ctx context.Context, field graphql.CollectedField, obj *ServiceClass) graphql.Marshaler { + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "ServiceClass", + Args: nil, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.ServiceClass_asyncApiSpec(ctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*JSON) + if res == nil { + return graphql.Null + } + return *res + }) +} + +func (ec *executionContext) _ServiceClass_content(ctx context.Context, field graphql.CollectedField, obj *ServiceClass) graphql.Marshaler { + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "ServiceClass", + Args: nil, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.ServiceClass_content(ctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*JSON) + if res == nil { + return graphql.Null + } + return *res + }) +} + +var serviceInstanceImplementors = []string{"ServiceInstance"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _ServiceInstance(ctx context.Context, sel []query.Selection, obj *ServiceInstance) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, serviceInstanceImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ServiceInstance") + case "name": + out.Values[i] = ec._ServiceInstance_name(ctx, field, obj) + case "environment": + out.Values[i] = ec._ServiceInstance_environment(ctx, field, obj) + case "serviceClassName": + out.Values[i] = ec._ServiceInstance_serviceClassName(ctx, field, obj) + case "ServiceClassDisplayName": + out.Values[i] = ec._ServiceInstance_ServiceClassDisplayName(ctx, field, obj) + case "servicePlanName": + out.Values[i] = ec._ServiceInstance_servicePlanName(ctx, field, obj) + case "servicePlanDisplayName": + out.Values[i] = ec._ServiceInstance_servicePlanDisplayName(ctx, field, obj) + case "creationTimestamp": + out.Values[i] = ec._ServiceInstance_creationTimestamp(ctx, field, obj) + case "labels": + out.Values[i] = ec._ServiceInstance_labels(ctx, field, obj) + case "status": + out.Values[i] = ec._ServiceInstance_status(ctx, field, obj) + case "servicePlan": + out.Values[i] = ec._ServiceInstance_servicePlan(ctx, field, obj) + case "serviceClass": + out.Values[i] = ec._ServiceInstance_serviceClass(ctx, field, obj) + case "bindable": + out.Values[i] = ec._ServiceInstance_bindable(ctx, field, obj) + case "serviceBindings": + out.Values[i] = ec._ServiceInstance_serviceBindings(ctx, field, obj) + case "serviceBindingUsages": + out.Values[i] = ec._ServiceInstance_serviceBindingUsages(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _ServiceInstance_name(ctx context.Context, field graphql.CollectedField, obj *ServiceInstance) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceInstance" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServiceInstance_environment(ctx context.Context, field graphql.CollectedField, obj *ServiceInstance) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceInstance" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Environment + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServiceInstance_serviceClassName(ctx context.Context, field graphql.CollectedField, obj *ServiceInstance) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceInstance" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.ServiceClassName + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +func (ec *executionContext) _ServiceInstance_ServiceClassDisplayName(ctx context.Context, field graphql.CollectedField, obj *ServiceInstance) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceInstance" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.ServiceClassDisplayName + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServiceInstance_servicePlanName(ctx context.Context, field graphql.CollectedField, obj *ServiceInstance) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceInstance" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.ServicePlanName + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +func (ec *executionContext) _ServiceInstance_servicePlanDisplayName(ctx context.Context, field graphql.CollectedField, obj *ServiceInstance) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceInstance" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.ServicePlanDisplayName + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServiceInstance_creationTimestamp(ctx context.Context, field graphql.CollectedField, obj *ServiceInstance) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceInstance" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.CreationTimestamp + return MarshalTimestamp(res) +} + +func (ec *executionContext) _ServiceInstance_labels(ctx context.Context, field graphql.CollectedField, obj *ServiceInstance) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceInstance" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Labels + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return graphql.MarshalString(res[idx1]) + }()) + } + return arr1 +} + +func (ec *executionContext) _ServiceInstance_status(ctx context.Context, field graphql.CollectedField, obj *ServiceInstance) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceInstance" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Status + return ec._ServiceInstanceStatus(ctx, field.Selections, &res) +} + +func (ec *executionContext) _ServiceInstance_servicePlan(ctx context.Context, field graphql.CollectedField, obj *ServiceInstance) graphql.Marshaler { + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "ServiceInstance", + Args: nil, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.ServiceInstance_servicePlan(ctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*ServicePlan) + if res == nil { + return graphql.Null + } + return ec._ServicePlan(ctx, field.Selections, res) + }) +} + +func (ec *executionContext) _ServiceInstance_serviceClass(ctx context.Context, field graphql.CollectedField, obj *ServiceInstance) graphql.Marshaler { + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "ServiceInstance", + Args: nil, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.ServiceInstance_serviceClass(ctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*ServiceClass) + if res == nil { + return graphql.Null + } + return ec._ServiceClass(ctx, field.Selections, res) + }) +} + +func (ec *executionContext) _ServiceInstance_bindable(ctx context.Context, field graphql.CollectedField, obj *ServiceInstance) graphql.Marshaler { + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "ServiceInstance", + Args: nil, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.ServiceInstance_bindable(ctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(bool) + return graphql.MarshalBoolean(res) + }) +} + +func (ec *executionContext) _ServiceInstance_serviceBindings(ctx context.Context, field graphql.CollectedField, obj *ServiceInstance) graphql.Marshaler { + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "ServiceInstance", + Args: nil, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.ServiceInstance_serviceBindings(ctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]ServiceBinding) + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._ServiceBinding(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 + }) +} + +func (ec *executionContext) _ServiceInstance_serviceBindingUsages(ctx context.Context, field graphql.CollectedField, obj *ServiceInstance) graphql.Marshaler { + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "ServiceInstance", + Args: nil, + Field: field, + }) + return graphql.Defer(func() (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + userErr := ec.Recover(ctx, r) + ec.Error(ctx, userErr) + ret = graphql.Null + } + }() + + resTmp, err := ec.ResolverMiddleware(ctx, func(ctx context.Context) (interface{}, error) { + return ec.resolvers.ServiceInstance_serviceBindingUsages(ctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]ServiceBindingUsage) + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._ServiceBindingUsage(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 + }) +} + +var serviceInstanceEventImplementors = []string{"ServiceInstanceEvent"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _ServiceInstanceEvent(ctx context.Context, sel []query.Selection, obj *ServiceInstanceEvent) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, serviceInstanceEventImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ServiceInstanceEvent") + case "type": + out.Values[i] = ec._ServiceInstanceEvent_type(ctx, field, obj) + case "instance": + out.Values[i] = ec._ServiceInstanceEvent_instance(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _ServiceInstanceEvent_type(ctx context.Context, field graphql.CollectedField, obj *ServiceInstanceEvent) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceInstanceEvent" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Type + return res +} + +func (ec *executionContext) _ServiceInstanceEvent_instance(ctx context.Context, field graphql.CollectedField, obj *ServiceInstanceEvent) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceInstanceEvent" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Instance + return ec._ServiceInstance(ctx, field.Selections, &res) +} + +var serviceInstanceStatusImplementors = []string{"ServiceInstanceStatus"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _ServiceInstanceStatus(ctx context.Context, sel []query.Selection, obj *ServiceInstanceStatus) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, serviceInstanceStatusImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ServiceInstanceStatus") + case "type": + out.Values[i] = ec._ServiceInstanceStatus_type(ctx, field, obj) + case "reason": + out.Values[i] = ec._ServiceInstanceStatus_reason(ctx, field, obj) + case "message": + out.Values[i] = ec._ServiceInstanceStatus_message(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _ServiceInstanceStatus_type(ctx context.Context, field graphql.CollectedField, obj *ServiceInstanceStatus) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceInstanceStatus" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Type + return res +} + +func (ec *executionContext) _ServiceInstanceStatus_reason(ctx context.Context, field graphql.CollectedField, obj *ServiceInstanceStatus) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceInstanceStatus" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Reason + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServiceInstanceStatus_message(ctx context.Context, field graphql.CollectedField, obj *ServiceInstanceStatus) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServiceInstanceStatus" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Message + return graphql.MarshalString(res) +} + +var servicePlanImplementors = []string{"ServicePlan"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _ServicePlan(ctx context.Context, sel []query.Selection, obj *ServicePlan) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, servicePlanImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ServicePlan") + case "name": + out.Values[i] = ec._ServicePlan_name(ctx, field, obj) + case "displayName": + out.Values[i] = ec._ServicePlan_displayName(ctx, field, obj) + case "externalName": + out.Values[i] = ec._ServicePlan_externalName(ctx, field, obj) + case "description": + out.Values[i] = ec._ServicePlan_description(ctx, field, obj) + case "relatedServiceClassName": + out.Values[i] = ec._ServicePlan_relatedServiceClassName(ctx, field, obj) + case "instanceCreateParameterSchema": + out.Values[i] = ec._ServicePlan_instanceCreateParameterSchema(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _ServicePlan_name(ctx context.Context, field graphql.CollectedField, obj *ServicePlan) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServicePlan" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServicePlan_displayName(ctx context.Context, field graphql.CollectedField, obj *ServicePlan) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServicePlan" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.DisplayName + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +func (ec *executionContext) _ServicePlan_externalName(ctx context.Context, field graphql.CollectedField, obj *ServicePlan) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServicePlan" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.ExternalName + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServicePlan_description(ctx context.Context, field graphql.CollectedField, obj *ServicePlan) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServicePlan" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Description + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServicePlan_relatedServiceClassName(ctx context.Context, field graphql.CollectedField, obj *ServicePlan) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServicePlan" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.RelatedServiceClassName + return graphql.MarshalString(res) +} + +func (ec *executionContext) _ServicePlan_instanceCreateParameterSchema(ctx context.Context, field graphql.CollectedField, obj *ServicePlan) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "ServicePlan" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.InstanceCreateParameterSchema + if res == nil { + return graphql.Null + } + return *res +} + +var subscriptionImplementors = []string{"Subscription"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _Subscription(ctx context.Context, sel []query.Selection) func() graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, subscriptionImplementors, ec.Variables) + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Subscription", + }) + if len(fields) != 1 { + ec.Errorf(ctx, "must subscribe to exactly one stream") + return nil + } + + switch fields[0].Name { + case "serviceInstanceEvent": + return ec._Subscription_serviceInstanceEvent(ctx, fields[0]) + default: + panic("unknown field " + strconv.Quote(fields[0].Name)) + } +} + +func (ec *executionContext) _Subscription_serviceInstanceEvent(ctx context.Context, field graphql.CollectedField) func() graphql.Marshaler { + args := map[string]interface{}{} + var arg0 string + if tmp, ok := field.Args["environment"]; ok { + var err error + arg0, err = graphql.UnmarshalString(tmp) + if err != nil { + ec.Error(ctx, err) + return nil + } + } + args["environment"] = arg0 + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{Field: field}) + results, err := ec.resolvers.Subscription_serviceInstanceEvent(ctx, args["environment"].(string)) + if err != nil { + ec.Error(ctx, err) + return nil + } + return func() graphql.Marshaler { + res, ok := <-results + if !ok { + return nil + } + var out graphql.OrderedMap + out.Add(field.Alias, func() graphql.Marshaler { return ec._ServiceInstanceEvent(ctx, field.Selections, &res) }()) + return &out + } +} + +var titleImplementors = []string{"Title"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _Title(ctx context.Context, sel []query.Selection, obj *Title) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, titleImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Title") + case "name": + out.Values[i] = ec._Title_name(ctx, field, obj) + case "anchor": + out.Values[i] = ec._Title_anchor(ctx, field, obj) + case "titles": + out.Values[i] = ec._Title_titles(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _Title_name(ctx context.Context, field graphql.CollectedField, obj *Title) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Title" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name + return graphql.MarshalString(res) +} + +func (ec *executionContext) _Title_anchor(ctx context.Context, field graphql.CollectedField, obj *Title) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Title" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Anchor + return graphql.MarshalString(res) +} + +func (ec *executionContext) _Title_titles(ctx context.Context, field graphql.CollectedField, obj *Title) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "Title" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Titles + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._Title(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 +} + +var topicEntryImplementors = []string{"TopicEntry"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) _TopicEntry(ctx context.Context, sel []query.Selection, obj *TopicEntry) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, topicEntryImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("TopicEntry") + case "contentType": + out.Values[i] = ec._TopicEntry_contentType(ctx, field, obj) + case "id": + out.Values[i] = ec._TopicEntry_id(ctx, field, obj) + case "sections": + out.Values[i] = ec._TopicEntry_sections(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) _TopicEntry_contentType(ctx context.Context, field graphql.CollectedField, obj *TopicEntry) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "TopicEntry" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.ContentType + return graphql.MarshalString(res) +} + +func (ec *executionContext) _TopicEntry_id(ctx context.Context, field graphql.CollectedField, obj *TopicEntry) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "TopicEntry" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.ID + return graphql.MarshalString(res) +} + +func (ec *executionContext) _TopicEntry_sections(ctx context.Context, field graphql.CollectedField, obj *TopicEntry) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "TopicEntry" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Sections + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return ec._Section(ctx, field.Selections, &res[idx1]) + }()) + } + return arr1 +} + +var __DirectiveImplementors = []string{"__Directive"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) ___Directive(ctx context.Context, sel []query.Selection, obj *introspection.Directive) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, __DirectiveImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("__Directive") + case "name": + out.Values[i] = ec.___Directive_name(ctx, field, obj) + case "description": + out.Values[i] = ec.___Directive_description(ctx, field, obj) + case "locations": + out.Values[i] = ec.___Directive_locations(ctx, field, obj) + case "args": + out.Values[i] = ec.___Directive_args(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) ___Directive_name(ctx context.Context, field graphql.CollectedField, obj *introspection.Directive) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Directive" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name() + return graphql.MarshalString(res) +} + +func (ec *executionContext) ___Directive_description(ctx context.Context, field graphql.CollectedField, obj *introspection.Directive) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Directive" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Description() + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +func (ec *executionContext) ___Directive_locations(ctx context.Context, field graphql.CollectedField, obj *introspection.Directive) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Directive" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Locations() + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + return graphql.MarshalString(res[idx1]) + }()) + } + return arr1 +} + +func (ec *executionContext) ___Directive_args(ctx context.Context, field graphql.CollectedField, obj *introspection.Directive) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Directive" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Args() + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + if res[idx1] == nil { + return graphql.Null + } + return ec.___InputValue(ctx, field.Selections, res[idx1]) + }()) + } + return arr1 +} + +var __EnumValueImplementors = []string{"__EnumValue"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) ___EnumValue(ctx context.Context, sel []query.Selection, obj *introspection.EnumValue) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, __EnumValueImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("__EnumValue") + case "name": + out.Values[i] = ec.___EnumValue_name(ctx, field, obj) + case "description": + out.Values[i] = ec.___EnumValue_description(ctx, field, obj) + case "isDeprecated": + out.Values[i] = ec.___EnumValue_isDeprecated(ctx, field, obj) + case "deprecationReason": + out.Values[i] = ec.___EnumValue_deprecationReason(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) ___EnumValue_name(ctx context.Context, field graphql.CollectedField, obj *introspection.EnumValue) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__EnumValue" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name() + return graphql.MarshalString(res) +} + +func (ec *executionContext) ___EnumValue_description(ctx context.Context, field graphql.CollectedField, obj *introspection.EnumValue) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__EnumValue" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Description() + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +func (ec *executionContext) ___EnumValue_isDeprecated(ctx context.Context, field graphql.CollectedField, obj *introspection.EnumValue) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__EnumValue" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.IsDeprecated() + return graphql.MarshalBoolean(res) +} + +func (ec *executionContext) ___EnumValue_deprecationReason(ctx context.Context, field graphql.CollectedField, obj *introspection.EnumValue) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__EnumValue" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.DeprecationReason() + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +var __FieldImplementors = []string{"__Field"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) ___Field(ctx context.Context, sel []query.Selection, obj *introspection.Field) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, __FieldImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("__Field") + case "name": + out.Values[i] = ec.___Field_name(ctx, field, obj) + case "description": + out.Values[i] = ec.___Field_description(ctx, field, obj) + case "args": + out.Values[i] = ec.___Field_args(ctx, field, obj) + case "type": + out.Values[i] = ec.___Field_type(ctx, field, obj) + case "isDeprecated": + out.Values[i] = ec.___Field_isDeprecated(ctx, field, obj) + case "deprecationReason": + out.Values[i] = ec.___Field_deprecationReason(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) ___Field_name(ctx context.Context, field graphql.CollectedField, obj *introspection.Field) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Field" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name() + return graphql.MarshalString(res) +} + +func (ec *executionContext) ___Field_description(ctx context.Context, field graphql.CollectedField, obj *introspection.Field) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Field" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Description() + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +func (ec *executionContext) ___Field_args(ctx context.Context, field graphql.CollectedField, obj *introspection.Field) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Field" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Args() + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + if res[idx1] == nil { + return graphql.Null + } + return ec.___InputValue(ctx, field.Selections, res[idx1]) + }()) + } + return arr1 +} + +func (ec *executionContext) ___Field_type(ctx context.Context, field graphql.CollectedField, obj *introspection.Field) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Field" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Type() + if res == nil { + return graphql.Null + } + return ec.___Type(ctx, field.Selections, res) +} + +func (ec *executionContext) ___Field_isDeprecated(ctx context.Context, field graphql.CollectedField, obj *introspection.Field) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Field" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.IsDeprecated() + return graphql.MarshalBoolean(res) +} + +func (ec *executionContext) ___Field_deprecationReason(ctx context.Context, field graphql.CollectedField, obj *introspection.Field) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Field" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.DeprecationReason() + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +var __InputValueImplementors = []string{"__InputValue"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) ___InputValue(ctx context.Context, sel []query.Selection, obj *introspection.InputValue) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, __InputValueImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("__InputValue") + case "name": + out.Values[i] = ec.___InputValue_name(ctx, field, obj) + case "description": + out.Values[i] = ec.___InputValue_description(ctx, field, obj) + case "type": + out.Values[i] = ec.___InputValue_type(ctx, field, obj) + case "defaultValue": + out.Values[i] = ec.___InputValue_defaultValue(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) ___InputValue_name(ctx context.Context, field graphql.CollectedField, obj *introspection.InputValue) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__InputValue" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name() + return graphql.MarshalString(res) +} + +func (ec *executionContext) ___InputValue_description(ctx context.Context, field graphql.CollectedField, obj *introspection.InputValue) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__InputValue" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Description() + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +func (ec *executionContext) ___InputValue_type(ctx context.Context, field graphql.CollectedField, obj *introspection.InputValue) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__InputValue" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Type() + if res == nil { + return graphql.Null + } + return ec.___Type(ctx, field.Selections, res) +} + +func (ec *executionContext) ___InputValue_defaultValue(ctx context.Context, field graphql.CollectedField, obj *introspection.InputValue) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__InputValue" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.DefaultValue() + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +var __SchemaImplementors = []string{"__Schema"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) ___Schema(ctx context.Context, sel []query.Selection, obj *introspection.Schema) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, __SchemaImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("__Schema") + case "types": + out.Values[i] = ec.___Schema_types(ctx, field, obj) + case "queryType": + out.Values[i] = ec.___Schema_queryType(ctx, field, obj) + case "mutationType": + out.Values[i] = ec.___Schema_mutationType(ctx, field, obj) + case "subscriptionType": + out.Values[i] = ec.___Schema_subscriptionType(ctx, field, obj) + case "directives": + out.Values[i] = ec.___Schema_directives(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) ___Schema_types(ctx context.Context, field graphql.CollectedField, obj *introspection.Schema) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Schema" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Types() + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + if res[idx1] == nil { + return graphql.Null + } + return ec.___Type(ctx, field.Selections, res[idx1]) + }()) + } + return arr1 +} + +func (ec *executionContext) ___Schema_queryType(ctx context.Context, field graphql.CollectedField, obj *introspection.Schema) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Schema" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.QueryType() + if res == nil { + return graphql.Null + } + return ec.___Type(ctx, field.Selections, res) +} + +func (ec *executionContext) ___Schema_mutationType(ctx context.Context, field graphql.CollectedField, obj *introspection.Schema) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Schema" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.MutationType() + if res == nil { + return graphql.Null + } + return ec.___Type(ctx, field.Selections, res) +} + +func (ec *executionContext) ___Schema_subscriptionType(ctx context.Context, field graphql.CollectedField, obj *introspection.Schema) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Schema" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.SubscriptionType() + if res == nil { + return graphql.Null + } + return ec.___Type(ctx, field.Selections, res) +} + +func (ec *executionContext) ___Schema_directives(ctx context.Context, field graphql.CollectedField, obj *introspection.Schema) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Schema" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Directives() + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + if res[idx1] == nil { + return graphql.Null + } + return ec.___Directive(ctx, field.Selections, res[idx1]) + }()) + } + return arr1 +} + +var __TypeImplementors = []string{"__Type"} + +// nolint: gocyclo, errcheck, gas, goconst +func (ec *executionContext) ___Type(ctx context.Context, sel []query.Selection, obj *introspection.Type) graphql.Marshaler { + fields := graphql.CollectFields(ec.Doc, sel, __TypeImplementors, ec.Variables) + + out := graphql.NewOrderedMap(len(fields)) + for i, field := range fields { + out.Keys[i] = field.Alias + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("__Type") + case "kind": + out.Values[i] = ec.___Type_kind(ctx, field, obj) + case "name": + out.Values[i] = ec.___Type_name(ctx, field, obj) + case "description": + out.Values[i] = ec.___Type_description(ctx, field, obj) + case "fields": + out.Values[i] = ec.___Type_fields(ctx, field, obj) + case "interfaces": + out.Values[i] = ec.___Type_interfaces(ctx, field, obj) + case "possibleTypes": + out.Values[i] = ec.___Type_possibleTypes(ctx, field, obj) + case "enumValues": + out.Values[i] = ec.___Type_enumValues(ctx, field, obj) + case "inputFields": + out.Values[i] = ec.___Type_inputFields(ctx, field, obj) + case "ofType": + out.Values[i] = ec.___Type_ofType(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + + return out +} + +func (ec *executionContext) ___Type_kind(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Type" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Kind() + return graphql.MarshalString(res) +} + +func (ec *executionContext) ___Type_name(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Type" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Name() + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +func (ec *executionContext) ___Type_description(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Type" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Description() + if res == nil { + return graphql.Null + } + return graphql.MarshalString(*res) +} + +func (ec *executionContext) ___Type_fields(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 bool + if tmp, ok := field.Args["includeDeprecated"]; ok { + var err error + arg0, err = graphql.UnmarshalBoolean(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["includeDeprecated"] = arg0 + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Type" + rctx.Args = args + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Fields(args["includeDeprecated"].(bool)) + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + if res[idx1] == nil { + return graphql.Null + } + return ec.___Field(ctx, field.Selections, res[idx1]) + }()) + } + return arr1 +} + +func (ec *executionContext) ___Type_interfaces(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Type" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.Interfaces() + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + if res[idx1] == nil { + return graphql.Null + } + return ec.___Type(ctx, field.Selections, res[idx1]) + }()) + } + return arr1 +} + +func (ec *executionContext) ___Type_possibleTypes(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Type" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.PossibleTypes() + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + if res[idx1] == nil { + return graphql.Null + } + return ec.___Type(ctx, field.Selections, res[idx1]) + }()) + } + return arr1 +} + +func (ec *executionContext) ___Type_enumValues(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) graphql.Marshaler { + args := map[string]interface{}{} + var arg0 bool + if tmp, ok := field.Args["includeDeprecated"]; ok { + var err error + arg0, err = graphql.UnmarshalBoolean(tmp) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + } + args["includeDeprecated"] = arg0 + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Type" + rctx.Args = args + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.EnumValues(args["includeDeprecated"].(bool)) + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + if res[idx1] == nil { + return graphql.Null + } + return ec.___EnumValue(ctx, field.Selections, res[idx1]) + }()) + } + return arr1 +} + +func (ec *executionContext) ___Type_inputFields(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Type" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.InputFields() + arr1 := graphql.Array{} + for idx1 := range res { + arr1 = append(arr1, func() graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.PushIndex(idx1) + defer rctx.Pop() + if res[idx1] == nil { + return graphql.Null + } + return ec.___InputValue(ctx, field.Selections, res[idx1]) + }()) + } + return arr1 +} + +func (ec *executionContext) ___Type_ofType(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) graphql.Marshaler { + rctx := graphql.GetResolverContext(ctx) + rctx.Object = "__Type" + rctx.Args = nil + rctx.Field = field + rctx.PushField(field.Alias) + defer rctx.Pop() + res := obj.OfType() + if res == nil { + return graphql.Null + } + return ec.___Type(ctx, field.Selections, res) +} + +func UnmarshalCreateServiceBindingUsageInput(v interface{}) (CreateServiceBindingUsageInput, error) { + var it CreateServiceBindingUsageInput + var asMap = v.(map[string]interface{}) + + for k, v := range asMap { + switch k { + case "name": + var err error + it.Name, err = graphql.UnmarshalString(v) + if err != nil { + return it, err + } + case "environment": + var err error + it.Environment, err = graphql.UnmarshalString(v) + if err != nil { + return it, err + } + case "serviceBindingRef": + var err error + it.ServiceBindingRef, err = UnmarshalServiceBindingRefInput(v) + if err != nil { + return it, err + } + case "usedBy": + var err error + it.UsedBy, err = UnmarshalLocalObjectReferenceInput(v) + if err != nil { + return it, err + } + case "parameters": + var err error + var ptr1 ServiceBindingUsageParametersInput + if v != nil { + ptr1, err = UnmarshalServiceBindingUsageParametersInput(v) + it.Parameters = &ptr1 + } + + if err != nil { + return it, err + } + } + } + + return it, nil +} + +func UnmarshalEnvPrefixInput(v interface{}) (EnvPrefixInput, error) { + var it EnvPrefixInput + var asMap = v.(map[string]interface{}) + + for k, v := range asMap { + switch k { + case "name": + var err error + it.Name, err = graphql.UnmarshalString(v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + +func UnmarshalInputTopic(v interface{}) (InputTopic, error) { + var it InputTopic + var asMap = v.(map[string]interface{}) + + for k, v := range asMap { + switch k { + case "id": + var err error + it.ID, err = graphql.UnmarshalString(v) + if err != nil { + return it, err + } + case "type": + var err error + it.Type, err = graphql.UnmarshalString(v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + +func UnmarshalLocalObjectReferenceInput(v interface{}) (LocalObjectReferenceInput, error) { + var it LocalObjectReferenceInput + var asMap = v.(map[string]interface{}) + + for k, v := range asMap { + switch k { + case "kind": + var err error + err = (&it.Kind).UnmarshalGQL(v) + if err != nil { + return it, err + } + case "name": + var err error + it.Name, err = graphql.UnmarshalString(v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + +func UnmarshalServiceBindingRefInput(v interface{}) (ServiceBindingRefInput, error) { + var it ServiceBindingRefInput + var asMap = v.(map[string]interface{}) + + for k, v := range asMap { + switch k { + case "name": + var err error + it.Name, err = graphql.UnmarshalString(v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + +func UnmarshalServiceBindingUsageParametersInput(v interface{}) (ServiceBindingUsageParametersInput, error) { + var it ServiceBindingUsageParametersInput + var asMap = v.(map[string]interface{}) + + for k, v := range asMap { + switch k { + case "envPrefix": + var err error + var ptr1 EnvPrefixInput + if v != nil { + ptr1, err = UnmarshalEnvPrefixInput(v) + it.EnvPrefix = &ptr1 + } + + if err != nil { + return it, err + } + } + } + + return it, nil +} + +func UnmarshalServiceInstanceCreateInput(v interface{}) (ServiceInstanceCreateInput, error) { + var it ServiceInstanceCreateInput + var asMap = v.(map[string]interface{}) + + for k, v := range asMap { + switch k { + case "name": + var err error + it.Name, err = graphql.UnmarshalString(v) + if err != nil { + return it, err + } + case "environment": + var err error + it.Environment, err = graphql.UnmarshalString(v) + if err != nil { + return it, err + } + case "externalServiceClassName": + var err error + it.ExternalServiceClassName, err = graphql.UnmarshalString(v) + if err != nil { + return it, err + } + case "externalPlanName": + var err error + it.ExternalPlanName, err = graphql.UnmarshalString(v) + if err != nil { + return it, err + } + case "labels": + var err error + var rawIf1 []interface{} + if v != nil { + rawIf1 = v.([]interface{}) + } + it.Labels = make([]string, len(rawIf1)) + for idx1 := range rawIf1 { + it.Labels[idx1], err = graphql.UnmarshalString(rawIf1[idx1]) + } + if err != nil { + return it, err + } + case "parameterSchema": + var err error + var ptr1 JSON + if v != nil { + err = (&ptr1).UnmarshalGQL(v) + it.ParameterSchema = &ptr1 + } + + if err != nil { + return it, err + } + } + } + + return it, nil +} + +func (ec *executionContext) introspectSchema() *introspection.Schema { + return introspection.WrapSchema(parsedSchema) +} + +func (ec *executionContext) introspectType(name string) *introspection.Type { + t := parsedSchema.Resolve(name) + if t == nil { + return nil + } + return introspection.WrapType(t) +} + +var parsedSchema = schema.MustParse(`# Scalars + +scalar JSON + +scalar Timestamp + +# Content + + +type Title { + name: String! + anchor: String! + titles: [Title!] +} + +type Section { + titles: [Title!]! + topicType: String! +} + +type TopicEntry { + contentType: String! + id: String! + sections: [Section!]! +} + +# Service Catalog + +type ServiceInstance { + name: String! + environment: String! + serviceClassName: String + ServiceClassDisplayName: String! + servicePlanName: String + servicePlanDisplayName: String! + creationTimestamp: Timestamp! + labels: [String]! + status: ServiceInstanceStatus + servicePlan: ServicePlan + serviceClass: ServiceClass + bindable: Boolean! + serviceBindings: [ServiceBinding]! + serviceBindingUsages: [ServiceBindingUsage]! +} + +type ServiceInstanceStatus { + type: InstanceStatusType! + reason: String! + message: String! +} + +enum InstanceStatusType { + RUNNING + PROVISIONING + DEPROVISIONING + PENDING + FAILED +} + +type ServiceInstanceEvent { + type: ServiceInstanceEventType! + instance: ServiceInstance +} + +enum ServiceInstanceEventType { + ADD + UPDATE + DELETE +} + +input ServiceInstanceCreateInput { + name: String! + environment: String! + externalServiceClassName: String! + externalPlanName: String! + labels: [String]! + parameterSchema: JSON +} + +type ServiceClass { + name: String! + externalName: String! + displayName: String + creationTimestamp: Timestamp! + description: String! + imageUrl: String + documentationUrl: String + providerDisplayName: String + tags: [String]! + plans: [ServicePlan]! + activated: Boolean! + apiSpec: JSON + asyncApiSpec: JSON + content: JSON +} + +type ServicePlan { + name: String! + displayName: String + externalName: String! + description: String! + relatedServiceClassName: String! + instanceCreateParameterSchema: JSON +} + +type ServiceBroker { + name: String! + status: ServiceBrokerStatus! + creationTimestamp: Timestamp! + url: String! + labels: JSON! +} + +type ServiceBrokerStatus { + ready: Boolean! + reason: String! + message: String! +} + +type ServiceBinding { + name: String! + serviceInstanceName: String! + environment: String! + secret: Secret + status: ServiceBindingStatus! +} + +type ServiceBindingStatus { + type: ServiceBindingStatusType! + reason: String! + message: String! +} + +enum ServiceBindingStatusType { + READY + PENDING + FAILED + UNKNOWN +} + +# We cannot use ServiceBinding as a result of create action +# because secret at the moment of mutation execution is not available. +type CreateServiceBindingOutput { + name: String! + serviceInstanceName: String! + environment: String! +} + +type Secret { + name: String! + environment: String! + data: JSON! +} + +type DeleteServiceBindingOutput { + name: String! + environment: String! +} + +type DeleteServiceBindingUsageOutput { + name: String! + environment: String! +} + +type ServiceBindingUsage { + name: String! + environment: String! + serviceBinding: ServiceBinding + usedBy: LocalObjectReference! + parameters: ServiceBindingUsageParameters + status: ServiceBindingUsageStatus! +} + +type ServiceBindingUsageStatus { + type: ServiceBindingUsageStatusType! + reason: String! + message: String! +} + +enum ServiceBindingUsageStatusType { + READY + PENDING + FAILED + UNKNOWN +} + +type LocalObjectReference { + kind: BindingUsageReferenceType! + name: String! +} + +type ServiceBindingUsageParameters { + envPrefix: EnvPrefix +} + +type EnvPrefix { + name: String! +} + +type LimitRange { + name: String! + limits: [LimitRangeItem]! +} + +type LimitRangeItem { + limitType: LimitType! + max: ResourceType! + default: ResourceType! + defaultRequest: ResourceType! +} + +enum LimitType { + Container + Pod +} + +type ResourceType { + memory: String + cpu: String +} + +input CreateServiceBindingUsageInput { + name: String! + environment: String! + serviceBindingRef: ServiceBindingRefInput! + usedBy: LocalObjectReferenceInput! + parameters: ServiceBindingUsageParametersInput +} + +input ServiceBindingRefInput { + name: String! +} + +input LocalObjectReferenceInput { + kind: BindingUsageReferenceType! + name: String! +} + +enum BindingUsageReferenceType { + DEPLOYMENT + FUNCTION +} + +input ServiceBindingUsageParametersInput { + envPrefix: EnvPrefixInput +} + +input EnvPrefixInput { + name: String! +} + +type Container { + name: String! + image: String! +} + +type DeploymentStatus { + replicas: Int! + updatedReplicas: Int! + readyReplicas: Int! + availableReplicas: Int! + conditions: DeploymentCondition! +} + +type DeploymentCondition { + status: String! + type: String! + lastTransitionTimestamp: Timestamp! + lastUpdateTimestamp: Timestamp! + message: String! + reason: String! +} + +type Deployment { + name: String! + environment: String! + creationTimestamp: Timestamp! + status: DeploymentStatus! + labels: JSON! + containers: [Container]! + boundServiceInstanceNames: [String]! +} + +type ResourceQuota { + name: String! + pods: String + limits: ResourceValues! + requests: ResourceValues! +} + +type ResourceValues { + memory: String + cpu: String +} + +# Remote Environments + +type Environment { + name: String! + remoteEnvironments: [String]! +} + +type RemoteEnvironment { + name: String! + description: String! + source: RemoteEnvironmentSource! + services: [RemoteEnvironmentService]! + enabledInEnvironments: [String]! + status: RemoteEnvironmentStatus! +} + +type ConnectorService { + url: String! +} + +type EnvironmentMapping { + environment: String! + remoteEnvironment: String! +} + +type RemoteEnvironmentSource { + environment: String! + type: String! + namespace: String! +} + +type RemoteEnvironmentService { + id: String! + displayName: String! + longDescription: String! + providerDisplayName: String! + tags: [String]! + entries: [RemoteEnvironmentEntry]! +} + +type RemoteEnvironmentEntry { + type: String! + gatewayUrl: String + accessLabel: String +} + +enum RemoteEnvironmentStatus { + SERVING + NOT_SERVING + GATEWAY_NOT_CONFIGURED +} + +type EventActivationSource { + environment: String! + type: String! + namespace: String! +} + +type EventActivationEvent { + eventType: String! + version: String! + description: String! +} + +type EventActivation { + name: String! + displayName: String! + source: EventActivationSource! + events: [EventActivationEvent]! +} + +# IDP PRESETS + +type IDPPreset { + name: String! + issuer: String! + jwksUri: String! +} + +# Kubeless + +type Function { + name: String! + trigger: String! + creationTimestamp: Timestamp! + labels: JSON! + environment: String! +} + +input InputTopic { + id: String! + type: String! +} + +# API controller + +type Service { + name: String! + port: Int! +} + +enum AuthenticationPolicyType { + JWT +} + +type AuthenticationPolicy { + type: AuthenticationPolicyType! + issuer: String! + jwksURI: String! +} + +type API { + name: String! + hostname: String! + service: Service! + authenticationPolicies: [AuthenticationPolicy]! +} + +# Queries + +type Query { + serviceInstance(name: String!, environment: String!): ServiceInstance + serviceInstances(environment: String!, first: Int, offset: Int, status: InstanceStatusType): [ServiceInstance]! + serviceClasses(first: Int, offset: Int): [ServiceClass]! + serviceClass(name: String!): ServiceClass + serviceBrokers(first: Int, offset: Int): [ServiceBroker]! + serviceBroker(name: String!): ServiceBroker + serviceBindingUsage(name: String!, environment: String!): ServiceBindingUsage + serviceBinding(name: String!, environment: String!): ServiceBinding + + apis(environment: String!, serviceName: String, hostname: String): [API!]! + + remoteEnvironment(name: String!): RemoteEnvironment + remoteEnvironments(environment: String, first: Int, offset: Int): [RemoteEnvironment]! + connectorService(remoteEnvironment: String!): ConnectorService! + + environments(remoteEnvironment: String): [Environment]! + deployments(environment: String!, excludeFunctions: Boolean): [Deployment]! + resourceQuotas(environment: String!): [ResourceQuota]! + + functions(environment: String!, first: Int, offset: Int): [Function]! + + #TODO: it is not possible to define ` + "`" + `type` + "`" + ` as argument name + content(contentType: String!, id: String!): JSON + topics(input: [InputTopic]!, internal: Boolean): [TopicEntry] + eventActivations(environment: String!): [EventActivation]! + + limitRanges(environment: String!): [LimitRange!]! + + IDPPreset(name: String!): IDPPreset + IDPPresets(first: Int, offset: Int): [IDPPreset!]! +} + +# Mutations + +type Mutation { + createServiceInstance(params: ServiceInstanceCreateInput!): ServiceInstance + deleteServiceInstance(name: String!, environment: String!): ServiceInstance + createServiceBinding(serviceBindingName: String!, serviceInstanceName: String!, environment: String!): CreateServiceBindingOutput + deleteServiceBinding(serviceBindingName: String!, environment: String!): DeleteServiceBindingOutput + createServiceBindingUsage(createServiceBindingUsageInput: CreateServiceBindingUsageInput): ServiceBindingUsage + deleteServiceBindingUsage(serviceBindingUsageName: String!, environment: String!): DeleteServiceBindingUsageOutput + + enableRemoteEnvironment(remoteEnvironment: String!, environment: String!): EnvironmentMapping + disableRemoteEnvironment(remoteEnvironment: String!, environment: String!): EnvironmentMapping + + createIDPPreset(name: String!, issuer: String!, jwksUri: String!): IDPPreset + deleteIDPPreset(name: String!): IDPPreset +} + +# Subscriptions + +type Subscription { + serviceInstanceEvent(environment: String!): ServiceInstanceEvent! +} + +# Schema + +schema { + query: Query + mutation: Mutation +} +`) diff --git a/components/ui-api-layer/internal/gqlschema/servicebinding.go b/components/ui-api-layer/internal/gqlschema/servicebinding.go new file mode 100644 index 000000000000..0a31723c0048 --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/servicebinding.go @@ -0,0 +1,9 @@ +package gqlschema + +type ServiceBinding struct { + Name string + ServiceInstanceName string + Environment string + SecretName string + Status ServiceBindingStatus +} diff --git a/components/ui-api-layer/internal/gqlschema/servicebindingusage.go b/components/ui-api-layer/internal/gqlschema/servicebindingusage.go new file mode 100644 index 000000000000..345269ec7038 --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/servicebindingusage.go @@ -0,0 +1,23 @@ +package gqlschema + +type ServiceBindingUsage struct { + Name string + Environment string + ServiceBindingName string + UsedBy LocalObjectReference + Status ServiceBindingUsageStatus + Parameters *ServiceBindingUsageParameters +} + +type LocalObjectReference struct { + Kind BindingUsageReferenceType + Name string +} + +type ServiceBindingUsageParameters struct { + EnvPrefix *EnvPrefix +} + +type EnvPrefix struct { + Name string +} diff --git a/components/ui-api-layer/internal/gqlschema/servicebroker.go b/components/ui-api-layer/internal/gqlschema/servicebroker.go new file mode 100644 index 000000000000..bb40b5b6d923 --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/servicebroker.go @@ -0,0 +1,11 @@ +package gqlschema + +import "time" + +type ServiceBroker struct { + Name string + CreationTimestamp time.Time + Url string + Labels JSON + Status ServiceBrokerStatus +} diff --git a/components/ui-api-layer/internal/gqlschema/serviceclass.go b/components/ui-api-layer/internal/gqlschema/serviceclass.go new file mode 100644 index 000000000000..b6986f9d34e6 --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/serviceclass.go @@ -0,0 +1,19 @@ +package gqlschema + +import "time" + +type ServiceClass struct { + Name string + ExternalName string + DisplayName *string + CreationTimestamp time.Time + Description string + ImageUrl *string + DocumentationUrl *string + ProviderDisplayName *string + Tags []string + activated bool + apiSpec *JSON + asyncApiSpec *JSON + content *JSON +} diff --git a/components/ui-api-layer/internal/gqlschema/serviceinstance.go b/components/ui-api-layer/internal/gqlschema/serviceinstance.go new file mode 100644 index 000000000000..f73633f3612b --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/serviceinstance.go @@ -0,0 +1,15 @@ +package gqlschema + +import "time" + +type ServiceInstance struct { + Name string + Environment string + ServiceClassName *string + ServiceClassDisplayName string + ServicePlanName *string + ServicePlanDisplayName string + CreationTimestamp time.Time + Labels []string + Status ServiceInstanceStatus +} diff --git a/components/ui-api-layer/internal/gqlschema/serviceinstanceevent.go b/components/ui-api-layer/internal/gqlschema/serviceinstanceevent.go new file mode 100644 index 000000000000..fec74edec68b --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/serviceinstanceevent.go @@ -0,0 +1,6 @@ +package gqlschema + +type ServiceInstanceEvent struct { + Type ServiceInstanceEventType + Instance ServiceInstance +} diff --git a/components/ui-api-layer/internal/gqlschema/timestamp.go b/components/ui-api-layer/internal/gqlschema/timestamp.go new file mode 100644 index 000000000000..83c878a726c5 --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/timestamp.go @@ -0,0 +1,23 @@ +package gqlschema + +import ( + "io" + "strconv" + "time" + + "github.com/pkg/errors" + "github.com/vektah/gqlgen/graphql" +) + +func MarshalTimestamp(t time.Time) graphql.Marshaler { + return graphql.WriterFunc(func(w io.Writer) { + io.WriteString(w, strconv.FormatInt(t.Unix(), 10)) + }) +} + +func UnmarshalTimestamp(v interface{}) (time.Time, error) { + if tmpStr, ok := v.(int); ok { + return time.Unix(int64(tmpStr), 0), nil + } + return time.Time{}, errors.New("Time should be an unix timestamp") +} diff --git a/components/ui-api-layer/internal/gqlschema/types.json b/components/ui-api-layer/internal/gqlschema/types.json new file mode 100644 index 000000000000..28b396d3dad3 --- /dev/null +++ b/components/ui-api-layer/internal/gqlschema/types.json @@ -0,0 +1,25 @@ +{ + "JSON": "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.JSON", + "Timestamp": "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.Timestamp", + "ServiceInstance": "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.ServiceInstance", + "ServiceInstanceEvent":"github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.ServiceInstanceEvent", + "ServiceClass": "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.ServiceClass", + "ServiceBroker": "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.ServiceBroker", + "RemoteEnvironment": "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.RemoteEnvironment", + "RemoteEnvironmentService": "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.RemoteEnvironmentService", + "ServiceBinding": "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.ServiceBinding", + "LocalObjectReference": "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.LocalObjectReference", + "ServiceBindingUsage":"github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.ServiceBindingUsage", + "ServiceBindingUsageParameters":"github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.ServiceBindingUsageParameters", + "EnvPrefix":"github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.EnvPrefix", + "Deployment": "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.Deployment", + "DeploymentStatus": "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.DeploymentStatus", + "EventActivation":"github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.EventActivation", + "ResourceQuota":"github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.ResourceQuota", + "API":"github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.API", + "LimitRange": "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.LimitRange", + "LimitRangeItem": "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.LimitRangeItem", + "Section":"github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.Section", + "TopicEntry":"github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.TopicEntry", + "Title":"github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema.Title" +} \ No newline at end of file diff --git a/components/ui-api-layer/internal/pager/automock/pageable_indexer.go b/components/ui-api-layer/internal/pager/automock/pageable_indexer.go new file mode 100644 index 000000000000..f22d3516f95f --- /dev/null +++ b/components/ui-api-layer/internal/pager/automock/pageable_indexer.go @@ -0,0 +1,85 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" + +// PageableIndexer is an autogenerated mock type for the PageableIndexer type +type PageableIndexer struct { + mock.Mock +} + +// ByIndex provides a mock function with given fields: indexName, indexKey +func (_m *PageableIndexer) ByIndex(indexName string, indexKey string) ([]interface{}, error) { + ret := _m.Called(indexName, indexKey) + + var r0 []interface{} + if rf, ok := ret.Get(0).(func(string, string) []interface{}); ok { + r0 = rf(indexName, indexKey) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]interface{}) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(indexName, indexKey) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetByKey provides a mock function with given fields: key +func (_m *PageableIndexer) GetByKey(key string) (interface{}, bool, error) { + ret := _m.Called(key) + + var r0 interface{} + if rf, ok := ret.Get(0).(func(string) interface{}); ok { + r0 = rf(key) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(interface{}) + } + } + + var r1 bool + if rf, ok := ret.Get(1).(func(string) bool); ok { + r1 = rf(key) + } else { + r1 = ret.Get(1).(bool) + } + + var r2 error + if rf, ok := ret.Get(2).(func(string) error); ok { + r2 = rf(key) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// IndexKeys provides a mock function with given fields: indexName, indexKey +func (_m *PageableIndexer) IndexKeys(indexName string, indexKey string) ([]string, error) { + ret := _m.Called(indexName, indexKey) + + var r0 []string + if rf, ok := ret.Get(0).(func(string, string) []string); ok { + r0 = rf(indexName, indexKey) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(indexName, indexKey) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/components/ui-api-layer/internal/pager/automock/pageable_store.go b/components/ui-api-layer/internal/pager/automock/pageable_store.go new file mode 100644 index 000000000000..b0f26c9498a4 --- /dev/null +++ b/components/ui-api-layer/internal/pager/automock/pageable_store.go @@ -0,0 +1,71 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" + +// PageableStore is an autogenerated mock type for the PageableStore type +type PageableStore struct { + mock.Mock +} + +// GetByKey provides a mock function with given fields: key +func (_m *PageableStore) GetByKey(key string) (interface{}, bool, error) { + ret := _m.Called(key) + + var r0 interface{} + if rf, ok := ret.Get(0).(func(string) interface{}); ok { + r0 = rf(key) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(interface{}) + } + } + + var r1 bool + if rf, ok := ret.Get(1).(func(string) bool); ok { + r1 = rf(key) + } else { + r1 = ret.Get(1).(bool) + } + + var r2 error + if rf, ok := ret.Get(2).(func(string) error); ok { + r2 = rf(key) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// List provides a mock function with given fields: +func (_m *PageableStore) List() []interface{} { + ret := _m.Called() + + var r0 []interface{} + if rf, ok := ret.Get(0).(func() []interface{}); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]interface{}) + } + } + + return r0 +} + +// ListKeys provides a mock function with given fields: +func (_m *PageableStore) ListKeys() []string { + ret := _m.Called() + + var r0 []string + if rf, ok := ret.Get(0).(func() []string); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + return r0 +} diff --git a/components/ui-api-layer/internal/pager/indexpager.go b/components/ui-api-layer/internal/pager/indexpager.go new file mode 100644 index 000000000000..ff811e2c3466 --- /dev/null +++ b/components/ui-api-layer/internal/pager/indexpager.go @@ -0,0 +1,41 @@ +package pager + +import ( + "github.com/pkg/errors" +) + +type IndexPager struct { + *Pager + indexer PageableIndexer + indexName string + indexKey string +} + +//go:generate mockery -name=PageableIndexer -output=automock -outpkg=automock -case=underscore +type PageableIndexer interface { + ByIndex(indexName, indexKey string) ([]interface{}, error) + IndexKeys(indexName, indexKey string) ([]string, error) + GetByKey(key string) (item interface{}, exists bool, err error) +} + +func FromIndexer(indexer PageableIndexer, indexName, indexKey string) *IndexPager { + return &IndexPager{ + indexer: indexer, + indexName: indexName, + indexKey: indexKey, + } +} + +func (p *IndexPager) Limit(params PagingParams) ([]interface{}, error) { + items, err := p.indexer.ByIndex(p.indexName, p.indexKey) + if err != nil { + return nil, errors.Wrap(err, "while getting items by index from indexer") + } + keys, err := p.indexer.IndexKeys(p.indexName, p.indexKey) + if err != nil { + return nil, errors.Wrap(err, "while getting index keys for indexer") + } + + internalParams := p.readParams(params) + return p.limitList(internalParams, items, keys, p.indexer) +} diff --git a/components/ui-api-layer/internal/pager/indexpager_test.go b/components/ui-api-layer/internal/pager/indexpager_test.go new file mode 100644 index 000000000000..3a2cda44f1ab --- /dev/null +++ b/components/ui-api-layer/internal/pager/indexpager_test.go @@ -0,0 +1,149 @@ +package pager + +import ( + "errors" + "testing" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager/automock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLimitList(t *testing.T) { + indexName := "environment" + indexKey := "test" + keys := []string{ + "Test1", + "Test2", + "Test3", + } + values := []interface{}{ + 1, + 2, + 3, + } + + t.Run("Empty list", func(t *testing.T) { + first := 30 + offset := 0 + params := PagingParams{ + First: &first, + Offset: &offset, + } + expectedItems := []interface{}{} + indexer := new(automock.PageableIndexer) + indexer.On("IndexKeys", indexName, indexKey).Return(nil, nil) + indexer.On("ByIndex", indexName, indexKey).Return(nil, nil) + pager := FromIndexer(indexer, indexName, indexKey) + + items, err := pager.Limit(params) + + require.NoError(t, err) + assert.Equal(t, expectedItems, items) + }) + + t.Run("No paging parameters given", func(t *testing.T) { + indexer := fixIndexer(keys, values, indexName, indexKey) + pager := FromIndexer(indexer, indexName, indexKey) + first := 0 + offset := 0 + params := PagingParams{ + First: &first, + Offset: &offset, + } + expectedItems := values + + items, err := pager.Limit(params) + + require.NoError(t, err) + assert.Equal(t, expectedItems, items) + }) + + t.Run("Less items than given 'first' parameter, no offset", func(t *testing.T) { + indexer := fixIndexer(keys, values, indexName, indexKey) + pager := FromIndexer(indexer, indexName, indexKey) + first := 5 + offset := 0 + params := PagingParams{ + First: &first, + Offset: &offset, + } + expectedItems := values + + items, err := pager.Limit(params) + + require.NoError(t, err) + assert.Equal(t, expectedItems, items) + }) + + t.Run("Less items than given 'first' parameter, offset included", func(t *testing.T) { + indexer := fixIndexer(keys, values, indexName, indexKey) + pager := FromIndexer(indexer, indexName, indexKey) + first := 5 + offset := 1 + params := PagingParams{ + First: &first, + Offset: &offset, + } + expectedItems := []interface{}{ + 2, + 3, + } + + items, err := pager.Limit(params) + + require.NoError(t, err) + assert.Equal(t, expectedItems, items) + }) + + t.Run("More items, 'first' and 'offset' parameters given", func(t *testing.T) { + indexer := fixIndexer(keys, values, indexName, indexKey) + pager := FromIndexer(indexer, indexName, indexKey) + first := 1 + offset := 1 + params := PagingParams{ + First: &first, + Offset: &offset, + } + expectedItems := []interface{}{ + 2, + } + + items, err := pager.Limit(params) + + require.NoError(t, err) + assert.Equal(t, expectedItems, items) + }) + + t.Run("Error thrown", func(t *testing.T) { + err := errors.New("New error") + first := 30 + offset := 0 + params := PagingParams{ + First: &first, + Offset: &offset, + } + + indexer := new(automock.PageableIndexer) + indexer.On("IndexKeys", indexName, indexKey).Return(nil, nil) + indexer.On("ByIndex", indexName, indexKey).Return(nil, err) + pager := FromIndexer(indexer, indexName, indexKey) + + _, err = pager.Limit(params) + + require.Error(t, err, "while getting items by index from indexer: New error") + }) + +} + +func fixIndexer(keys []string, values []interface{}, indexName, indexKey string) *automock.PageableIndexer { + indexer := new(automock.PageableIndexer) + indexer.On("IndexKeys", indexName, indexKey).Return(keys, nil) + indexer.On("ByIndex", indexName, indexKey).Return(values, nil) + + for index, key := range keys { + indexer.On("GetByKey", key).Return(values[index], true, nil) + } + + return indexer +} diff --git a/components/ui-api-layer/internal/pager/pager.go b/components/ui-api-layer/internal/pager/pager.go new file mode 100644 index 000000000000..81982e4b19b6 --- /dev/null +++ b/components/ui-api-layer/internal/pager/pager.go @@ -0,0 +1,123 @@ +package pager + +import ( + "fmt" + "sort" + "strings" + + "github.com/pkg/errors" +) + +type PagingParams struct { + First *int + Offset *int +} + +type Pager struct { + store PageableStore +} + +//go:generate mockery -name=PageableStore -output=automock -outpkg=automock -case=underscore +type PageableStore interface { + GetByKey(key string) (item interface{}, exists bool, err error) + List() []interface{} + ListKeys() []string +} + +func From(store PageableStore) *Pager { + return &Pager{ + store: store, + } +} + +func (p *Pager) Limit(params PagingParams) ([]interface{}, error) { + items := p.store.List() + keys := p.store.ListKeys() + internalParams := p.readParams(params) + return p.limitList(internalParams, items, keys, p.store) +} + +type itemGetter interface { + GetByKey(key string) (item interface{}, exists bool, err error) +} + +func (p *Pager) readParams(params PagingParams) PagingParams { + var f, o int + if params.First != nil { + f = *params.First + } + if params.Offset != nil { + o = *params.Offset + } + + return PagingParams{ + First: &f, + Offset: &o, + } +} + +func (p *Pager) limitList(params PagingParams, items []interface{}, keys []string, getter itemGetter) ([]interface{}, error) { + if len(items) == 0 { + return []interface{}{}, nil + } + + keysCount := len(keys) + + first := *params.First + offset := *params.Offset + + if first < 0 { + return nil, errors.New("'First' parameter cannot be below 0") + } + + if offset < 0 { + return nil, errors.New("'Offset' parameter cannot be below 0") + } + + sliceStart := offset + sliceEnd := sliceStart + first + + if sliceStart >= keysCount { + return nil, fmt.Errorf("Offset %d is out of range; maximum value: %d", sliceStart, keysCount-1) + } + + if sliceEnd >= keysCount { + sliceEnd = keysCount + } + + sortedList, err := p.sortByKey(keys, getter) + if err != nil { + return nil, errors.Wrap(err, "while sorting store") + } + + if offset == 0 && (first == 0 || first >= keysCount) { + return sortedList, nil + } + + return sortedList[sliceStart:sliceEnd], nil +} + +func (p *Pager) sortByKey(keys []string, store itemGetter) ([]interface{}, error) { + var sortedKeys []string + sortedKeys = append(sortedKeys, keys...) + + sort.SliceStable(sortedKeys, func(i, j int) bool { + result := strings.Compare(sortedKeys[i], sortedKeys[j]) + return result != 1 + }) + + var sortedList []interface{} + for _, key := range sortedKeys { + item, exists, err := store.GetByKey(key) + if !exists { + return nil, fmt.Errorf("Item with key %s doesn't exist", key) + } + if err != nil { + return nil, errors.Wrapf(err, "While getting item with key %s", key) + } + + sortedList = append(sortedList, item) + } + + return sortedList, nil +} diff --git a/components/ui-api-layer/internal/pager/pager_test.go b/components/ui-api-layer/internal/pager/pager_test.go new file mode 100644 index 000000000000..564beef1cd05 --- /dev/null +++ b/components/ui-api-layer/internal/pager/pager_test.go @@ -0,0 +1,127 @@ +package pager + +import ( + "testing" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/pager/automock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPager_Limit(t *testing.T) { + keys := []string{ + "Test1", + "Test2", + "Test3", + } + values := []interface{}{ + 1, + 2, + 3, + } + + t.Run("Empty list", func(t *testing.T) { + first := 30 + offset := 0 + params := PagingParams{ + First: &first, + Offset: &offset, + } + expectedItems := []interface{}{} + store := new(automock.PageableStore) + store.On("ListKeys").Return(keys) + store.On("List").Return([]interface{}{}) + pager := From(store) + + items, err := pager.Limit(params) + + require.NoError(t, err) + assert.Equal(t, expectedItems, items) + }) + + t.Run("No paging parameters given", func(t *testing.T) { + store := fixStore(keys, values) + pager := From(store) + first := 0 + offset := 0 + params := PagingParams{ + First: &first, + Offset: &offset, + } + expectedItems := values + + items, err := pager.Limit(params) + + require.NoError(t, err) + assert.Equal(t, expectedItems, items) + }) + + t.Run("Less items than given 'first' parameter, no offset", func(t *testing.T) { + store := fixStore(keys, values) + pager := From(store) + first := 5 + offset := 0 + params := PagingParams{ + First: &first, + Offset: &offset, + } + expectedItems := values + + items, err := pager.Limit(params) + + require.NoError(t, err) + assert.Equal(t, expectedItems, items) + }) + + t.Run("Less items than given 'first' parameter, offset included", func(t *testing.T) { + store := fixStore(keys, values) + pager := From(store) + first := 5 + offset := 1 + params := PagingParams{ + First: &first, + Offset: &offset, + } + expectedItems := []interface{}{ + 2, + 3, + } + + items, err := pager.Limit(params) + + require.NoError(t, err) + assert.Equal(t, expectedItems, items) + }) + + t.Run("More items, 'first' and 'offset' parameters given", func(t *testing.T) { + store := fixStore(keys, values) + pager := From(store) + first := 1 + offset := 1 + params := PagingParams{ + First: &first, + Offset: &offset, + } + expectedItems := []interface{}{ + 2, + } + + items, err := pager.Limit(params) + + require.NoError(t, err) + assert.Equal(t, expectedItems, items) + }) + +} + +func fixStore(keys []string, values []interface{}) *automock.PageableStore { + store := new(automock.PageableStore) + store.On("ListKeys").Return(keys) + store.On("List").Return(values) + + for index, key := range keys { + store.On("GetByKey", key).Return(values[index], true, nil) + } + + return store +} diff --git a/components/ui-api-layer/internal/resource/extractdata.go b/components/ui-api-layer/internal/resource/extractdata.go new file mode 100644 index 000000000000..28128e0eb720 --- /dev/null +++ b/components/ui-api-layer/internal/resource/extractdata.go @@ -0,0 +1,32 @@ +package resource + +import ( + "encoding/json" + + "github.com/pkg/errors" +) + +func ExtractRawToMap(fieldName string, raw []byte) (map[string]interface{}, error) { + extracted := make(map[string]interface{}) + err := json.Unmarshal(raw, &extracted) + if err != nil { + return nil, errors.Wrapf(err, "while extracting raw value of field %s", fieldName) + } + + return extracted, nil +} + +func ToStringPtr(val interface{}) *string { + var result string + + if val != nil { + valStr, ok := val.(string) + if !ok { + return nil + } + + result = valStr + } + + return &result +} diff --git a/components/ui-api-layer/internal/testing/waitforinformer.go b/components/ui-api-layer/internal/testing/waitforinformer.go new file mode 100644 index 000000000000..3f02169e60d3 --- /dev/null +++ b/components/ui-api-layer/internal/testing/waitforinformer.go @@ -0,0 +1,28 @@ +package testing + +import ( + "testing" + "time" + + "k8s.io/client-go/tools/cache" +) + +func WaitForInformerStartAtMost(t *testing.T, timeout time.Duration, informer cache.SharedIndexInformer) { + stop := make(chan struct{}) + syncedDone := make(chan struct{}) + + go func() { + if !cache.WaitForCacheSync(stop, informer.HasSynced) { + t.Fatalf("timeout occurred when waiting to sync informer") + } + close(syncedDone) + }() + + go informer.Run(stop) + + select { + case <-time.After(timeout): + close(stop) + case <-syncedDone: + } +} diff --git a/components/ui-api-layer/main.go b/components/ui-api-layer/main.go new file mode 100644 index 000000000000..86a501bc6aa7 --- /dev/null +++ b/components/ui-api-layer/main.go @@ -0,0 +1,126 @@ +package main + +import ( + "context" + "flag" + "fmt" + "net/http" + "time" + + "github.com/golang/glog" + "github.com/kyma-project/kyma/components/ui-api-layer/pkg/signal" + "github.com/pkg/errors" + "github.com/rs/cors" + "github.com/vektah/gqlgen/graphql" + "github.com/vrischmann/envconfig" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/content" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/domain/remoteenvironment" + "github.com/kyma-project/kyma/components/ui-api-layer/internal/gqlschema" + "github.com/vektah/gqlgen/handler" +) + +type config struct { + Host string `envconfig:"default=127.0.0.1"` + Port int `envconfig:"default=3000"` + AllowedOrigins []string `envconfig:"optional"` + Verbose bool `envconfig:"default=false"` + KubeconfigPath string `envconfig:"optional"` + Content content.Config + InformerResyncPeriod time.Duration `envconfig:"default=10m"` + ServerTimeout time.Duration `envconfig:"default=10s"` + RemoteEnvironment remoteenvironment.Config +} + +func main() { + cfg, err := loadConfig("APP") + exitOnError(err, "Error while loading app config") + parseFlags(cfg) + + k8sConfig, err := newRestClientConfig(cfg.KubeconfigPath) + exitOnError(err, "Error while initializing REST client config") + + resolvers, err := domain.New(k8sConfig, cfg.Content, cfg.RemoteEnvironment, cfg.InformerResyncPeriod) + exitOnError(err, "Error while creating resolvers") + + stopCh := signal.SetupChannel() + resolvers.WaitForCacheSync(stopCh) + + executableSchema := gqlschema.MakeExecutableSchema(resolvers) + addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + + runServer(stopCh, addr, cfg.AllowedOrigins, executableSchema) +} + +func loadConfig(prefix string) (config, error) { + cfg := config{} + err := envconfig.InitWithPrefix(&cfg, prefix) + return cfg, err +} + +func exitOnError(err error, context string) { + if err != nil { + wrappedError := errors.Wrap(err, context) + glog.Fatal(wrappedError) + } +} + +func parseFlags(cfg config) { + if cfg.Verbose { + flag.Set("stderrthreshold", "INFO") + } + flag.Parse() +} + +func newRestClientConfig(kubeconfigPath string) (*restclient.Config, error) { + var config *restclient.Config + var err error + if kubeconfigPath != "" { + config, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath) + } else { + config, err = restclient.InClusterConfig() + } + + if err != nil { + return nil, err + } + return config, nil +} + +func runServer(stop <-chan struct{}, addr string, allowedOrigins []string, schema graphql.ExecutableSchema) { + if len(allowedOrigins) == 0 { + allowedOrigins = []string{"*"} + } + + mux := http.NewServeMux() + mux.Handle("/", handler.Playground("Dataloader", "/graphql")) + mux.Handle("/graphql", handler.GraphQL(schema)) + serverHandler := cors.New(cors.Options{ + AllowedOrigins: allowedOrigins, + AllowedMethods: []string{ + "POST", "GET", "OPTIONS", + }, + AllowCredentials: true, + AllowedHeaders: []string{"*"}, + OptionsPassthrough: false, + }).Handler(mux) + + srv := &http.Server{Addr: addr, Handler: serverHandler} + + glog.Infof("Listening on %s", addr) + + go func() { + <-stop + // Interrupt signal received - shut down the server + if err := srv.Shutdown(context.Background()); err != nil { + glog.Errorf("HTTP server Shutdown: %v", err) + } + }() + + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + glog.Errorf("HTTP server ListenAndServe: %v", err) + } +} diff --git a/components/ui-api-layer/pkg/executor/executor.go b/components/ui-api-layer/pkg/executor/executor.go new file mode 100644 index 000000000000..802d301f5a3c --- /dev/null +++ b/components/ui-api-layer/pkg/executor/executor.go @@ -0,0 +1,34 @@ +package executor + +import ( + "time" +) + +type periodic struct { + refreshPeriod time.Duration + executionFunc func(stopCh <-chan struct{}) +} + +// NewPeriodic creates a periodic executor, which calls given executionFunc periodically. +func NewPeriodic(period time.Duration, executionFunc func(stopCh <-chan struct{})) *periodic { + return &periodic{ + refreshPeriod: period, + executionFunc: executionFunc, + } +} + +// Run starts the periodic work +func (e *periodic) Run(stopCh <-chan struct{}) { + go func() { + ticker := time.NewTicker(e.refreshPeriod) + for { + e.executionFunc(stopCh) + select { + case <-stopCh: + ticker.Stop() + return + case <-ticker.C: + } + } + }() +} diff --git a/components/ui-api-layer/pkg/executor/executor_test.go b/components/ui-api-layer/pkg/executor/executor_test.go new file mode 100644 index 000000000000..61cc1aa64964 --- /dev/null +++ b/components/ui-api-layer/pkg/executor/executor_test.go @@ -0,0 +1,32 @@ +package executor + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestPeriodic(t *testing.T) { + /* + This test is very simple - it has hardcoded time.Sleep. Running it at machine with high load may cause the test fail. + The best way is to modify executor_test (and adapt executor) to not rely on time.Sleep. + */ + + // GIVEN + called := 0 + worker := func(stopCh <-chan struct{}) { + called = called + 1 + } + stopCh := make(chan struct{}) + svc := NewPeriodic(50*time.Millisecond, worker) + + // WHEN + svc.Run(stopCh) + time.Sleep(120 * time.Millisecond) + close(stopCh) + + // THEN + // expecting 3 calls, first at after 0ms, second - after 10ms, third after 20ms + assert.Equal(t, 3, called) +} diff --git a/components/ui-api-layer/pkg/iosafety/iosafety.go b/components/ui-api-layer/pkg/iosafety/iosafety.go new file mode 100644 index 000000000000..fa2acfe017bb --- /dev/null +++ b/components/ui-api-layer/pkg/iosafety/iosafety.go @@ -0,0 +1,16 @@ +package iosafety + +import ( + "io" + "io/ioutil" +) + +// DrainReader reads and discards the remaining part in reader (for example response body data) +// In case of HTTP this ensured that the http connection could be reused for another request if the keepalive http connection behavior is enabled. +func DrainReader(reader io.Reader) error { + if reader == nil { + return nil + } + _, drainError := io.Copy(ioutil.Discard, io.LimitReader(reader, 4096)) + return drainError +} diff --git a/components/ui-api-layer/pkg/jsoncopy/jsoncopy.go b/components/ui-api-layer/pkg/jsoncopy/jsoncopy.go new file mode 100644 index 000000000000..d020250d7f6d --- /dev/null +++ b/components/ui-api-layer/pkg/jsoncopy/jsoncopy.go @@ -0,0 +1,38 @@ +// Copied from https://github.com/kubernetes/apimachinery/blob/master/pkg/runtime/converter.go +// TODO: Switch to "k8s.io/apimachinery/pkg/runtime" dependency when we're using kubernetes 1.9 dependency or above (Service Catalog upgrade) + +package jsoncopy + +import ( + encodingjson "encoding/json" + "fmt" +) + +// DeepCopyJSON deep copies the passed value, assuming it is a valid JSON representation i.e. only contains +// types produced by json.Unmarshal(). +func DeepCopyJSON(x map[string]interface{}) map[string]interface{} { + return DeepCopyJSONValue(x).(map[string]interface{}) +} + +// DeepCopyJSONValue deep copies the passed value, assuming it is a valid JSON representation i.e. only contains +// types produced by json.Unmarshal(). +func DeepCopyJSONValue(x interface{}) interface{} { + switch x := x.(type) { + case map[string]interface{}: + clone := make(map[string]interface{}, len(x)) + for k, v := range x { + clone[k] = DeepCopyJSONValue(v) + } + return clone + case []interface{}: + clone := make([]interface{}, len(x)) + for i, v := range x { + clone[i] = DeepCopyJSONValue(v) + } + return clone + case string, int64, bool, float64, nil, encodingjson.Number: + return x + default: + panic(fmt.Errorf("cannot deep copy %T", x)) + } +} diff --git a/components/ui-api-layer/pkg/resource/automock/listener.go b/components/ui-api-layer/pkg/resource/automock/listener.go new file mode 100644 index 000000000000..73efe2c7b59e --- /dev/null +++ b/components/ui-api-layer/pkg/resource/automock/listener.go @@ -0,0 +1,24 @@ +// Code generated by mockery v1.0.0 +package automock + +import mock "github.com/stretchr/testify/mock" + +// Listener is an autogenerated mock type for the Listener type +type Listener struct { + mock.Mock +} + +// OnAdd provides a mock function with given fields: object +func (_m *Listener) OnAdd(object interface{}) { + _m.Called(object) +} + +// OnDelete provides a mock function with given fields: object +func (_m *Listener) OnDelete(object interface{}) { + _m.Called(object) +} + +// OnUpdate provides a mock function with given fields: newObject, oldObject +func (_m *Listener) OnUpdate(newObject interface{}, oldObject interface{}) { + _m.Called(newObject, oldObject) +} diff --git a/components/ui-api-layer/pkg/resource/notifier.go b/components/ui-api-layer/pkg/resource/notifier.go new file mode 100644 index 000000000000..1c9a60b20540 --- /dev/null +++ b/components/ui-api-layer/pkg/resource/notifier.go @@ -0,0 +1,84 @@ +package resource + +import "sync" + +//go:generate mockery -name=Listener -output=automock -outpkg=automock -case=underscore +type Listener interface { + OnAdd(object interface{}) + OnUpdate(newObject, oldObject interface{}) + OnDelete(object interface{}) +} + +type Notifier interface { + OnAdd(object interface{}) + OnUpdate(newObject, oldObject interface{}) + OnDelete(object interface{}) + AddListener(observer Listener) + DeleteListener(observer Listener) +} + +type notifier struct { + sync.RWMutex + // TODO: change to map for better performance + listeners []Listener +} + +func NewNotifier() Notifier { + return new(notifier) +} + +func (n *notifier) AddListener(listener Listener) { + if listener == nil { + return + } + + n.Lock() + defer n.Unlock() + + n.listeners = append(n.listeners, listener) +} + +func (n *notifier) DeleteListener(listener Listener) { + if listener == nil { + return + } + + n.Lock() + defer n.Unlock() + + filtered := n.listeners[:0] + for _, l := range n.listeners { + if l != listener { + filtered = append(filtered, l) + } + } + + n.listeners = filtered +} + +func (n *notifier) OnAdd(obj interface{}) { + n.RLock() + defer n.RUnlock() + + for _, listener := range n.listeners { + listener.OnAdd(obj) + } +} + +func (n *notifier) OnUpdate(oldObj, newObj interface{}) { + n.RLock() + defer n.RUnlock() + + for _, listener := range n.listeners { + listener.OnUpdate(oldObj, newObj) + } +} + +func (n *notifier) OnDelete(obj interface{}) { + n.RLock() + defer n.RUnlock() + + for _, listener := range n.listeners { + listener.OnDelete(obj) + } +} diff --git a/components/ui-api-layer/pkg/resource/notifier_test.go b/components/ui-api-layer/pkg/resource/notifier_test.go new file mode 100644 index 000000000000..5525791f6edc --- /dev/null +++ b/components/ui-api-layer/pkg/resource/notifier_test.go @@ -0,0 +1,287 @@ +package resource_test + +import ( + "testing" + + "github.com/kyma-project/kyma/components/ui-api-layer/pkg/resource" + "github.com/kyma-project/kyma/components/ui-api-layer/pkg/resource/automock" +) + +func TestNotifier_AddListener(t *testing.T) { + t.Run("Single", func(t *testing.T) { + listener := new(automock.Listener) + notifier := resource.NewNotifier() + + notifier.AddListener(listener) + }) + + t.Run("Duplicated", func(t *testing.T) { + listener := new(automock.Listener) + notifier := resource.NewNotifier() + + notifier.AddListener(listener) + notifier.AddListener(listener) + }) + + t.Run("Multiple", func(t *testing.T) { + listenerA := new(automock.Listener) + listenerB := new(automock.Listener) + notifier := resource.NewNotifier() + + notifier.AddListener(listenerA) + notifier.AddListener(listenerB) + }) + + t.Run("Nil listener", func(t *testing.T) { + notifier := resource.NewNotifier() + + notifier.AddListener(nil) + }) +} + +func TestNotifier_DeleteListener(t *testing.T) { + t.Run("Single", func(t *testing.T) { + listener := new(automock.Listener) + notifier := resource.NewNotifier() + + notifier.AddListener(listener) + notifier.DeleteListener(listener) + }) + + t.Run("Duplicated", func(t *testing.T) { + listener := new(automock.Listener) + notifier := resource.NewNotifier() + + notifier.AddListener(listener) + notifier.AddListener(listener) + notifier.DeleteListener(listener) + }) + + t.Run("Multiple", func(t *testing.T) { + listenerA := new(automock.Listener) + listenerB := new(automock.Listener) + notifier := resource.NewNotifier() + + notifier.AddListener(listenerA) + notifier.AddListener(listenerB) + notifier.DeleteListener(listenerA) + }) + + t.Run("Not existing", func(t *testing.T) { + listener := new(automock.Listener) + notifier := resource.NewNotifier() + + notifier.DeleteListener(listener) + }) + + t.Run("Nil listener", func(t *testing.T) { + notifier := resource.NewNotifier() + + notifier.DeleteListener(nil) + }) +} + +func TestNotifier_OnAdd(t *testing.T) { + expected := new(struct{}) + + t.Run("No listeners", func(t *testing.T) { + notifier := resource.NewNotifier() + + notifier.OnAdd(expected) + }) + + t.Run("Single", func(t *testing.T) { + listener := new(automock.Listener) + listener.On("OnAdd", expected).Once() + + notifier := resource.NewNotifier() + + notifier.AddListener(listener) + notifier.OnAdd(expected) + + listener.AssertCalled(t, "OnAdd", expected) + }) + + t.Run("Duplicated", func(t *testing.T) { + listener := new(automock.Listener) + listener.On("OnAdd", expected).Twice() + + notifier := resource.NewNotifier() + + notifier.AddListener(listener) + notifier.AddListener(listener) + notifier.OnAdd(expected) + + listener.AssertNumberOfCalls(t, "OnAdd", 2) + }) + + t.Run("Multiple", func(t *testing.T) { + listenerA := new(automock.Listener) + listenerB := new(automock.Listener) + listenerA.On("OnAdd", expected).Once() + listenerB.On("OnAdd", expected).Once() + + notifier := resource.NewNotifier() + + notifier.AddListener(listenerA) + notifier.AddListener(listenerB) + notifier.OnAdd(expected) + + listenerA.AssertCalled(t, "OnAdd", expected) + listenerB.AssertCalled(t, "OnAdd", expected) + }) + + t.Run("Deleted listener", func(t *testing.T) { + listenerA := new(automock.Listener) + listenerB := new(automock.Listener) + listenerA.On("OnAdd", expected).Once() + listenerB.On("OnAdd", expected).Once() + + notifier := resource.NewNotifier() + + notifier.AddListener(listenerA) + notifier.AddListener(listenerB) + notifier.DeleteListener(listenerB) + notifier.OnAdd(expected) + + listenerA.AssertCalled(t, "OnAdd", expected) + listenerB.AssertNotCalled(t, "OnAdd", expected) + }) +} + +func TestNotifier_OnDelete(t *testing.T) { + expected := new(struct{}) + + t.Run("No listeners", func(t *testing.T) { + notifier := resource.NewNotifier() + + notifier.OnDelete(expected) + }) + + t.Run("Single", func(t *testing.T) { + listener := new(automock.Listener) + listener.On("OnDelete", expected).Once() + + notifier := resource.NewNotifier() + + notifier.AddListener(listener) + notifier.OnDelete(expected) + + listener.AssertCalled(t, "OnDelete", expected) + }) + + t.Run("Duplicated", func(t *testing.T) { + listener := new(automock.Listener) + listener.On("OnDelete", expected).Twice() + + notifier := resource.NewNotifier() + + notifier.AddListener(listener) + notifier.AddListener(listener) + notifier.OnDelete(expected) + + listener.AssertNumberOfCalls(t, "OnDelete", 2) + }) + + t.Run("Multiple", func(t *testing.T) { + listenerA := new(automock.Listener) + listenerB := new(automock.Listener) + listenerA.On("OnDelete", expected).Once() + listenerB.On("OnDelete", expected).Once() + + notifier := resource.NewNotifier() + + notifier.AddListener(listenerA) + notifier.AddListener(listenerB) + notifier.OnDelete(expected) + + listenerA.AssertCalled(t, "OnDelete", expected) + listenerB.AssertCalled(t, "OnDelete", expected) + }) + + t.Run("Deleted listener", func(t *testing.T) { + listenerA := new(automock.Listener) + listenerB := new(automock.Listener) + listenerA.On("OnDelete", expected).Once() + listenerB.On("OnDelete", expected).Once() + + notifier := resource.NewNotifier() + + notifier.AddListener(listenerA) + notifier.AddListener(listenerB) + notifier.DeleteListener(listenerB) + notifier.OnDelete(expected) + + listenerA.AssertCalled(t, "OnDelete", expected) + listenerB.AssertNotCalled(t, "OnDelete", expected) + }) +} + +func TestNotifier_OnUpdate(t *testing.T) { + expectedNew := new(struct{}) + expectedOld := new(struct{}) + + t.Run("No listeners", func(t *testing.T) { + notifier := resource.NewNotifier() + + notifier.OnUpdate(expectedOld, expectedNew) + }) + + t.Run("Single", func(t *testing.T) { + listener := new(automock.Listener) + listener.On("OnUpdate", expectedOld, expectedNew).Once() + + notifier := resource.NewNotifier() + + notifier.AddListener(listener) + notifier.OnUpdate(expectedOld, expectedNew) + + listener.AssertCalled(t, "OnUpdate", expectedOld, expectedNew) + }) + + t.Run("Duplicated", func(t *testing.T) { + listener := new(automock.Listener) + listener.On("OnUpdate", expectedOld, expectedNew).Twice() + + notifier := resource.NewNotifier() + + notifier.AddListener(listener) + notifier.AddListener(listener) + notifier.OnUpdate(expectedOld, expectedNew) + + listener.AssertNumberOfCalls(t, "OnUpdate", 2) + }) + + t.Run("Multiple", func(t *testing.T) { + listenerA := new(automock.Listener) + listenerB := new(automock.Listener) + listenerA.On("OnUpdate", expectedOld, expectedNew).Once() + listenerB.On("OnUpdate", expectedOld, expectedNew).Once() + + notifier := resource.NewNotifier() + + notifier.AddListener(listenerA) + notifier.AddListener(listenerB) + notifier.OnUpdate(expectedOld, expectedNew) + + listenerA.AssertCalled(t, "OnUpdate", expectedOld, expectedNew) + listenerB.AssertCalled(t, "OnUpdate", expectedOld, expectedNew) + }) + + t.Run("Deleted listener", func(t *testing.T) { + listenerA := new(automock.Listener) + listenerB := new(automock.Listener) + listenerA.On("OnUpdate", expectedOld, expectedNew).Once() + listenerB.On("OnUpdate", expectedOld, expectedNew).Once() + + notifier := resource.NewNotifier() + + notifier.AddListener(listenerA) + notifier.AddListener(listenerB) + notifier.DeleteListener(listenerB) + notifier.OnUpdate(expectedOld, expectedNew) + + listenerA.AssertCalled(t, "OnUpdate", expectedOld, expectedNew) + listenerB.AssertNotCalled(t, "OnUpdate", expectedOld, expectedNew) + }) +} diff --git a/components/ui-api-layer/pkg/signal/signal.go b/components/ui-api-layer/pkg/signal/signal.go new file mode 100644 index 000000000000..c1ae50661bf3 --- /dev/null +++ b/components/ui-api-layer/pkg/signal/signal.go @@ -0,0 +1,26 @@ +// Copied from github.com/kyma-project/kyma/components/binding-usage-controller/pkg/signal/signal.go + +package signal + +import ( + "os" + "os/signal" + "syscall" +) + +// SetupChannel registered for SIGTERM and Interrupt. A stop channel is returned +// which is closed on one of these signals. If a second signal is caught, the program +// is terminated with exit code 1. +func SetupChannel() (stopCh <-chan struct{}) { + stop := make(chan struct{}) + c := make(chan os.Signal, 2) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + go func() { + <-c + close(stop) + <-c + os.Exit(1) // second signal. Exit directly. + }() + + return stop +} diff --git a/docs/Jenkinsfile b/docs/Jenkinsfile new file mode 100644 index 000000000000..bd8f91cee40f --- /dev/null +++ b/docs/Jenkinsfile @@ -0,0 +1,156 @@ +#!/usr/bin/env groovy +def docs_image_suffix = "docs" +def kyma = "kyma" +def service_catalog = "service-catalog" +def service_brokers = "service-brokers" +def application_connector = "application-connector" +def event_bus = "event-bus" +def service_mesh = "service-mesh" +def serverless = "serverless" +def monitoring = "monitoring" +def tracing = "tracing" +def azure_mysql = "azure-mysql" +def azure_redis_cache = "azure-redis-cache" +def azure_sql = "azure-sql" +def azure_classes_docs_dir = "$service_brokers/azure-broker-service-classes" +def api_gateway = "api-gateway" +def authorization_and_authentication = "authorization-and-authentication" +def docs_baseimage = "https://github.wdf.sap.corp/raw/kyma/content-upload-base-image/master/Dockerfile" +def label = "kyma-${UUID.randomUUID().toString()}" + +def isMaster = params.GIT_BRANCH == 'master' + +def dockerPushRoot = isMaster + ? "${env.DOCKER_REGISTRY}" + : "${env.DOCKER_REGISTRY}snapshot/" + +def dockerImageTag = isMaster + ? params.APP_VERSION + : params.GIT_BRANCH + +echo """ +******************************** +Job started with the following parameters: +DOCKER_REGISTRY=${env.DOCKER_REGISTRY} +DOCKER_CREDENTIALS=${env.DOCKER_CREDENTIALS} +GIT_REVISION=${params.GIT_REVISION} +GIT_BRANCH=${params.GIT_BRANCH} +APP_VERSION=${params.APP_VERSION} +APP_FOLDER=${env.APP_FOLDER} +******************************** +""" + +podTemplate(label: label) { + node(label) { + try { + timestamps { + timeout(time:20, unit:"MINUTES") { + ansiColor('xterm') { + stage("setup") { + checkout scm + + withCredentials([usernamePassword(credentialsId: env.DOCKER_CREDENTIALS, passwordVariable: 'pwd', usernameVariable: 'uname')]) { + sh "docker login -u $uname -p '$pwd' $env.DOCKER_REGISTRY" + } + } + + stage("docs build image") { + dir("$docs_image_suffix/$kyma") { + sh "curl $docs_baseimage | docker build -f - . -t $kyma-$docs_image_suffix" + } + dir("$docs_image_suffix/$service_catalog") { + sh "curl $docs_baseimage | docker build -f - . -t $service_catalog-$docs_image_suffix" + } + dir("$docs_image_suffix/$service_brokers") { + sh "curl $docs_baseimage | docker build -f - . -t $service_brokers-$docs_image_suffix" + } + dir("$docs_image_suffix/$application_connector") { + sh "curl $docs_baseimage | docker build -f - . -t $application_connector-$docs_image_suffix" + } + dir("$docs_image_suffix/$event_bus") { + sh "curl $docs_baseimage | docker build -f - . -t $event_bus-$docs_image_suffix" + } + dir("$docs_image_suffix/$service_mesh") { + sh "curl $docs_baseimage | docker build -f - . -t $service_mesh-$docs_image_suffix" + } + dir("$docs_image_suffix/$serverless") { + sh "curl $docs_baseimage | docker build -f - . -t $serverless-$docs_image_suffix" + } + dir("$docs_image_suffix/$monitoring") { + sh "curl $docs_baseimage | docker build -f - . -t $monitoring-$docs_image_suffix" + } + dir("$docs_image_suffix/$tracing") { + sh "curl $docs_baseimage | docker build -f - . -t $tracing-$docs_image_suffix" + } + dir("$docs_image_suffix/$azure_classes_docs_dir/$azure_mysql") { + sh "curl $docs_baseimage | docker build -f - . -t $azure_mysql-$docs_image_suffix" + } + dir("$docs_image_suffix/$azure_classes_docs_dir/$azure_sql") { + sh "curl $docs_baseimage | docker build -f - . -t $azure_sql-$docs_image_suffix" + } + dir("$docs_image_suffix/$azure_classes_docs_dir/$azure_redis_cache") { + sh "curl $docs_baseimage | docker build -f - . -t $azure_redis_cache-$docs_image_suffix" + } + dir("$docs_image_suffix/$api_gateway") { + sh "curl $docs_baseimage | docker build -f - . -t $api_gateway-$docs_image_suffix" + } + dir("$docs_image_suffix/$authorization_and_authentication") { + sh "curl $docs_baseimage | docker build -f - . -t $authorization_and_authentication-$docs_image_suffix" + } + + } + + stage("docs push image") { + sh "docker tag $kyma-$docs_image_suffix ${dockerPushRoot}$kyma-$docs_image_suffix:$dockerImageTag" + sh "docker push ${dockerPushRoot}$kyma-$docs_image_suffix:$dockerImageTag" + + sh "docker tag $service_catalog-$docs_image_suffix ${dockerPushRoot}$service_catalog-$docs_image_suffix:$dockerImageTag" + sh "docker push ${dockerPushRoot}$service_catalog-$docs_image_suffix:$dockerImageTag" + + sh "docker tag $service_brokers-$docs_image_suffix ${dockerPushRoot}$service_brokers-$docs_image_suffix:$dockerImageTag" + sh "docker push ${dockerPushRoot}$service_brokers-$docs_image_suffix:$dockerImageTag" + + sh "docker tag $application_connector-$docs_image_suffix ${dockerPushRoot}$application_connector-$docs_image_suffix:$dockerImageTag" + sh "docker push ${dockerPushRoot}$application_connector-$docs_image_suffix:$dockerImageTag" + + sh "docker tag $event_bus-$docs_image_suffix ${dockerPushRoot}$event_bus-$docs_image_suffix:$dockerImageTag" + sh "docker push ${dockerPushRoot}$event_bus-$docs_image_suffix:$dockerImageTag" + + sh "docker tag $service_mesh-$docs_image_suffix ${dockerPushRoot}$service_mesh-$docs_image_suffix:$dockerImageTag" + sh "docker push ${dockerPushRoot}$service_mesh-$docs_image_suffix:$dockerImageTag" + + sh "docker tag $serverless-$docs_image_suffix ${dockerPushRoot}$serverless-$docs_image_suffix:$dockerImageTag" + sh "docker push ${dockerPushRoot}$serverless-$docs_image_suffix:$dockerImageTag" + + sh "docker tag $monitoring-$docs_image_suffix ${dockerPushRoot}$monitoring-$docs_image_suffix:$dockerImageTag" + sh "docker push ${dockerPushRoot}$monitoring-$docs_image_suffix:$dockerImageTag" + + sh "docker tag $tracing-$docs_image_suffix ${dockerPushRoot}$tracing-$docs_image_suffix:$dockerImageTag" + sh "docker push ${dockerPushRoot}$tracing-$docs_image_suffix:$dockerImageTag" + + sh "docker tag $azure_mysql-$docs_image_suffix ${dockerPushRoot}$azure_mysql-$docs_image_suffix:$dockerImageTag" + sh "docker push ${dockerPushRoot}$azure_mysql-$docs_image_suffix:$dockerImageTag" + + sh "docker tag $azure_sql-$docs_image_suffix ${dockerPushRoot}$azure_sql-$docs_image_suffix:$dockerImageTag" + sh "docker push ${dockerPushRoot}$azure_sql-$docs_image_suffix:$dockerImageTag" + + sh "docker tag $azure_redis_cache-$docs_image_suffix ${dockerPushRoot}$azure_redis_cache-$docs_image_suffix:$dockerImageTag" + sh "docker push ${dockerPushRoot}$azure_redis_cache-$docs_image_suffix:$dockerImageTag" + + sh "docker tag $api_gateway-$docs_image_suffix ${dockerPushRoot}$api_gateway-$docs_image_suffix:$dockerImageTag" + sh "docker push ${dockerPushRoot}$api_gateway-$docs_image_suffix:$dockerImageTag" + + sh "docker tag $authorization_and_authentication-$docs_image_suffix ${dockerPushRoot}$authorization_and_authentication-$docs_image_suffix:$dockerImageTag" + sh "docker push ${dockerPushRoot}$authorization_and_authentication-$docs_image_suffix:$dockerImageTag" + } + } + } + } + } catch (ex) { + echo "Got exception: ${ex}" + currentBuild.result = "FAILURE" + def body = "${currentBuild.currentResult} ${env.JOB_NAME}${env.BUILD_DISPLAY_NAME}: on branch: ${params.GIT_BRANCH}. See details: ${env.BUILD_URL}" + emailext body: body, recipientProviders: [[$class: 'DevelopersRecipientProvider'], [$class: 'CulpritsRecipientProvider'], [$class: 'RequesterRecipientProvider']], subject: "${currentBuild.currentResult}: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'" + } + } +} diff --git a/docs/LICENSE b/docs/LICENSE new file mode 100644 index 000000000000..be36d55b6f12 --- /dev/null +++ b/docs/LICENSE @@ -0,0 +1,202 @@ + + 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 (c) 2018 SAP SE or an SAP affiliate company. All rights reserved. + + 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/docs/README.md b/docs/README.md new file mode 100644 index 000000000000..0888a7a3f3ed --- /dev/null +++ b/docs/README.md @@ -0,0 +1,18 @@ +# Documentation + +## Overview + +The `docs` folder contains end-to-end documentation on Kyma and the following components: + +* [Application Connector](application-connector/docs/) +* [Event Bus](event-bus/docs/) +* [Monitoring](monitoring/docs/) +* [Serverless](serverless/docs/) +* [Service Brokers](service-brokers/docs/) +* [Service Catalog](service-catalog/docs/) +* [Service Mesh](service-mesh/docs/) +* [Tracing](tracing/docs/) + +Read it directly in GitHub or inside the Kyma Console if you have access to the Kyma cluster. + +The navigation order of the documentation page is based on the [Manifest](manifest.yaml) file. diff --git a/docs/api-gateway/assets/001-service-exposure-flow.html b/docs/api-gateway/assets/001-service-exposure-flow.html new file mode 100644 index 000000000000..1598d23914d6 --- /dev/null +++ b/docs/api-gateway/assets/001-service-exposure-flow.html @@ -0,0 +1,11 @@ + + + + +001-service-exposure-flow + + +
    + + + \ No newline at end of file diff --git a/docs/api-gateway/assets/001-service-exposure-flow.png b/docs/api-gateway/assets/001-service-exposure-flow.png new file mode 100644 index 0000000000000000000000000000000000000000..e9a47df0f5a4eefe7ce904e96a1469360ac2897e GIT binary patch literal 35691 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 literal 0 HcmV?d00001 diff --git a/docs/api-gateway/docs.config.json b/docs/api-gateway/docs.config.json new file mode 100644 index 000000000000..f48e5c3d200a --- /dev/null +++ b/docs/api-gateway/docs.config.json @@ -0,0 +1,8 @@ +{ + "spec":{ + "id":"api-gateway", + "displayName":"API Gateway", + "description":"API Gateway documentation", + "type":"Components" + } +} diff --git a/docs/api-gateway/docs/001-overview.md b/docs/api-gateway/docs/001-overview.md new file mode 100644 index 000000000000..bc160fe48e88 --- /dev/null +++ b/docs/api-gateway/docs/001-overview.md @@ -0,0 +1,6 @@ +--- +title: Overview +type: Overview +--- + +To make your service accessible outside the Kyma cluster, expose it using the Kyma API Controller, which listens for the Custom Resource (CR) objects that follow the `api.gateway.kyma.cx` Custom Resource Definition (CRD). Creating a valid CR triggers the API Controller to create an Istio Ingress for the service. Optionally, you can specify the **authentication** attribute of the CR to secure the exposed service and create an Istio Authentication Policy for it. diff --git a/docs/api-gateway/docs/005-architecture.md b/docs/api-gateway/docs/005-architecture.md new file mode 100644 index 000000000000..24a14316f9f4 --- /dev/null +++ b/docs/api-gateway/docs/005-architecture.md @@ -0,0 +1,23 @@ +--- +title: Architecture +type: Architecture +--- + +This diagram illustrates the workflow that leads to exposing a service in Kyma: + +![service-exposure-flow](../assets/001-service-exposure-flow.png) + +- **API-Controller** is a component responsible for exposing services. The API-Controller is an application deployed in the `kyma-system` Namespace, implemented according to the [Kubernetes Operator](https://coreos.com/blog/introducing-operators.html) principles. The API-Controller listens for newly created Custom Resources (CR) that follow the set `api.gateway.kyma.cx` Custom Resource Definition (CRD), which describes the details of exposing services in Kyma. + +- **Istio Ingress** is used to specify the services that are visible outside the cluster. The API-Controller creates Istio Ingress for the hostname defined in the `api.gateway.kyma.cx` CRD. The convention is to create a hostname using the name of the service as the subdomain, and the domain of the Kyma cluster. Although you can specify any hostname, TLS termination functions only for the domain of the Kyma cluster because the API-Controller injects the TLS configuration of the cluster into the Istio Ingress. To learn more about the Istio Ingress concept, read this [Istio documentation](https://kubernetes.io/docs/concepts/services-networking/ingress/). +To get the list of all Ingresses in Kyma, run: +``` +kubectl get ingresses +``` + +- **Istio Authentication Policy** allows operators to specify authentication requirements for a service. It is an optional resource, created only when the CR specifies the desired authentication method, the token issuer, and the JSON Web Key Set (JWKS) endpoint URI. You can secure services using the `JWT` authentication method. You can specify multiple JWT issuers to allow to access the service with tokens from different ID providers. For more details, see the [Security](./008-security.md) document. +The JWKS endpoint is used to fetch cryptographic keys, which allow to verify the ID token signature. Services exposed through a CR with the **authentication** section specified require valid ID tokens to access them. To learn more about the Istio Authentication Policy, read this [Istio security documentation](https://istio.io/docs/concepts/security/authn-policy/). +To get the list of all Istio Authentication Policies created in Kyma, run: +``` +kubectl get policies.authentication.istio.io --all-namespaces +``` diff --git a/docs/api-gateway/docs/008-security.md b/docs/api-gateway/docs/008-security.md new file mode 100644 index 000000000000..1ce40fd93fa5 --- /dev/null +++ b/docs/api-gateway/docs/008-security.md @@ -0,0 +1,37 @@ +--- +title: Security +type: Security +--- + +When you expose a service in Kyma, you can secure it by specifying the **authentication** attribute in the Custom Resource (CR). To successfully secure the exposed service, you must specify all of these attributes in the CR: + - **authentication.type** + - **jwt.issuer** + - **jwt.jwksUri** + +If you don't specify any of these attributes, the API-Controller does not create an Istio Authentication Policy for the service and leaves it unsecured. + +>**NOTE:** You can secure only the entire service. You cannot secure the specific endpoints of the service. + +## Call a secured service + +You can secure the exposed service using JWT authentication. This means that you must include a valid JWT ID token in the `Authorization` header of the request when you call +a secured service. + +This is an example of a call to a secure exposed service: +``` +curl -i https://httpbin.org/headers -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjFmNThlNTBhODI4OWMzYWM5MmE5ZTA2ZmM0YzIyZDc1NTU4MTc5YjIifQ.eyJpc3MiOiJodHRwczovL2RleC55ZmFjdG9yeS5zYXAuY29ycCIsInN1YiI6IkNpUXhPR0U0TmpnMFlpMWtZamc0TFRSaU56TXRPVEJoT1MwelkyUXhOall4WmpVME5qTVNCV3h2WTJGcyIsImF1ZCI6WyJreW1hLWNsaWVudCIsImt1YmVjb250cm9sbGVyIl0sImV4cCI6MTUzMDA5ODg3MiwiaWF0IjoxNTMwMDEyNDcyLCJhenAiOiJrdWJlY29udHJvbGxlciIsImF0X2hhc2giOiJ5QzJwY0ZmVWYzWVd2N2U5QUY3U0t3IiwiZW1haWwiOiJhZG1pbkBreW1hLmN4IiwiZW1haWxfdmVyaWZpZWQiOnRydWUsIm5hbWUiOiJhZG1pbiJ9.pxy4P95PVSwIiXArcfsqAPVFhBmo5sHzUnqzwY6HF9UgMRkDFlIs5CKe1ZiGteGr6-gYU_0VmHroZ4alpcVcpL8Z5M2xnlOaZDyB8TNLvUAATpElcBMy6Cxb_7zLwP91IsX0QgI3DTg3H-M0eaJ4VwMKrfEu9h2rwxzvBrDc5vB_1Bm8OABl08wLSQpR27GGsI58RmA5YJmZX1PSzv90Zl_krqyvWIe6pmcHCrP--02LLUaoxhY42IDWkF8n9RPMLixmFZFXbeonCddR30OUkAbEFLBVBf8nJaFDms_VjZHSXZDitCu4r6myE4AnT_IeXI2dRgdGT73Hh8895zu7fQ" +``` +## Specify multiple JWT token issuers + +You can specify multiple JWT token issuers to allow to access the secured service with tokens issued by different ID providers. You can successfully call the secured service using JWT ID tokens issued by any of the parties specified in the **authentication** attribute of the CR. This is an example of the **authentication** attribute that allows to access the service using JWT tokens signed by two different issuers. + +``` + - type: JWT + jwt: + issuer: https://sampleissuer1.abc.com + jwksUri: https://www.sampleapis.com/oauth2/v3/certs + - type: JWT + jwt: + issuer: https://sampleissuer2.abc.com + jwksUri: https://www.regularsampleapis.com/oauth2/v3/certs +``` diff --git a/docs/api-gateway/docs/011-CRD.md b/docs/api-gateway/docs/011-CRD.md new file mode 100644 index 000000000000..98fc3ae17e21 --- /dev/null +++ b/docs/api-gateway/docs/011-CRD.md @@ -0,0 +1,45 @@ +--- +title: API +type: CRD +--- + +The `api.gateway.kyma.cx` Custom Resource Definition (CRD) is a detailed description of the kind of data and the format the API Controller listens for. To get the up-to-date CRD and show +the output in the `yaml` format, run this command: +``` +kubectl get crd apis.gateway.kyma.cx -o yaml +``` + +## Sample Custom Resource + +This is a sample CR that the API-Controller listens for to expose a service. This example has the **authentication** section specified which makes the API-Controller create an Istio Authentication Policy for this service. + +``` +apiVersion: gateway.kyma.cx/v1alpha2 +kind: api +metadata: + name: sample-api +spec: + service: + name: kubernetes + port: 443 + hostname: kubernetes.kyma.local + authentication: + - type: JWT + jwt: + issuer: https://accounts.google.com + jwksUri: https://www.googleapis.com/oauth2/v3/certs +``` + +This table analyses the elements of the sample CR and the information it contains: + + +| Field | Mandatory? | Description | +|:----------:|:-------------:|:------| +| **apiVersion** | **YES** | Defined basing on the `group` and `version` fields of the CRD `spec` section. | +| **kind** | **YES** | Defined basing on the `names: kind` field of the CRD `spec` section. | +| **metadata.name** | **YES** | Specifies the name of the exposed API | +| **service.name**, **service.port** | **YES** | Specifies the name and the communication port of the exposed service. | +| **hostname** | **YES** | Specifies the service's external inbound communication address. | +| **authentication** | **NO** | Allows to specify an array of authentication policies that secure the service. | +| **authentication.type** | **YES** | Specifies the desired authentication method that secures the exposed service. | +| **authentication.jwt.issuer**, **authentication.jwt.jwksUri** | **YES** | Specifies the issuer of the tokens used to access the services, as well as the JWKS endpoint URI. | diff --git a/docs/application-connector/docs.config.json b/docs/application-connector/docs.config.json new file mode 100644 index 000000000000..8be664d9cb46 --- /dev/null +++ b/docs/application-connector/docs.config.json @@ -0,0 +1,8 @@ +{ + "spec":{ + "id":"application-connector", + "displayName":"Application Connector", + "description":"Overal documentation for Application Connector", + "type":"Components" + } +} \ No newline at end of file diff --git a/docs/application-connector/docs/001-overview-application-connector.md b/docs/application-connector/docs/001-overview-application-connector.md new file mode 100644 index 000000000000..abdfc0ab7bf4 --- /dev/null +++ b/docs/application-connector/docs/001-overview-application-connector.md @@ -0,0 +1,10 @@ +--- +title: The Application Connector +type: Overview +--- + + +The Application Connector is a proprietary Kyma solution. This endpoint is the Kyma side of the connection between Kyma and external solutions. The Application Connector provides the following functionalities: +* Metadata API for registering available external solutions' APIs, Event Catalog, and documentation +* Events API for sending Events from the external solution to the Kyma Event Bus +* Proxy API for calling registered external solutions' APIs from Kyma services or lambdas diff --git a/docs/application-connector/docs/005-architecture-application-connector.md b/docs/application-connector/docs/005-architecture-application-connector.md new file mode 100644 index 000000000000..55fd1d0df22e --- /dev/null +++ b/docs/application-connector/docs/005-architecture-application-connector.md @@ -0,0 +1,15 @@ +--- +title: Application Connector components +type: Architecture +--- + +The Application Connector consists of the following components: + +* **Ingress-Gateway controller** responsible for validating certificates and exposing multiple Application Connectors to the external world +* **Gateway** responsible for registering available services (APIs, Events) and proxying calls to the registered solution +* **Remote Environment CRD instance** responsible for storing a solution's metadata +* **Minio bucket** responsible for storing API specifications, Event Catalog, and documentation + +To connect a new solution, you must deploy a new Application Connector. Every instance of the external solution connected to Kyma has only one instance of the Application Connector dedicated to it. See the **Deploying a new Application Connector** document to learn how to deploy a new Application Connector. + +![Architecture Diagram](assets/001-application-connector.png) diff --git a/docs/application-connector/docs/006-architecture-ingress-gateway.md b/docs/application-connector/docs/006-architecture-ingress-gateway.md new file mode 100644 index 000000000000..3d2be46f374e --- /dev/null +++ b/docs/application-connector/docs/006-architecture-ingress-gateway.md @@ -0,0 +1,15 @@ +--- +title: Ingress-Gateway controller +type: Architecture +--- + +The Ingress-Gateway controller exposes the Kyma gateways to the outside world by the public IP address/DNS name. +The DNS name of the Ingress is `gateway.[cluster-dns]`. For example: `gateway.servicemanager.cluster.kyma.cx`. + +A particular Remote Environment is exposed as a path. For example, to reach the Gateway for the Remote Environment named `ec-default`, use the following URL: `gateway.servicemanager.cluster.kyma.cx/ec-default` + +This is an example of how to get all ServiceClasses: + +```console +http GET https://gateway.servicemanager.cluster.kyma.cx/ec-default/v1/metadata/services --cert=ec-default.pem +``` diff --git a/docs/application-connector/docs/010-details-ac-deployment.md b/docs/application-connector/docs/010-details-ac-deployment.md new file mode 100644 index 000000000000..e88db355ad5d --- /dev/null +++ b/docs/application-connector/docs/010-details-ac-deployment.md @@ -0,0 +1,67 @@ +--- +title: Deploy a new Application Connector +type: Details +--- + +By default, Kyma comes with two Application Connectors preconfigured. Those Application Connectors are installed in the `kyma-integration` Namespace. + +

    + +### Install a Application Connector locally + +For installation on Minikube, provide the NodePort as shown in this example: + +``` bash +helm install --name {remote-environment-name} --set deployment.args.sourceType=commerce --set global.isLocalEnv=true --set service.externalapi.nodePort=32001 --namespace kyma-integration ./resources/remote-environments +``` + +You can override the following parameters: + +- **sourceEnvironment** - the Event source environment name. +- **sourceType** - the Event source type. +- **sourceNamespace** - the organization that publishes the Event. + +

    + +### Install an Application Connector on the cluster + +To add a new Application Connector to the cluster, download [remote-environments.zip](assets/remote-environments.zip) package, unpack it, and place the content in the project's directory. + +To install a Remote Environment, use: +``` bash +helm install --name {remote-environment-name} --set deployment.args.sourceType=commerce --set global.isLocalEnv=false --set global.domainName={domain-name} --namespace kyma-integration ./remote-environments +``` + +- global.domainName override is required and cannot be omitted, example values may look like: +``` +wormhole.cluster.kyma.cx +nightly.cluster.kyma.cx +``` + +You can override the following parameters: + +- **sourceEnvironment** is the Event source environment name. +- **sourceType** is the Event source type. +- **sourceNamespace** is the organization that publishes the Event. + +### Working with Helm + +Helm provides the following commands: +- `helm list` - lists existing Helm releases +- `helm test [release-name]` - tests a release +- `helm get [release-name]` - shows the contents of `.yaml` files that make up the release +- `helm status [release-name]` - shows the status of a named release +- `helm delete [release-name]` - deletes a release from Kubernetes + +The full list of the Helm commands is available in the [Helm documentation](https://docs.helm.sh/helm/). +You can also use the `helm --help` command. + +### Use kubectl + +To check if everything runs correctly, use kubectl: +`kubectl get pods -n kyma-integration` +`kubectl get services -n kyma-integration` + +### Examples + +Follow the **Running a new Application Connector on Minikube** tutorial to learn how to get a new Application Connector running on Minikube. diff --git a/docs/application-connector/docs/011-details-ac-security.md b/docs/application-connector/docs/011-details-ac-security.md new file mode 100644 index 000000000000..9e15456fa274 --- /dev/null +++ b/docs/application-connector/docs/011-details-ac-security.md @@ -0,0 +1,30 @@ +--- +title: Application Connector security +type: Details +--- + +To provide maximum security, the Application Connector uses TLS protocol with Client Authentication enabled. As a result, whoever wants to connect to the Application Connector must present a valid client certificate, which is dedicated to a specific Remote Environment. In this way, the traffic is fully encrypted and the client has a valid identity. Kyma representatives generate client certificates. + +### Disable SSL certificate verification + +You can disable the SSL certificate verification in the communication between Kyma and a Remote Environment to allow Kyma to send requests and data to an unsecured Remote Environment. Disabling the certificate verification can be useful in certain testing scenarios. + +>**NOTE:** By default, the SSL certificate verification is enabled when sending data and requests to every Remote Environment. + +* Disable SSL certificate verification for communication between Kyma and an existing Remote Environment + + - Edit the `ec-default-gateway` Deployment in the `kyma-integration` Namespace. Run: + ``` + kubectl -n kyma-integration edit deployment ec-default-gateway + ``` + - Edit the Deployment in Vim. Select `i` to start editing. + - Find the **skipVerify** parameter and change its value to `true`. + - Select `esc`, type `:wq`, and select `enter` to write and quit. + +* Install a new Remote Environment with the certificate verification disabled + + Disable the certification by adding the `--skipVerify=true` flag to the `helm install` command. This is an example of a command that installs a new Remote Environment with SSL certificate verification disabled: + + ``` + helm install --name {remote-environment-name} --set deployment.args.sourceType=commerce --set global.isLocalEnv=false --set global.domainName={domain-name} --namespace kyma-integration ./remote-environments --skipVerify=true + ``` diff --git a/docs/application-connector/docs/012-details-serviceclass-documentation.md b/docs/application-connector/docs/012-details-serviceclass-documentation.md new file mode 100644 index 000000000000..6ab016d4021a --- /dev/null +++ b/docs/application-connector/docs/012-details-serviceclass-documentation.md @@ -0,0 +1,29 @@ +--- +title: Consuming applications through the Service Catalog +type: Details +--- + +To consume the external solutions, referred to as Remote Environments, register them in Kyma. As a result of registering the external solutions, ClusterServiceClasses are created in the Service Catalog. + +### How an external solution is represented in the Service Catalog +This document presents the example referring to the Order API ClusterServiceClass. This class is registered in Kyma with a `targetUrl` pointing to `https://www.orders.com/v1/orders`. The response `id` during the registration is `01a702b8-e302-4e62-b678-8d361b627e49`. + +As a result, the Remote Environment Broker, which provides ServiceClasses to the Service Catalog, contains the class with the following `id`: +``` +re-{remote-environment-name}-gateway-{service-id} +``` +The `{service-id}` is an identifier returned in the process of registration. The `{remote-environment-name}` is the name of the Remote Environment created in Kyma. It represents an instance of the external solution that owns the registered service. Such an `id` in the Service Broker is referred to as a `name` of the ClusterServiceClass in the Service Catalog. +Example `name`: +``` +re-ec-default-gateway-01a702b8-e302-4e62-b678-8d361b627e49 +``` + +### Service consumption + +After provisioning the Order API in the environment using the Service Catalog, you can bind it to your application and consume it by calling the `url` provided during the binding operation. + +The following example shows the Gateway `url` provided for your applications: +``` +re-ec-default-gateway-01a702b8-e302-4e62-b678-8d361b627e49.kyma-integration/orders +``` +The Gateway proxies all your requests to `https://www.orders.com/v1/orders`, in the case of the Order API example. You do not have to obtain the OAuth token in your application to access the API because the Gateway does it for you. diff --git a/docs/application-connector/docs/013-details-api.md b/docs/application-connector/docs/013-details-api.md new file mode 100644 index 000000000000..d91b0d057640 --- /dev/null +++ b/docs/application-connector/docs/013-details-api.md @@ -0,0 +1,7 @@ +--- +title: API +type: Details +--- + +Find the Application Connector API documentation in [this](assets/externalapi.yaml) Swagger file. +Download and open it using [this](https://editor.swagger.io/) Swagger Editor. diff --git a/docs/application-connector/docs/014-details-remote-environment.md b/docs/application-connector/docs/014-details-remote-environment.md new file mode 100644 index 000000000000..808ac98830e4 --- /dev/null +++ b/docs/application-connector/docs/014-details-remote-environment.md @@ -0,0 +1,99 @@ +--- +title: RemoteEnvironment custom resource +type: Details +--- + +## Overview + +This file contains information about the RemoteEnvironment custom resource. +The RemoteEnvironment resource registers a remote environment in Kyma. The RemoteEnvironment resource defines APIs that the remote environment offers, such as Orders API in the EC. As a result, the RemoteEnvironment is mapped to service classes in the Service Catalog. + +## Description + +The RemoteEnvironment **spec** field contains the following attributes: + * **source** which identifies the remote environment in the cluster + * **services** which contains all services that the remote environment provides + * **accessLabel** which labels the environment (Kubernetes Namespace) + +The RemoteEnvironment **spec.services** list contains objects with the following fields: + * **id** is a required unique field that the UI uses to fetch JSON schemas and documents. This filed maps to the **metadata.remoteEnvironmentServiceId** OSB service attribute. + * **displayName** is a required field which maps to the **metadata.displayName** OSB service attribute. It is normalized and mapped to the **name** field from the [OSB Service object specification](https://github.com/openservicebrokerapi/servicebroker/blob/v2.12/spec.md#service-objects). + * **longDescription** is a required field which maps to the **metadata.longDescription** OSB service attribute. + * **providerDisplayName** is a required field which maps to the **metadata.providerDisplayName** OSB service attribute. + * **tags** is an optional field which maps to the **tags** OSB service attribute. Tags provide a flexible mechanism to expose a classification, attribute, or base technology of a service. + * **entries** is a field that contains information about APIs and events. This is a collection which must contain at least one element, + and at most one element of the API type and one of the event type. An API entry must contain **gatewayUrl** and **accessLabel** fields. **accessLabel** must be unique for all the services of all RemoteEnvironments. + +The OSB Service contains one default plan. + +## Example + +This is an example of the RemoteEnvironment custom resource: + +```yaml +apiVersion: remoteenvironment.kyma.cx/v1alpha1 +kind: RemoteEnvironment +metadata: + name: re-prod +spec: + source: + environment: "production" + type: "commerce" + namespace: "com.github" + description: "RE description" + accessLabel: "re-access-label" + services: + - id: "ac031e8c-9aa4-4cb7-8999-0d358726ffaa" + + displayName: "Promotions" + longDescription: "Promotions APIs" + providerDisplayName: "Organization name" + + tags: + - occ + - Promotions + + entries: + - type: API + gatewayUrl: "http://promotions-gateway.production.svc.cluster.local" + accessLabel: "access-label-1" + targetUrl: "http://10.0.0.54:9932/occ/promotions" + oauthUrl: "http://10.0.0.55:10219/occ/token" + credentialsSecretName: "re-ac031e8c-9aa4-4cb7-8999-0d358726ffaa" + - type: Events +``` + +This Remote Environment is mapped to the OSB Service: + +```json +{ + "name": "ac031e8c-9aa4-4cb7-8999-0d358726ffaa", + "id": "ac031e8c-9aa4-4cb7-8999-0d358726ffaa", + "description": "Promotions APIs", + "metadata": { + "displayName": "Promotions", + "longDescription": "Promotions APIs", + "providerDisplayName": "Organization name", + + "labelsRequiredOnInstance": "access-label-1", + "remoteEnvironmentServiceId": "ac031e8c-9aa4-4cb7-8999-0d358726ffaa", + "source": { + "environment": "production", + "type": "commerce", + "namespace": "com.github" + } + }, + "tags": ["occ", "promotions"], + + "plans":[ + { + "name": "default", + "id": "global unique GUID", + "description": "Default plan", + "metadata": { + "displayName": "Default" + } + } + ] +} +``` diff --git a/docs/application-connector/docs/015-details-one-click-configuration.md b/docs/application-connector/docs/015-details-one-click-configuration.md new file mode 100644 index 000000000000..c90896bf82d8 --- /dev/null +++ b/docs/application-connector/docs/015-details-one-click-configuration.md @@ -0,0 +1,94 @@ +--- +title: Automatic connection configuration +type: Details +--- + +## Overview + +Kyma Application Connector allows to authenticate and securely communicate with different systems. Kyma provides an easy way to set up these external solutions with the mechanism for automatic connection configuration. + +## Flow description + +Automatic configuration flow is presented in following diagram: +![Automatic Configuration Flow](./assets/002-automatic-configuration.png) + +This example assumes that the new Remote Environment already exists and it is in the `disconnected` state, which means that there are no solutions connected to it. + +On the diagram, Admin on the Kyma side and on external system side is the same person. + +1. The admin requests for a token using the CLI or the UI and receives a link with the token, which is valid for a limited period of time. +2. The admin passes the token to the external system, which requests for information regarding the Kyma installation. In the response, it receives the following information: + - the URL to which a third-party solution sends its Certificate Signing Request (CSR) + - URLs of the available APIs + - information required to generate a CSR +3. The external system generates a CSR based on the information provided by Kyma and sends the CSR to the designated URL. In the response, the external system receives a signed certificate. It can use the certificate to authenticate and safely communicate with Kyma. + +## Configuration steps + +Follow these steps to configure the automatic connection between the Kyma's Application Connector and an external system: + +1. Request a token through the terminal. + >**NOTE:** Alternatively, use the UI to request it. + + There is no direct way to request the token from command line. You can do it using `kubectl port-forward` or `kubectl proxy`. + + - Request: + + In the first terminal run: + ``` + kubectl -n=kyma-integration port-forward svc/connector-service-internal-api 8080:8080 + ``` + And send the request in the second one. + ``` + curl -X POST http://localhost:8080/v1/remoteenvironments/{remote-environment-name}/tokens + ``` + - Response: + ```json + { + "url":"https://connector-service.CLUSTER_NAME.kyma.cluster.cx/v1/remoteenvironments/{remote-environment-name}/info?token=example-token-123", + "token":"example-token-123" + } + ``` + +2. Use the provided link to fetch information about the Kyma's URLs and CSR configuration. + - Request: + ``` + curl https://connector-service.CLUSTER_NAME.kyma.cluster.cx/v1/remoteenvironments/{remote-environment-name}/info?token=example-token-123 + ``` + - Response: + ```json + { + "csrUrl": "https://connector-service.CLUSTER_NAME.kyma.cluster.cx/v1/remoteenvironments/{remote-environment-name}/client-certs?token=example-token-456", + "api":{ + "metadataUrl": "https://gateway.CLUSTER_NAME.kyma.cluster.cx/{remote-environment-name}/v1/metadata/services", + "eventsUrl": "https://gateway.CLUSTER_NAME.kyma.cluster.cx/{remote-environment-name}/v1/events", + "certificatesUrl": "https://connector-service.CLUSTER_NAME.kyma.cluster.cx/v1/remoteenvironments/{remote-environment-name}", + }, + "certificate":{ + "subject":"OU=Test,O=Test,L=Blacksburg,ST=Virginia,C=US,CN=ec-default", + "extensions": "", + "key-algorithm": "rsa2048", + } + } + ``` + +3. Use values received in the `certificate.subject` field to create a CSR. After the CSR is ready, make the following call: + - Request: + ``` + curl -H "Content-Type: application/json" -d '{"csr":"BASE64_ENCODED_CSR_HERE"}' https://connector-service.CLUSTER_NAME.kyma.cluster.cx/v1/remoteenvironments/{remote-environment-name}/client-certs?token=example-token-456 + ``` + - Response: + ```json + { + "crt":"BASE64_ENCODED_CRT" + } + ``` + + Use the following command to generate the example CSR: + ``` + openssl req -new -out test.csr -key hmc-default.key -subj "/OU=OrgUnit/O=Organization/L=Waldorf/ST=Waldorf/C=DE/CN=ec-default" + ``` + +4. The `crt` field contains a valid base64-encoded PEM block of a certificate signed by the Kyma's CA. + +5. The external system can now use the created certificate to securely authenticate and communicate with Kyma's Application Connector. diff --git a/docs/application-connector/docs/016-details-passing-header-with-access-token.md b/docs/application-connector/docs/016-details-passing-header-with-access-token.md new file mode 100644 index 000000000000..d5a28f5ebba1 --- /dev/null +++ b/docs/application-connector/docs/016-details-passing-header-with-access-token.md @@ -0,0 +1,18 @@ +--- +title: Passing a header with the access token +type: Details +--- + +# Passing a header with the access token + +## Overview + +The Application Connector supports passing the access token directly in the request. + +## Passing the access token + +If the user is already authenticated to the service deployed on Kyma, the access token can be passed via a custom `Access-token` header. If the Application Connector detects that the custom header is present, instead of obtaining a new token, it passes the received one as a `Bearer` token in the `Authorization` header. + +## Example + +Find the example of passing the EC access token to the Application Connector using Lambda in the [`examples`](https://github.com/kyma-project/examples/tree/master/call-ec) repository. diff --git a/docs/application-connector/docs/030-examples-ac.md b/docs/application-connector/docs/030-examples-ac.md new file mode 100644 index 000000000000..249177a542d7 --- /dev/null +++ b/docs/application-connector/docs/030-examples-ac.md @@ -0,0 +1,113 @@ +--- +title: Examples +type: Examples +--- + +This is a tutorial on how to get a new Application Connector running on Minikube. + +To integrate a new Remote Environment marked as `Production`, you can use the following values: +* **sourceEnvironment** = `production` +* **sourceType** = `marketing` +* **sourceNamespace** = `organization.com` + +Start with: + +``` bash +helm install --name hmc-prod --set deployment.args.sourceType=marketing --set deployment.args.sourceEnvironment=production --set global.isLocalEnv=true --set service.externalapi.nodePort=32002 --namespace kyma-integration ./remote-environments +``` + +Your output looks like this: +``` bash +NAME: hmc-prod +LAST DEPLOYED: Fri Apr 20 11:25:44 2018 +NAMESPACE: kyma-integration +STATUS: DEPLOYED + +RESOURCES: +==> v1/Role +NAME AGE +hmc-prod-gateway-role 0s + +==> v1/RoleBinding +NAME AGE +hmc-prod-gateway-rolebinding 0s + +==> v1/Service +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +hmc-prod-gateway-external-api NodePort 10.108.126.243 8081:32002/TCP 0s +hmc-prod-gateway-echo ClusterIP 10.100.94.12 8080/TCP 0s + +==> v1beta1/Deployment +NAME DESIRED CURRENT UP-TO-DATE AVAILABLE AGE +hmc-prod-gateway 1 1 1 0 0s + +==> v1alpha1/RemoteEnvironment +NAME AGE +hmc-prod 0s + +==> v1/Pod(related) +NAME READY STATUS RESTARTS AGE +hmc-prod-gateway-67469769c8-6lgjl 0/1 ContainerCreating 0 0s + + +NOTES: +------------------------------------------------------------------------------------------------------------------------ + +Thank you for installing Gateway helm chart for Kubernetes version 0.0.1. + +To learn more about the release, see: + + $ helm status hmc-prod + $ helm get hmc-prod + +------------------------------------------------------------------------------------------------------------------------ + +``` +Running `helm status hmc-prod` shows a similar output with the most recent status of the release. + +If you run `helm list`, you can see your release among the others: +``` bash +cluster-essentials 1 Wed Apr 18 07:50:01 2018 DEPLOYED kyma-cluster-essentials-0.0.1 kyma-system +ec-default 1 Wed Apr 18 07:57:50 2018 DEPLOYED gateway-0.0.1 kyma-integration +hmc-default 1 Wed Apr 18 07:57:36 2018 DEPLOYED gateway-0.0.1 kyma-integration +istio 1 Wed Apr 18 07:50:04 2018 DEPLOYED istio-0.5.0 istio-system +prometheus-operator 1 Wed Apr 18 07:51:50 2018 DEPLOYED prometheus-operator-0.17.0 kyma-system +hmc-prod 1 Fri Apr 20 11:25:44 2018 DEPLOYED gateway-0.0.1 kyma-integration +core 2 Wed Apr 18 07:56:56 2018 DEPLOYED core-0.0.1 kyma-system +``` + +Use kubectl commands to see the Kubernetes resources associated with your release: + +``` +kubectl get pods -n kyma-integration +``` + +``` bash +NAME READY STATUS RESTARTS AGE +ec-default-gateway-5b77fdf7b5-rx64m 2/2 Running 3 2d +hmc-default-gateway-f88b58978-75dkb 2/2 Running 3 2d +hmc-prod-gateway-67469769c8-6lgjl 1/1 Running 0 1m +``` + +``` +kubectl get services -n kyma-integration +``` + +``` bash +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +ec-default-gateway-echo ClusterIP 10.96.212.205 8080/TCP 2d +ec-default-gateway-external-api NodePort 10.101.245.196 8081:32000/TCP 2d +hmc-default-gateway-echo ClusterIP 10.101.68.223 8080/TCP 2d +hmc-default-gateway-external-api NodePort 10.96.215.1 8081:32001/TCP 2d +hmc-prod-gateway-echo ClusterIP 10.100.94.12 8080/TCP 1m +hmc-prod-gateway-external-api NodePort 10.108.126.243 8081:32002/TCP 1m +``` + +If you are done with the release, you can delete it by following this example: +``` +helm delete hmc-prod --purge +``` + +```bash +release "hmc-prod" deleted +``` diff --git a/docs/application-connector/docs/assets/001-application-connector.html b/docs/application-connector/docs/assets/001-application-connector.html new file mode 100644 index 000000000000..8fe3b29324fd --- /dev/null +++ b/docs/application-connector/docs/assets/001-application-connector.html @@ -0,0 +1,12 @@ + + + + +001-application-connector.html + + + +
    + + + diff --git a/docs/application-connector/docs/assets/001-application-connector.png b/docs/application-connector/docs/assets/001-application-connector.png new file mode 100644 index 0000000000000000000000000000000000000000..a1e692eb01a90176f94d1d3b9ab94192c566629d GIT binary patch literal 44802 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 D5r}MI literal 0 HcmV?d00001 diff --git a/docs/application-connector/docs/assets/002-automatic-configuration.html b/docs/application-connector/docs/assets/002-automatic-configuration.html new file mode 100644 index 000000000000..9142ea903503 --- /dev/null +++ b/docs/application-connector/docs/assets/002-automatic-configuration.html @@ -0,0 +1,12 @@ + + + + +002-automatic-configuration.html + + + +
    + + + diff --git a/docs/application-connector/docs/assets/002-automatic-configuration.png b/docs/application-connector/docs/assets/002-automatic-configuration.png new file mode 100644 index 0000000000000000000000000000000000000000..8d93be7f7bdff28086504f199b1cea02c0925ccc GIT binary patch literal 78131 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 literal 0 HcmV?d00001 diff --git a/docs/application-connector/docs/assets/externalapi.yaml b/docs/application-connector/docs/assets/externalapi.yaml new file mode 100644 index 000000000000..8c595b179cef --- /dev/null +++ b/docs/application-connector/docs/assets/externalapi.yaml @@ -0,0 +1,434 @@ +openapi: '3.0.0' +info: + version: '1.0.0' + title: 'Kyma Gateway Metadata API' +tags: +- name: 'service metadata' + description: 'Service registering API and events catalog.' +paths: + /v1/metadata/services: + post: + tags: + - 'service metadata' + summary: 'Registers a new service' + operationId: 'registerService' + requestBody: + description: 'Service object to be registered' + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceDetails' + responses: + '200': + description: 'Successful operation' + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceId' + '400': + description: 'Bad request' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + '500': + description: 'Internal server error' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + get: + tags: + - 'service metadata' + summary: 'Gets all registered services' + operationId: 'getServices' + responses: + '200': + description: 'Successful operation' + content: + application/json: + schema: + type: 'array' + items: + $ref: '#/components/schemas/Service' + '500': + description: 'Internal server error' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + /v1/metadata/services/{serviceId}: + get: + tags: + - 'service metadata' + summary: 'Gets a service by service ID' + operationId: 'getServiceByServiceId' + parameters: + - in: 'path' + name: 'serviceId' + description: 'ID of a service' + required: true + schema: + type: 'string' + format: 'uuid' + responses: + '200': + description: 'Successful operation' + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceDetails' + '404': + description: 'Service not found' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + '500': + description: 'Internal server error' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + put: + tags: + - 'service metadata' + summary: 'Updates a service by service ID' + operationId: 'updateService' + requestBody: + description: 'Service object to be stored' + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceDetails' + parameters: + - in: 'path' + name: 'serviceId' + description: 'ID of a service' + required: true + schema: + type: 'string' + format: 'uuid' + responses: + '204': + description: 'Successful operation' + '404': + description: 'Service not found' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + '500': + description: 'Internal server error' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + delete: + tags: + - 'service metadata' + summary: 'Deletes a service by service ID' + operationId: 'deleteServiceByServiceId' + parameters: + - in: 'path' + name: 'serviceId' + description: 'ID of a service' + required: true + schema: + type: 'string' + format: 'uuid' + responses: + '204': + description: 'Successful operation' + '404': + description: 'Service not found' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + '500': + description: 'Internal server error' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataErrorResponse' + /v1/health: + get: + summary: 'Returns health of a service' + operationId: 'getHealth' + tags: + - 'health' + responses: + '200': + description: 'The service is in a good health' + /v1/events: + post: + summary: 'Publish an event' + operationId: 'publishEvent' + tags: + - 'publish' + requestBody: + description: 'The event to be published' + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PublishRequest' + responses: + '200': + description: 'The event was successfully published' + content: + application/json: + schema: + $ref: '#/components/schemas/PublishResponse' + '400': + description: 'Bad Request' + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + '401': + description: 'Authentication failure' + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + '403': + description: 'Not authorized' + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + '500': + description: 'Server error' + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' +components: + schemas: + ServiceId: + type: 'object' + properties: + id: + type: 'string' + format: 'uuid' + ServiceDetails: + type: 'object' + properties: + provider: + type: 'string' + name: + type: 'string' + description: + type: 'string' + api: + $ref: '#/components/schemas/Api' + events: + $ref: '#/components/schemas/Events' + documentation: + $ref: '#/components/schemas/Documentation' + required: + - provider + - name + - description + Service: + type: 'object' + properties: + id: + type: 'string' + format: 'uuid' + provider: + type: 'string' + name: + type: 'string' + description: + type: 'string' + Api: + type: 'object' + properties: + targetUrl: + type: 'string' + format: 'uri' + credentials: + $ref: '#/components/schemas/ApiCredentials' + spec: + type: 'object' + description: 'OpenApi v2 swagger file: https://github.com/OAI/OpenAPI-Specification/blob/master/schemas/v2.0/schema.json' + required: + - targetUrl + Events: + type: 'object' + properties: + spec: + description: 'AsynApi file v1: https://github.com/asyncapi/asyncapi/blob/develop/schema/asyncapi.json' + type: 'object' + Documentation: + type: 'object' + properties: + displayName: + type: 'string' + description: + type: 'string' + type: + type: 'string' + tags: + type: 'array' + items: + type: 'string' + docs: + type: 'array' + items: + $ref: '#/components/schemas/Document' + required: + - displayName + - description + - type + Document: + type: 'object' + properties: + title: + type: 'string' + type: + type: 'string' + source: + type: 'string' + required: + - title + - type + - source + ApiCredentials: + type: 'object' + properties: + oauth: + $ref: '#/components/schemas/OAuth' + required: + - oauth + OAuth: + type: 'object' + properties: + url: + type: 'string' + format: 'uri' + clientId: + type: 'string' + clientSecret: + type: 'string' + required: + - url + - clientId + - clientSecret + MetadataErrorResponse: + type: 'object' + properties: + code: + type: 'integer' + error: + type: 'string' + PublishRequest: + type: object + description: A Publish request + properties: + event-type: + description: Type of the event. + type: string + format: hostname + pattern: '^[a-zA-Z]+([_\-\.]?[a-zA-Z0-9]+)*$' + example: 'order.created' + event-type-version: + description: The version of the event-type. This is applicable to the data payload alone. + type: string + pattern: '^[a-zA-Z0-9]+$' + example: 'v1' + event-id: + description: Optional publisher provided ID (UUID v4) of the to-be-published event. When omitted, one will be automatically generated. + type: string + pattern: '^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$' + example: '31109198-4d69-4ae0-972d-76117f3748c8' + event-time: + description: RFC 3339 timestamp of when the event happened. + type: string + format: date-time + example: '2012-11-01T22:08:41+00:00' + data: + $ref: '#/components/schemas/AnyValue' + required: + - event-type + - event-type-version + - event-time + - data + PublishResponse: + type: object + description: A Publish response + properties: + event-id: + type: string + description: ID of the published event + pattern: '^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$' + example: '31109198-4d69-4ae0-972d-76117f3748c8' + required: + - event-id + AnyValue: + nullable: false + description: Can be any value but null. + APIError: + type: object + description: API Error response body + properties: + status: + type: integer + description: >- + original HTTP error code, should be consistent with the response HTTP code + minimum: 100 + maximum: 599 + type: + type: string + description: >- + classification of the error type, lower case with underscore eg + validation_failure + pattern: '[a-z]+[a-z_]*[a-z]+' + message: + type: string + description: descriptive error message for debugging + moreInfo: + type: string + format: uri + description: link to documentation to investigate further and finding support + details: + type: array + description: list of error causes + items: + $ref: '#/components/schemas/APIErrorDetail' + required: + - status + - type + APIErrorDetail: + description: schema for specific error detail + type: object + properties: + field: + type: string + description: >- + a bean notation expression specifying the element in request + data causing the error, eg product.variants[3].name, this can + be empty if violation was not field specific + type: + type: string + description: >- + classification of the error detail type, lower case with + underscore eg missing_value, this value must be always + interpreted in context of the general error type. + pattern: '[a-z]+[a-z_]*[a-z]+' + message: + type: string + description: descriptive error detail message for debugging + moreInfo: + type: string + format: uri + description: >- + link to documentation to investigate further and finding + support for error detail + required: + - type \ No newline at end of file diff --git a/docs/application-connector/docs/assets/remote-environment-prod.yaml b/docs/application-connector/docs/assets/remote-environment-prod.yaml new file mode 100644 index 000000000000..9ebea700c1c4 --- /dev/null +++ b/docs/application-connector/docs/assets/remote-environment-prod.yaml @@ -0,0 +1,61 @@ +apiVersion: remoteenvironment.kyma.cx/v1alpha1 +kind: RemoteEnvironment +metadata: + name: example-prod +spec: + # source identifies remote environment in the cluster + source: + environment: "prod" + type: "commerce" + namespace: "com.org" + + description: "Example prod instance" + + # accessLabel is used to label namespace + accessLabel: "access-label-ec-prod" + + # list of services + services: + # mandatory, unique ID + - id: "ac031e8c-9aa4-4cb7-8999-0d358726ffaa" # required, uuid, immutable + + # required, mapped to metadata.displayName, used by catalog UI + displayName: "Promotions" + # required, mapped to metadata.longDescription, used by catalog UI + longDescription: "Promotions APIs" + # required, maps to metadata.providerDisplayName, provider name in catalog UI + providerDisplayName: "Promotions Provider" + + # mapped to tags, not required + tags: + - occ + - promotions + + # Entries defines, what can be enabled by activation (instantiating and/or binding) of the service + entries: + # type API defines remote API + - type: API + # the url, which can be used by service/lambda to make a call to remote API + gatewayUrl: "http://promotions-gateway.production.svc.cluster.local" + # used in istio rules, generated by Gateway, must be unique in k8s cluster + accessLabel: "access-label-1" + # the url, which points to the solution instance. It is used by Kyma Gateway. + targetUrl: "http://10.0.0.54:9932/occ/promotions" + # the url, which points to oauth. It is used by Kyma Gateway. + oauthUrl: "http://10.0.0.55:10219/occ/token" + # the name of a secret, which contains a pair of client id and client secret for oAuth. It is used by Kyma Gateway. + credentialsSecretName: "re-ac031e8c-9aa4-4cb7-8999-0d358726ffaa" + - type: Events + + # second service provided by remote environment + - id: "48ab05bf-9aa4-4cb7-8999-0d3587265ac3" + displayName: "Orders" + longDescription: "Orders API" + providerDisplayName: "Orders Provider" + entries: + - type: API + gatewayUrl: "http://orders-gateway.production.svc.cluster.local" + accessLabel: "access-label-2" + targetUrl: "http://10.0.0.54:7732/occ/orders" + oauthUrl: "http://10.0.0.55:23238/occ/token" + credentialsSecretName: "re-48ab05bf-9aa4-4cb7-8999-0d3587265ac3" diff --git a/docs/application-connector/docs/assets/remote-environments.zip b/docs/application-connector/docs/assets/remote-environments.zip new file mode 100644 index 0000000000000000000000000000000000000000..e09afcb85fe075123e4ed48148ea6f0ffa2924d5 GIT binary patch literal 4662 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 bL1ZI4m(tXf@bJ$*lA>=AI^*F`oW1=Ykhz(S literal 0 HcmV?d00001 diff --git a/docs/assets/crd/mapping-prod.yaml b/docs/assets/crd/mapping-prod.yaml new file mode 100644 index 000000000000..45799c3ff670 --- /dev/null +++ b/docs/assets/crd/mapping-prod.yaml @@ -0,0 +1,6 @@ +apiVersion: remoteenvironment.kyma.cx/v1alpha1 +kind: EnvironmentMapping +metadata: + # Remote Environment "ec-prod" is enabled in "production" environment + name: ec-prod + namespace: production \ No newline at end of file diff --git a/docs/assets/rebase.gif b/docs/assets/rebase.gif new file mode 100644 index 0000000000000000000000000000000000000000..22ccb0f7a9a49bea6f7f978f5beaf21aa6d3e6a4 GIT binary patch literal 149302 zcma&OcT`hZ6gB?Rdk7&3p~X-PEodmxL?`s1h@ojv(13_^LlFVdNkVTD5D*YB6cG^> z6;Tm20hFqsV#N{=u`7ygMrZg1ar}L4t?&Dn#dtU9cCp8uSSO z08{Jq(sJ>>vOkOWfz51*_AXme_e2Ua(3UH0)^AS8&eL`EGF;`mJtK#`X{%ps!p{7{ z)WRZelz2iF>U{ zb7@{D#)IEFD6YT%J|`53O;TghvvYFu@(Y$Tz@Xw1 zXl7yg{))<~NJdF*Y3hOchQ>opD~f81r74GxpJ+R|zorG$nsw@Y=Y_87#b-LoF81_Z zxyos8IUC(~?fS@Q^5t3ywfn}s`wvbH9K1WOn3ya)GWg`_i%+9a@_m33h)aM`Mw<2@nIAX`S38V=M5cOhqEv_0ar z#QTiQ=UQdFQ-@Xyn41TykKka}4%i;NKRj^y`SF^S>1Z=pV)KoLN*8+2VClo!CT_#3 znpJ07ZXe~Hj(jwD=IGrM{GL3W)g7((Plw-X2^#7+Hhxy{bg*Xi+2fBcB+tHjG<5dF zlgmN~hUR&$ZK5Yf&2-c7xsy-(B>JmsJE|O-=X~0l&i0qLnthLM zy4HE-_1(5DL$zxzbi5rum+<=WwF_t8Kar(l7I}4@`|z}XpXui7UFT%u28sbS3%S=vc2CbH>SMw2-P<>8aLMorC= zdCZO%lliQxMo$Z@?u0)rw4H8#y4T^$i>F0wxbc+4g%UAU%wZgvD)F>_Ikk`LX8f$w zZ(YQ*GG5q`XXSxOFQ4t_XBkgdgqBB4SB5tonXZcNcsYGQaMk#Eb>f|f=QYXGN1oTF zeR=u(pb&2ILYhH|d{LLfXn9dzVEyVvgT&3`Wn<~O$d`vI!dhN7RVTfAd03id@~XM9 zJo43%=BAccEv+4|UL9?_YVx|Z{Z8cTW9Oz@ULWuJ^6K>o8QkixxU(?{Q5`tjxU`^x}=IU|FrN6*NShOIN*SerL9Jp^~=hhEb9=nq$@+gd+d z)!zB$Lmxex`LW+%fAq%zqrmr+F=vOZ?ncjEvwhw=d)?vdo7oXI!tB$i zi+aqb8yv%9pKf~Ey!~{G>u&aW%x`_n=i9t(7oCuR?q5*YK7a-=;Q$9~?D8+hQr8%z;L3N$TZJC)h-&EwXLUDJ*u0N%*3z zdb$^oku!z%q(EZ|9;t@|XMxdBwd^dIM!}x^C>TVCrcQt$G#Gd^gd(8jsj8|`88En( zDw%=KE@zM_TAHCK1cX3r#%OA5>%a`j5R@GmP9=gN=!;eubg!{`lwBeXk7!!+b`(i_*ZnS`Nk-EN55c@r`w2N)3{Ui;0U^n^J` zG=jrt%T9`*Dq40cZP`M=9g5PMBb@`Tr9)xm79=)T7p#HC5V#7$aqu!IE-=NG)e5Aq zC)uXXy2y=85GXV^MlN_#8JPlGboKZh+|_Xn z{$|cMd*>kHv6ukiVu|iy2qua$6-q!(4Is*qiL5qRGYXAj^nc_Ai+xnESW+^#?23!- zpB8&$NWcw26+Wz+~$*IDycDcrXZo%nVvdtI!`uNywHDQKU3~5jQ1hT_#!m z$Q@UZYDTzIiLrweq}idt7QwyWSvo?70k)voG$JT;yibCIS%Jt}-}z2C0gM z0{g{b;RZl$ydjAlj>T5Z=$OC+Z9vHm>fKu)hJ^yNth3m=1g24HbpTA}bLGpOFK($W zJ}tU807uakoA;p&toOsD762&l3$j~y#FXXlU?Qj!<-InA3r3939Aj z%8=VYcu#s$`NpQCjGRdFQ%y9sIaC~zZ8zEkQTH=>4!ZFzR5!~CH`)Rr5E7z6I!3Z$ z2E}^a@D9eO{A;j9Yq;ic6d&@mU?OCPM(Yye3S(G-zXGsXbkSto3a)q~4{}#aXG13> zFGZ9qMjUmGfxH)aP`ETW0kOyiXK9M_5 zJw98bh;{I+l?0T;J$$|B5xl)d2<$KrQcDl`X8}WQdFGCDr%kJ`E(WMNy0k|yHeIV^ z)q^ac89Fj;U;k#W1qezT7(R7GE5~D?$!X6TFGJ4wMuQb^3PaCKibDkeBKyJpq?;A7 zZNlk}2oV;nVkOI8s>L^A-1#1u{fhf{+BPw-Jw81zv9M}cIqLOl+h>rwPYRsinmNLJ z?QLgNjfcSS(QfF5GbdF1n?O3}h>8#r`mxMK^cH*@qgdAXT_-mt@AU1Ju?Ujr_FkGn z!fmLL3^&3FT}^SU*$)VX!H3w8f1YAJy!Z#aIpH=m{+)X3bdW=JuDMK24GeFC>uRd# zUUU-KRrgf9i+B%1`94f$9-?2<*qU!l2cq|Om(QJ|BX<9#3x}VxCe;becJ~(_#NX&K z!EHKbMK9&zcTnA+fr?}fu>ysqv%t1D;@aYUqPQYTB5-Vd{K*58Z~D(A-_SQQ7A%UJ zlnIT>B?+doI*(o>O_WjYDpu(|q}A-P?QfmKjv26DQwT#ed^8nbkqfCfhf`Z~g-o@j z!019O7Agjqr@80W-2^NOpdeT{mhR%;t70cDTjDWZvC49}<8d~IZ?3O>9F-FX1RPr`(Q3i`19w|-w&IE}WPHp0FlUiolj*hrtw#EzVrppg z>#DF+gO1p{q@%-Iy=H=CNt8Y?{^K0Oy0^k%F6!;To0Y`uA;ux(>x7v&G>kFkym&o^_=%^DD0T;p?C`{uM4T?9BZ;J;X~jp6-8pIy?;)|{GH$pl0gsV)Sk zxK;75nkby#XcqtQu>YOE@~FB@sTO$D@&Uw>6ai?#JwdwOvVsjpd9L8J8<(Z#?1KV2 z|K9yaqUxR-q!+K$KIg7NSjbyS9Dt4`?SmWE@N00=B&~w*+m~x~`%|qHradfw|3*H=k}yseFg3-}UH)xVQ7^HJ`-E_MmqicRN)B zvUfU8KF$ywvU^v^r(P_+;-z3Vse1eGI+Gu?>)K-2tj>v@Z3Cs^ zOj6&1*K3t>U@mXIXn(2|RtNtRy!@v-q+!)AnBwi|Pg`rVN{? zkP$@2#y~xcPy?C?5;)jpF=?e`G%B*P3ab0_7Z3nEz}&9<1u8#(0Z(}@kf|b_q#LHf zTUe%y(r?`&tY=x_34L+j*Dc zBF>_G`BA${_Bnib*j9En`Cp+DEF+V;0A>Y{0cO%~uwW$^K&}=McvIo^hGTh4hrw5T zM+HnuHtfVUc_I^%-yQ5z7n<`dQL&ef}vMmWUt4(+o7dVdkaD5&YJgOShDb;=|yjCFV*oqx_tA(*W z(zjxNjO$U9mwUj6og48f*Mqse1~*#ItxJReRyrMlU%cDK0sNV zYvj=&P1)=y30V+A@^|*kaCsOtCFdhA__kB_tzKchC(Se-Ojd@ILsf?Ji!rEjC&(=L zox~s|s*nLSLKi zfz={9d!DxOa)HCGnbgbR!4M+yaC)+wSLOz~QAu5E>at~0d6q1A>>XK=ym+Ky%n^;? z5f5!EHlGt~XFk2ZFNKv{v}^tHU17oY!rcT~R(2LLFaH-Laz4na{~1yrDiSzN^-JeD zE4cnP>S-fwB-0CO@u4OZ$Osd&4bqH2`5@zSet)RI#Bui}en*j2A-Z@qEMrSoF1jlC zL%x19D%8MiWVVSuEP6TnF zijlKlPHy1uNj2N;D-;=;!)XPDG^C^$`HNA^JaY4I2un_#<~s-zPIz@6okT; zXh#=W@&_xhJ#&j4&Dv*KP70j1w@AtDzS3XV=y`72ekVF$el<~)+`9bc_RPa7@USKk zaP&>7TL(xb;V%D(dnS);^L0DW;YH}l_{=MVi{-`BK<3q}qbo4x&~sU@x`HA0H&;wn zzFi>{DbX^se&rZ8-vG}3M%(Z+$I1rq9~x6{t?s3^K&$+qn9TD$I3csQ(di`eGE5|k z`Wx@K;TnPz>*~GzeG&(Gz*^V`5;3zamcgAfN7`46yvf|VFWHMGP8TDyvVXz-Y_FGo zC)VjF92A_Yr1XEU{6l`HCf_${f2`_}hI=%6?^|bJsg?P_gm%2cXQpC=mXTUW&rs?t z$^#h^KmyDwzaPt==SvmZZR}?dQOo*S4?E~1VUg=`oag+2E2BM#>ViNvt^pgAkWbImZT)m*Ic5M1fg)2RoRol5dWqiHEZq9ZaU=LDR_dBTtg};bG z0%`#B+V9kb&l9DpB#QGt5OwJ=p=~eReL^PZm4d_S_%mFlliSBOThB|i&;&a_aoM(3 zegHt&^x?!PhoGG6q#O%F;zqm?ZbDvY!@;JI7U90G_m%XFHgcn`9)>=!sf`FdcE z{RR(N06#1%Zpf|GGF85(ZZ%)4a>DV692Z|13~G*U6b8@>L{$B5`moRW&Cdwu9X7YA z){QnRQcuEqoRiq=u}eb5q6bK7!C~m$!Eb!6UCZ59+Uyq7lpO%Mx9Ast(D~&u{u_3_ z)v=W^6QUPlo*BsP_sv<#)GvgcpeP88a*&Iz`4MHzx*SP;?d5dIy6>>;pojZk_vjFN zws}q|gI!aPRml0RXU1C*HF38S4ARVLnOT|tW2>V8%*o%>&X=MR@$k@Znx=b^&8PiW@n zq(5zYImImj)9>-=Mt!9Qrycy((UB&-Y~t6Zl{Vj+I+8v679S$ zwm`6HMD%}+{THpuEysegFbmS1R~GoX^P=GYsw}*JEtC=ze`yr`rz|u_8ZlSRi-JS0 z_MHl?W#KrbDA=BQZBy#iXHH#K+sEI3>)Nw4Yu~FhkvKggb1~Ugg}p~#n5efmt7Knk z7{Y9SDMkN);TD%a4U5Z^qF`iYq?$#IZ-M0S$y2A>QxC>96rQ=zbpf4NRzxI5d4yM7_{Z#m^&i)pqC?#!=af#p||~q%t?*(8ogP?b_EL z)W=;*ogTUeLp5?9T4OX}P{Jy2C}0NlHHrw2w2T&-s_2LklXl`#SUPck;1PxK2p9}Z zHPF;BLXePX!hQx3y$P)*BvMp0*W<`IRdOt}OI58`u;!?p zlqUmQkqejuR_408>BvR?Oez=Tm>H#F7tZ6dahX+EdUcB8q!4TX#RnP*>5!FpBEfT% zZSp`vjX-o@xt4;*Q92K`n>Q%*;oeBi+{x4lu@*v$M!LSm_cj>v*#zM5Wj!Kj0^l#* zG3gz#^6(lL+bxdZ>hn~4>8H`s67*^#J(pMPuq$BXYC^QFFW=cug=)c}ADtG#ZPZ51 z+D2dEcc_rs1_>J-(-D|nQwNstvv7)3&(Wy>`kt57-S*C*E42&6Wm!$(%88raf2e3b z^xt5hNMQln)jYWcb8vjh=-!yEk}Nr}bJ?>s3>x(-7kazc&=E#y7i+~BZC#&@N=}wt z$z|m{6YfN_*diF(OGt)PEE~;3y{rL5aFpBE$3`1k>oshme{6C36Jjr}LN$^5*P>5V zvS=Ab1usCL&Psvfb|27lk)SsYnOTvU!x@4Ytyv$G7ufq;oL}SpNx0=@xck9f4#bFV zkhGePE@$xRAl)zC01!5tb7fHkgOwwORyQNDqHW*Wd%>KFggmhOF*+#7FmV*LlJFzD z+wTf8!4(u%B^1U|a8ib^(V@=(7|If^J!E=ap;v%ChfrOI3XJpD(eUdGg=}LI`lL+0 z@ctZdY;n9Q2qj=;vIx-%EFHyTH)v5E_#i}6GQX;U>e&_x@glGsEp;+lz%GPPupjix z*qKPkg9>=m4)WNna4)T(pOFU#e#lO&?lo?0@BMciIH>-qyREf?9B$+$|K!n(07kO` zus&Y^+nsrwX^A%7S0At6HHhxNwsdhvDQ3yR=8pp#KUPt%nY4N?>MZ;bn0TF~z6Nq) z^|Ln?aPQdyr_s`}YGS@KL8(cH?vMF$-z;Z^^@HfMZ>7=ZUeAOGK4%g^CXQHjC9pTnL0#u9|Ks!nF8 zSl8K)Fr?e#*k7fMxtnxez~}R~`_yZk1ogtn5zYN*M5}%9h#h_#lSB+!sq7jUVk(WS z>5mAuCTSL!!BG}OH3t~c+Zw5vV2mVa07z}Dz!^vK*kM^7K!n@>DKb6pgv?}M5z@jU zb7Zj%w4F)4yaaZ_0n=^M*Jc9XaZoiKJtSNejb{*b7%J7YTX+hg(iBHTQ>GYd-&Ao( z82p_M5A71Z4OEN(lSwY4ge$@fh&Jto&kaWr*o}l|hx4~*W?)_zB=-xX5NH;YI%k#B z<_KB_Rj2cs6--wc2FCzC&hVdsc**p3KLWfCj8xBBTn{g(gTS;Exu;k%y?Qia3Dd7- zvcfi{c@l1wKNZphgnw03vJ1;kBAI#Qj&q?gr;+HRFF*uoN!vj_|OJI;iq z5FM^TX^=RhZSq(CW-KO`RiOXJ9b;PcEc>pr7(_;UvREH7EH)xPb(zT&qZ7Kp?|$5bpSvDuUd_pl{7ryeh53T zw15Se0(`oVrXw_X2K6%;)hWLVtg4x_&ktZZ!gRv<2g>-4LRaYMz@!M?+cY!HDrL|3 zV#Gc>w=L#pc6dP{?7_n*s$}E!KMS!enHepR9c=k0wWd9|XbA zKL~3wwDq~3Xx`;9k(k}WZb6Z)0IcuvynET8FMO#ehd&i;YOBeSXy`o%aJgg8+&l{O z>Ua|1w9)hYH{iwCnZ~LSpZl`*V{-iuD~_G|`~6vO5+6hfsOP^M9+9Ph;m9u6Wow~3 z1mH1JJJ<_a)UE#9OHGCP>kJms}Wlc=XZDRw|sAgc|Zcb)o2;8*+H_e|42a>S8vzx z?gPZHz*J8~J1V(8`l0=_bB`pdpslk1M^OWFIaUYg2YdS2?TZMvJ5wpFy)$KDR-6v3 zUoL8G@;(}H2mv8d0liuqdk^+g&3aQLC@TT};G$BVJY9pFA&4p;av<791MfFU_B9YD zY4^Yn0`5t>z&S$@YO>6`S@mM6HX8&92cG|kqY#o{S(==EJH+k9C(e23kG;tDH0Tz; zk7J=7biaa)JfyanNBw=e!(B#YxNd27g8o+Oay1c zdQC+knJ7bb)Tvz7)S#&uLEx8=BTFN5#k~4!P${);=Qq_Hol04SY5&Gpv_OB)@R9xP zy-r!xsPrzfUisgSB8#NPY}LQTmD`5a=#~acqiM6)uE4O^MDuPpu(!(PMv(Q|P}J7) z*$N~bWq&*OdPuFV&Y^HPrQrOUt<|FHQ|*h**Cvjn8;rpwBAifT>>IkCr|YTL!ZET8 zGF;v-1V(O!zcnzqS#pODA_KJ^>m#A?=z44BVzJ9c)mv2$J~i21GsareS+yU>s_k(t z-0W3;`cOqP2M2KT2gjQ1KOZsXnE32R-)`CPxrJHw%KJ#(?c=GRkGhYx_{dk?IaTG4 z1dp1m9~r#U(c#}3wc}{u^F04^H$NXwhDB{Sq_)o5yHZ3Kyd$7$mtXiy@Bph9IJ)d>_b z1DB_6*q{mz)6i5!laFJR8)3C%8eUt&4zEKcYaQ%XjzD9KRnqL#h=YjZOVM@a^CM8i zLvz#w2djd+Z$?F+7)KhvGTt4!O{86$N#z-KaBN_OZ#o;2M!d6?D`e?dlPQCbkOO`43+i!O`J69K zPKZ;ihyHKs_HM#JCz1C8xsJ^UxPv86w*KQ&NEMV3elB21dOF5RD+%xrI^IMHc!6CO z%};Xz(chNj$o4jZZPk~A%cOPA;c74_^$UN{8IeU!TH3}61aV0SpM&(*TDNo2h+#T? z|35D6&zC-|EN)wF)D+S*Mz5e_DiQTCi?um-_(Ir;NUF9_pg0doISowj*GxLOldbjq zoRe@4F5uBeViE+w3U>rg3PPi-WCsJG%joM7WKz#+E420&H!!-l9<+$ckxqok$UWt$ zL?gi0BJ;cyv(lI(BcY+%{4vvQ7w@uT36vI*!Ef=yJhpX1|GeFPAihwWyM23!O+s7?jB%ikh}6^14gYVd#c_ILIb= zuaBtm`OFq4xQ^guUNel|cJTN}R;1%nXY%Wp!0QT=6K8yRvO=5c_DnhLFKQzQX1eW` zzxCAl5)`b-ULsuHp0inNYkV0svS*FQR@q|C)%BnN@R4>@FhxM0yU4o}X0A=`8eM{CUe|8!wjD2)IYz8nKVcYe9`2*sO?teYim4TlHSTfWv?1bZO_ z=RMVjDBeRSS`bgTgp#lsvUkTngo!Lg59!4SCtR@q!N(i+byflxDpdY;9k-N}xOVR(U$}+9ms5`O^(;#RecnjL zfBHx0A6J!c^(iT-;-{DLLI7h_TQ_De14K>$RCaIeaY|;#Q~U3m=}O=THhtq_yIc(` z-;!}cp|-Ji`il$^Xjy9n{zghBoZl=)q&Qqtms)bCbZH zR3CjnfkfG|09S!>e?gGxE470<6LEoOrzhy~SCwT*iPF+ibj4OiiEB3nyZRuYdZUXJ zR2lW%OQE;TOyfzXsf5{^LJO506|Yh}0KeS;t!rz+jxiT!yr$a&FBlmXXz5H)jDzf@ zb?XQ*^z+Gxq-lxk`Nn1!l4whtCST+l$X*;hsbcfE0xbU6qQ*M}%e(ZzA1ZwgN+SF7 zeOF2L&twN`x`M!Y%Y>kApCG@t(;j+?3(+nu*4m(}#>6%e2LUq>2VfF9v^eDpR~ z_mctXM?77)ZJQhdi}z4wupd|_B?JXpi>_~ugy{;D42?jzqR znj$T0c3C{YJF#kP=E*;)CwgAg^DtpAHqESy#*g1#qH`#56ZP!3ZY)iJj;(@)6ij1w zBR}F}^+L(E%ta(#2ztX^4ud2F`IuPr()>dg&)h?TkDe+&o0g9L6a}w{m)$w%Ic;%P z2-Z;5Ef+;kTVMF=nqY4N{&>MwyYauSCtp8Zd3Wd$VL@ldEKEnOe@#b|=BJ}l+>0#b zbd=`EJpIme| za$y99Ss0UA{~D8SRGzSf*4S}7qp4)5Ai5Bxf63e--*?CJPF3(M;Zek0pDeZRQ_l#omzHf_t%+z4dZ7zZNXr z8=euHFFgEaTnD42vH6atgh#r*nU18B&`9&WlyYYWE9HHU8h1Q? z*@hQ;Bbld30!FPf0?}pg5DhI8s?bS)mdFD2f`JFhaITSSO^cdFgX1$r<>;8 zv3m2C{1{A#dDM#Dc};dg zSR_flnh7kK0Q01!94NbLNlOdblOC$fDJ;Jre*yF7halsk;ij)Z^#xg6H&E#ntQ|5*{9js>qC-V zj7WsIO{>jj-^{x13iVM=Flf}Iyrcz0wfW&x)o(=JPsC_vd)`k(P=SHD64BB(pzPsZ zLp^PAdoUl)(%Z4T$=68ef5?4;@ZB%|jCb*2%MS);kGq9}&sczHo^_*)j_VVHfbD5Q>CSR)xhsdw&fOxK9MUf}gJ9p|?#<-7PzR zx{3`eY%Mfz&AW=TX}l<4AZXrI9I=y?Zj!P~Z@VbJpm1+M=H{rJ#YOY3VrH>%wPAKl zZk5tiEUYzbX78&jYf`$3H?`=u!=&}cPbmioo^X?nbI0>8UG3}l>Nz%`bQN18VaTEL zqj&G!FX4wgc>Lr#t<~^~V)}WT^Q5EJ+jm;f7awOEX@%;xM|hB|g?9H##d8C) z|BnL#Q;(ZUi?%~d5jnS^uZ5n&*>Ff=l}oKg3qo|Q)>dEnoLQ-zLdc5hF3qK7_4J=^ zj$LtoCq{1kzI#cR#wg!~usCL&AyJJ;z42sZUgoJ5L)YENwqjKS4GJ?kB7-oSp!5B@0 znNgMQW!^ZrJ*O1RhCyhKV7}V5UdZ+g{G0*LPKe5aIzrh~^hhBEjwbMhGaMK=h4vza z%9dyJ+U@X4oNZ@6$pfoa{Ag^a_luGkpV%emN+5^S0IP7xj1ZDlEap1A5KfB4ctKRK zEz3Fx1gGHton_N+x2CTYg&qCMab~LM*b2B?#d~#7@YWw=rwY=+49t=PIrOlGI*7Vg z_-;NZR$g0ZL*-r5i1AQ~^7iECi^0Zu7V1Ch!f(02&Sn2`#vq{RrbyROoeBK7eKi4&QQ5FKoZ7CJ4hHMlds1JHtb3z|rITDGLiP_ZmDX>-56*$? zU@+DEF;Exo6UYS|3p5AcBP|ERA5@N#AW)951ijmzErdXF;)Gdy()Qm##f-T>QHO=I zMLYEi^l?y%!^g)cEk~(x{7PVf^oZE~ju47x+kR9?@mVLB!v^jM6wQ;?VTe2+e><8j zkgYfQ5iW;Sr|@fGnu&xe)Ke><2vX4X`3hJo8j^#gl2|2(xFvV*tt15}aI5c_v2tqf z)W~#1!YaO+wcBy_Bk*SKdYO8HB73v2=u1L(ebOh(BdEAbf2C`iaA(+Fc38x#Bm93@ zhAlK(mMGmKQD}H{eRHRY(xSa8J*3N|y!p5cen70;<3_zX(akt|^yIMK(Kn}?Ep{>6 z$0j#0SDn1I?xnUPd5p0yLcwP!(rw7)jL`RS*MHRimlk z2%1<13ZaUKSBeN|G*Kxcs8Miu2EwU=fj^)VrU575$dMRz3PmNXLv=wz&?Yn-wfrd} z?6OlKDvi`Pm;5wR$0F{VOBD`SHQb$_A_A0rKy!emfw)LMT8)a}!{x>>vL{#JKa|eK zLuaYLz3w71i08t!W!KBZML1gW%(o9ZaOk3^b1p`7W-tt+)9KBl%f-jBCM)dPSZd`c zh*D&@cbG1Bk3umG_!53uI@(izY=|xbWx~~_siTt5a8N4EK)PCz2S1Z+m^^JX$}h4> zVvb#93-7pQ;uFSpuRf(xH9VL4_sdvMwVl31-e`o= z3JT=}a?*A(0|LA1$rLJHLgAZfO9DrQj?URb3^cO&_30kiaJxFM`Ka`BuD9_=&78`! z-6HX1?S-tCy9Lk(j}(Q8u+=y*qB$>x2W-EQ5r%^sA&+E<;Fy{{d7sN{n5SbAG}-pk zo;VQXB#*VoIM_pa5Dp_rMRVZkY9J4WpgsqIaRpntVdQfqm{fF*`yD9uok+x^K+DEq zuRRnxD~||o-Q8^T5G-G5M3Mr9hqNW5D5TJ}Hp9?CxG~E&P%yIhv+*Z)a)XZf%>v_a zIuRXmEM5++;iPEzBltFtgL+a%9nCUzfjrZaN+|$UrM4kJS`Wp9Cf z!|lp%MG3dc<#73wHFh+x2FJK{L(eVb|8bT4@P8eW~5 z%cm>2lwEDFKpCC^qngRwh*dHQVg@+`w!HG<5CZVb7^SJV4-pCy02`VoYpcz!2VenA zv$WokNw)nS$&|hOCfj6W+t&cb%a*D14_)(q4zb%_2nvYlO^X1-XPKfyn^#K`LdU_^ zx})gJrJ#5Qi!TGIH8Q442SR*#_K02lvUb;9LQ%U~Td~2V)nQPXo4X`$9q7=i!o;~~ zwKiAd*r$%dbwwxXqw-Wm@yU`Ytwzh6N-=>2+)-1M10&-m(M2#dziT_tR#)PH4_5?%SzBFd=}y>xh)P#nFO)ExP+&)7xj&nvXwyyZ?>hz#W3DzHlJw z@5N1OE758nkP|WRWZ47YYB`L=-vqZ9PPQKfrtbAyU)!L0Hc)0mmK+XLd$l>%EAu4s zjmU{N1buaVr&V|g1EeDnPL#Gc?#=}uPn_fxuWnsZ3yg5m^K`_n9lbESxPLoUDqOSb zBnTB3tzw>7;Pm&@VeerPsQO->{yMhMPU@m*y$bQk;kh;Wp$SBF2D*$gBb;l39Si&? z90J!ne9ZGl)*`*0wQQ0(k(aaDn86AD;kNBENg8VRh3ba}rmA%IfcdSRnHnMGI4ntF z1)rjS{L~>vx5&H<&w2gbC(Jbqa zH*lz242q>*#*^qiVcj}c!Oo{LqQm0(2e?g0iLgw0YBbCZy7?uvftP3jIpx;_0*OUY z5q!MRSqNfxfKlIup#HB`uQO^ygSk>1JgtO!)Ul2Y!tal_U899psB7JJ_HIdA-9OC& zA+4Y*jf#h?{S?dAO~tC>G#}8NsTA%C@D?qObJ@Ktk@(%52o!T}kX8j-`_z+5>(=ss zTQ9q46^@3~RNr&}-)`7!^=b{3;A4|cttgMhEX6DIF>?whz=aP{pxcwcN#P^C7sgv2guS zz5Zx01nBmL6Muhlw z>9~X4$8wARPNF|8*Xw>Aa(B#&3VcEMtDI-blYCc4Ec5i+D1_y)am%ZYx;L13@gsDc zgvpTTGf^A2GLb-!o_{K_{6_r^Sn6@A%*p#wp;A!_`_y{iaI{Pe#Z*Cz%6JB>wakp^JYk)ye8avue;*XoeJ}i z@0}-;{XZ8LAzen)8?5LK!fT6>y)2k>JW9(W`)pvutAq{bdzVt&Dzx>=oe$k^rBG&E zMSpKJ-W>C`-+5>1o81&zdIoKKT-?H04Ki=0O9K4|H-G#EdgkH$6qiO#I!;98X8H}E z(Sl_5i0_`}FDZ0vn2_EMLAs@Tvu$B3t0mvY@F1^>{ibkhBZuh9nKo8WfNE1)+ZV8n z@-6Y1dDh_n57alSA7Om%zeu_g%1RK=L)pQ^yozmqkx@Rhn z+qhMPqcbvZS$-_gTD zVED{bS$OQ(W>3dH%+sQb_nOR{jEVv(sMBh4rE}l(_qZ9h0J#=~SY251_8u zc2e*OEWQUmMM$B#)ntR^ZISNq#+hTTpqw5^dN8R7$Z|_gfhQruw9@up`D?j>Rlsgr z;ZF(5v(R&4!RX9`)+s?3B&di5Q00PoO6(GFK|G&!a|eC*tmhWMh+#Y`{z7eE-2ea^ zD0nIpfcB|j8lcNBCE@b7ZgoYg?9B)XPD^R1$j<^{@u1AB4J-9Y1U;D7dZud{G&3Wt-)Z zHh&!hhN-Oj{R@8Yo?Y-x?JSvJWl0b>tfuT=qW6qb7a)~L4Uah!vL)jR#AUVp$U`A28Y}Iu=H6gA&jz_N$^5KVo-KaESwr86%bUHYAC z=U+^(FP>-m|FJb}szhMN^H#jD3X{h!B`ickoagY$@8`}djYI(z;;R|;vZW7W;_9-p*&I{-S06K5i{ttcC zeAvpK?7G)gacNwc`XMumpzXX(7t9Z?21?7aSPk#4s4mtk9{8Z{!lYzI6h2jbXS9i` z7x;+?Iq)trVAI@a?YW2P=O3I{cszA}#Z3NY_U8+%yT)i?#n4aIYhAXS&@P7DGj}{b z8Qv;9S&3}0do0i&mK!JgZ@uyIeQr>s=@{BX zJ(_E^B-nPoHlV2mzY_ZMQpn5SnDeV?vUh$p1u2=Ff6b`rq=FK4wp0ycSA?{x;?RCKa@6!H1iL7&M9X42LZR%x(^3x5BpzMv_ox`ygo(O^#dws1w-M4J_$h^q9W}DlFNYVVK8?xb+2_fmp zS^1@9<@>D>M)1NAoMUn3;tHipJFRNTk;A5S`YEkSm-hCf{2GIU(w+6G?U!ZpRUIpP z&CiLuPVDPeimY~5&H6V(ZI17|G&=t9QIB!alCcZxqaHj~e!8LjUj6I$Gap26hdzG! z`YrU+@L&J@JLmbPG@sqZheiQvCV}BS={?za2&b9~==S7OEjy&PH|nnz(U-=TzA$eb z*!RtC!#WqG$m&qGIB2li7|T$8x*_7{ryCZ7^tb%2y{c4&yVoh-BxqIG(<__w^}mmb|^pH0HgNT3<1XL3@R?_mHPn(DzuNU=KDSg@oaj!VM1!p(>CI^&9(6{2>|&@wCYt`0tf=*5pF>7ifPcC*2TE}L9s`%({ezQ z8OC0&dGGF6ys?#XY7cQ3Q^6z%+d+(bHIs4}!jmpfh@A){?K&L_-ba6@G=Vz(S?$|^ zT62DzOz&Z4lNgGansUTzfTdV1^>2Oj3?skWQWP<3zjzVDL2w66`}eBjDs|g#Y#Jd( zpfse^bkIN)`osjp-JCDV>rmLH>k{Ai1hA^#mTux;n2Orstp=4~cupzF5e`sgs}aO1 z!A5jf&CDHQ)}Wehu%;jnY7cYy^sHFZC{zZGxZ*Jh#M!bi2M)Il04zwq@l$VVn#j@P z>cbcNu$#w$;RSA)QHfHFR_`@eMKc zssyQ<%iiCLQ2Izg`X&D#XYaw*)b_Uf&Y9i_AtVrD5<~A8dO*}9AWhKF5m7@mB5FXC zVoT`VAc%;dp$b@55DO~0LKPG>BKEQZf?~(EEK3(VsDID{xMW^LX z_mgg~|MZYET=MDBwol(7FfkG@xiHIVKY+kwpU1Q3zW)4}XLO-+yu^LOmq|yztL!H= zsRz=5-9fLv1WYw{$j(eP9a`l&RULNh>x#lA1=XL4jrSsn?PQThZ z-S_y)*NH!M|8OH*+<4*J`x7|L?;nD^mwo@ZD16uVznz(n7uY?>+v)!E_sg%Z%od;= zfmRL;a2Wj7a5OrKQ}zWBrY#*n3^xKrox5QYS@po0zfX1MWF!lL!04 zW!<4!n=PrhM5XkBKD-FyCse9H8Vr*-L+5y^b1LBU7K&8K<-)g`vE0&JB$!NuP1w>S z%O%UPYm{a}@tL9q5U#Sg>&BJwpioVlDwa}EgM@Y9MIKy^dy6+QF~D4*hB=E(Sd=QU zJ={4B7Baf&`WSefg>$Vq`z#7_6hb{_)d>*qV#+Yy{uYXm-U5uKA2V&D4lvI(bzt+o zbtTydx=D8Z^a*#pCJm!+P@&p!3vbcMuafDn%(is!_ftNXfZ2GG9eXKvHy476Bl*fy z02f3?Uf9fFNx(NOe4t$m^L;b~FAQWVUY`$q*m#4JVdf`Lq|Nc;alp`(NaVXMJmU!F^b#r=lPW{Qx=vujJ>%?d zVGWwg6lq#1B=^+;P8H*v<*%o>bMH4+NHR|yP~CDhyFSi!TOaAM6xl!K^b)LdV0F(p zQur^p%ku}F65X(|szXSU*gSqcKoA{Np9!+X3D+>Un<1711-QH(?uTa~nw06`t?QNp zUWZrY1ZrXb6>J9UX|PBI1QYzV1v^g41TsvDt9zh_AQo2-&7d~rtcZ)OdQ_IYY75{1 z6Aiut52$q4?LsKUyO3Y|HgzxEw`Wfdw16@&_{6 zQM9!ydut^w=46V@rI!=+InWcPy{RkJ3?ZReq|~+&1CV}MlZvu)xV{fFWo|Z#{_OkW z?Yv3*2X0uJ^0DX1@ zyl)(Yf9*2&gQrRp?lBOJ43{E0pSq0-rodxW;tRJE1Ah0-GssM!h0h?K!`~K?LL_Q+(g%%#}UqVtiZt3a+bgHuLsdDfuYaE15 zq2Tf6B+YLG+m$YW0ZTxJ%Mf6QK053K%RFxJTZEwiK-7y$;VH+mM)I+{JoNkE9K)K_ z=Q6_S$wiuGS}U4wKwKZ*hUn{X@^wd_oa)+#{+D8azev+53Egab|mlX4YK{YW(^r1Qb*l`3Q6X`Nv!sa3u7(($!>^+RD7dYJ8)z1 zb8LPD?r6qM#Bd_f=z{Mp!(ekqf9rLcoo%;Ho&9)JRB{-dZ*lwVJvFj@n?SBTGthj5} zJ$e*My*kb?YY=OAcVdT0MLrR7aTx{BJ2whMLT>D_>Fc$yZIu{#$8h)dmT9+iPO; zOVcT^tJBXfR8$1tu^sebGN~7xUTJwnWWkuzE)P=T1J~R++9`h@o>z4dqQ~LbY(7$X zCRhP^pvXRAYiD%WN_Fs3(TTVaKqo+R0)wt1cY5zJ^qyA)C9_AG1We=Sof5+%T#vmq z_I4403X3IYf#l;?HatB7ZKj>sye2-_ZuQZH*d ztTUayC7w}Z0;RGf-y`D6#2y(xwKNPa+BC=d+q?2sh+fZ*9qWIGVBHGek&JloVdZ>B-|k*Dup9QZTj!?I z`>(f6Po1XRA%o{6rTuxfe~u;6JwAztYq#cX35a^HNszS=&K+NUas+s~vU>_Hsjkg` z$Mdm+tAg{B`ogv0-|e_pml3{dN4}Xl6qr9seKS}vT`hbVEroqBestpOl~5QZ7uIbT za`xrmwL*^K&SS`W(w%4?TxpxNT#?kepq@G6|DD>mH}k_i-8YxM7U+N93<@_in$||4 z??Xs4ddejp_GnF+uv#8D(4>zHSX*8XDi7&R#~}rvu9FHI%mHe>u=@pm8ttnsnGwy0 zB=>iF?G`Zi2S&_eZM77v;C$eF@8*O=o9ZH=-KQbsm{r<=+e`#oOvNcx^}>-{1Y7{R z8Gw%N_|eqO<{Z=DGJV3u?V4OSEU=@9wc|A`+pS7_wQRd3LGy2szIUbmj)0w~RfHv- z);bdldf&Sb)RPF^I%l9H@`8p7oDqFObHNUFEsyfT#^kOnhX;juN4NBXRfe~?*1>6DXnxMP*Oq>> zZ36A38{V!tGQhK$$g^$-*9klZ%~A}H=Z;5j@5zF^>=3PvT58=u_Ck4*pDw%<%he09 z@rsS^9}y?nIvh3C{k|6wC$+xB)b()SALBwX`YXNfI5W~jNk%K{a@el}DRF(lnj;Hd zdti*Xx*YYrJ>P(m4>9Z5X;-!N#bZB5&@?l^7Xg@BcM#-xfL}f68R~GdGm_V7eTYk( z3D1Tuj%kwPeJNToMj+O9<9ZgcNeL%$i6yWova0wmXUpZE5xSi6nYdis{`QI|lNIMC z<5rIOYpVQor?(7y5M^bn(%A;ZE_mtDl_*umtL#*Qk#=xJx?Ywl?T8+vLIwJ zvT4gx>4E#>wd2A2NAITEH|d=$2paGqV?_sfwLwPgtTyMU53i(%$$d^z3q&=vs-1GR!9}Njt{b0V+p4cEaPmG&vG%1W?7=IZ7IrDO zI7wG|F>S0P4y+9|I}jJ_JGSb)J$JE6w@~F)SFq{mZD{Xp1f@J>5pk;pt0FGMIpC|C zuDC+FJ2%YL#+JWoS9>1XBF;%|+*zcXm(FXj;7z z>G(I9oJon0H&^bZVHfVtVe*@U7)ymTiIPN`XcJ@ytzNzBXo4 zytRm@r6cz)xb86iKJFwZ=4{9^@>tAk2Ljnck1o&=A74TQkar2-;hW`GeH){~iN;)i zx-??#5ScDV%2hG9m9Z<^@t5Phi3!-iDZ&liIl-yGhDu(r6L@j4Ub2ZVg+R(4(-iU1 zIUK$7u6hUSaGFujqdmYwd%qlUs10v5^t5!{UO1UAZ)zq%HYIw#<=ebn{}Ga~Eft*M z7T^uHwWr#emcuOq&HO7V-y8^~aYc1y=H_70hq2T+srTDZ`dd{vL8@n;yz=Ww17D*# z2KOC0-_l+tK4780l^YSn zE!cN1yf3Jw_vY&W&CRFhMFH*GgX#vxOY%&cUf#~wIbR>vq3fpuuB$t1w!+I|Nl?8n zG&!S`!OUF7wz>u+es(g{;Li0c=ZywYmU+@|Zq`GQvi=-LZn~cOKFF-^7Q)Fky|oRS zr)u3Cgu3g#x?Os5``*nToo()}qg@7T=Xj-DA_@4thkDl7JOK7>+wacrOk>|}Cv1vz zSiymgg84xQB0uri~Q(bjj-+t#MHdBwyYF{ZS(!AwS_Eu zZ(t5TFnd$>!L!HP=R-S|0O=oWEjedqq5wTUsKu1X^Ry1Efe2do4xSFycOfZfU90P< zCp&jif!G{Kw%1heg0eOuhjEs(bGYIB!FSsTZfDoI!)1X7v=!dkvh81i`Rn9=8r)rQ z$-SYk!HSn=|L*eqYQpZ!y?A7qMcwpio1vV&ox9*LFPk1&=t2$Cg?%g~smllYbm2N5g(DoS1z)t&TBsR`(8{y8??9RRpN1Lj%!|_F4hqBmjxqW7Q~2e6hNm9PUv&f!44+2w z2HKn4N}$G;O1p6mmhq{-q-awT&5d^=_?&>2N;4IenfZ-meC^Y4i`CZgIKX0=KdOjq z-p{ZfsmBuhs4)Ky9emq-Es`f$5Dw*@HorQk^QSH~@6gcl!zIOpMO8$PH!Tiy$Iu3> zM$=0qv|VRct6sk@TK;wYfsDTDX}DZSxGfc5Z1qz%HpF9H;886>T~3dJ!8wY{JUbnN zeZz|sqg^J$qnzRWM2z6N){;iCaF2HQVc2n%`f(RCndjcuYszgDFM%M|qo23!c1y{2XOPZYRRGPV?3!~rq zSxJX19_(pScATn54>@>K%~!66-gbVVT&;hTV1DufyaJbW{a7*Ji*E6HSA@>46uKGc zD9BsWMqc+(iYHc{??O;N#D`O@>QHqE1sK+jkD+0OCPtyQH(QFS19p2$^1vbEl{efp z=5vWELU#R2IOPog-$uQETr@`aT?y`neO_~galYoDvwy*t*lAKa1PaE)HNRtE1)Z+470=wxJgOXhJ?Q5Yo;_$EI% z^Xdfu%NL4q|3O}?=BqCU-}s|V7bCjvEPbkgbhuQ%a`*3Pebf^8V4n2GNB_TizN=n; zc=+~;1Iia#{{%K?4faoeXy-+l2l4a;yLyN(NX&%kNykf=gNvlV_3AY)Od@+jShK`k zOXG3H;$|OaxyOMC--Y#|qZ6Thqe~c*fvdtm{$wZv;4hqvxG=eT^JM6O$w9&6%p;w>8KYE(EaLQ5o zG<(NX{#A&0$7IHVsnTOlivFG4weVT?hN+4j&$h0bEPFIn_2`-JhGz#D&xH)2Z0Xbb z9nXnT_~$Jbo(C+nI=0a22;)Tn<7cu%+D~Y;`Tqi~+84_BW&bZ|6(7(&#+fz0;>)_( zSQ5l4Kx%Dvh3ENd1)G?!tA9i1eoI)$a)6|z4L3lzQ(;7RjBppabUhrSsZM#&(8ikR z6(aOZ)He=Day$b?JcKr(@~RH**Nj)=r&M$T{Af$M2^u3}qqQk`j0OVTwn1t^-p(eI z8O_(M`|R*kJ2>Ki9U6IWK8b$ahJyUVbfCGEwd6>v2R9TxVjFrTSq9V5t@!(-rC~gQyv| zNKxvI>`aW##W*=%pPS=GW>894ZN|e(Qe0_O;w*(^Cy^T4GF?89x!KH$bS^+`W=~}G zsl#9zGQnKyOWjx6RlrFdY^GL=$aL=^oUL{7Jz+bv!80tXXRCnIgg&iv!|Kk>v4MHLh!5=I!|XtL8z{5rvQ#(rKZNetxoz9E#aw!&+3#SjsA`S21~hi4>Btg zhs=a86Q5h0i&&qqKS{~l^#_#t1oj5$XZXSM)K?9mNFW=)QZD+7UX)&aeEJTb-l-hu zhPmpl(z+>sk$rLpokX|_9!}>-K-hS$Vg{^FS5xiF{l!w}hsI20@cN4S?%>Cf7u68( z=l6aq|7hZuhFXM=$dI8Q<9jk^Dw?RWA*Ot38Pujte3xA9@6c0(nMlb5Kh^QM zn_c~7-La042_UoW&RYO>fcd2gf~vUTi+pU=pwPEU=F~x7x2ugQ({UmxJdrHt?y{yR z_oq;kR0RoQa}|2C9$%T03Kx$1fLM-70a<^-9)xQfJ?#cP*n;k2lq_EZ$w5zmP>r;A zdrWmY8;04M4zda;Tp;IIn#NjyaP)Bg6|Pq$QXA52q=ugp#}k>lR2*#4_n> zR^=1c!Pc9xi+o&eErV9>X%NE_2zg=zVY;^)W^FqZ=t32U6@Xh#E@X4+_K;y%Riw%Q z(Xo4EnJGmT)dRVgAzlO0SaLBO&1YVB{GPJSrzNTco?uK+1|2}thl#6Mv^DQE5@v+a zhybg1@jcMp7U>FfCA*wA-!nsv2d0EOuIqYYq}nIBXTY4=ePFaDvYC9RCh^3U^J1`j z&RoKZ(u_s28l<+WN&yhoEcUPX`1z}%=`ip&2aav@*?6AI=vLfqCrTh7!cF{_{tkny z<1>>7&vd*td7tzQK(1%9h!`wR4j`Ztu@R*j6)O}W2?P=P)RQB8ap2KGZQNjQNY{p8 zJ&xk9Tw8;|bDml-$^JhX721BMm1h&h=tynSwnYhP+82-x`+;|(p8uqt9LgyF;`7k! zY(A&;+G?p?d7N#bZ0(M(qq)`XHgG}SBK8RCdxXe&UCC?Ul!X{k9ISKQhza>HSP|#p zT>Xx~1T`UJyluhr@(GaDK5T0qBmcXD!Jef0J;A6Pgu0C0wS2N?u1@I5m zH{&f5op(oz1lH;#{NeY&`*3TxPzjW>y-cB$AvS(nU8;_t&Pkp~L#2hZTne-~>vP>X zEr<)9GLjfZXZav7g63im_i$!01d=eI<_BfO&<(JqhGGki3B8au5WJXV3`VCxP6q%W zWspPA7;XClmkpe+nXquEl$aS@Qu|KzvDPMjl`b)RP*XJCsLDaxg9s9~N?@+j#i0UD z=7a1w3zPzIhiKqeYe`v0eYn-zS=2jU1|N0ZYH(sOEijz`-AXChMO%}wC1v%IcrWo8 z{dSifU3H)ZcNa_~z<-}2rg;C|-$kp~q$|g-XzVXtXEy`vz491tw$hP2ZLPJgT^~@B z?E5oMNozV^*2U@Ytq8lN$)5~BSeq#fmm+|=3pj8X3x-xECx9a>2yRa$8t|yP!!nYm z^p?0SRGlonR$=$3EM(=0F$h$;*c>JRv#bV7Jzw_ML;{zSeSntXDQI#Vru*~M(Sy_i z&(4AN#`5_3w(?-;mJjh_H!;Tf??!hyH5P;=8BjK-)PZUO7RpNEOlbM5jP~-6_*c3? zTMJ@V>vP}9E^7bD-=k;Z9%h)70!ImO$btSme;AhslNNFZK-h|aLv5({oK6FU#6X|9 z;<9}45gXMp&&qcg&FeT@7D1~t+{5t8tt$$zx*)HCT4Mt~;_n_wVDm;Q5Yu*sizEQkm{Lrx#g=&49l`D)uh^dvgfFscn^Zire=Z@*Eo#!>`I(=zj%t}nqVDP+`zggdl*xBK8@i9^q}de`;o++sOGrw2+8$ z(OqQ3f%3N>C+>*N;Zo{`qL+45r%nS;2L#|qWJk@@NT}WY7sJBljsu^DpDz5z1--?o zPU~O4Dc+o~%AF?eTfY3tdD8xAh;9Bw$0g6Qt4ecW^$&LSrTmrJO8DXtd*!{;4|+r` zDFn_wq{~nW6T6?|)6N8Ln$2S^3HK<W2h9>{KXQZp@cVna zxre!KIevy6W)ARU6u=DT5cQ`@srPzE$DfFtJUFyi!+)Hngb<30PwI9KO)cALvwpMP zs%^{E@slXM(p|sGB4&qsJNTdB{(-2~*R<^ZL9eRhv%t~%zg*5;Tri@kllV~Npdhr) z#}uAyusT!Yw2L3dQ8f5=gycOvYn*XasAo=osgh_UTO>@M=GD}ooT8L3X+W?jT0 z;l3DqUn*TqW4gXdw^Um+C0M=OJowMiL(S$&7OTtsjCh{Sv{b~_{+zB^@b?GESXKBb;V4Epd9~6~BfMK1;%t|E@jDZ2g=$HAKokBYpK^V8s2tv-FKy zf4s_k`d5xbf99`Qo`4e9K0jT|0FD|)b0>L*JTb<)uXJoeM&JmGFkZ{_lJ4xuL zK2H@vGNEgf9?+p6SX6&VNn^)OJceWin#J98we_}xIpB6KFiM_fby0d{yY+s}F=DoK z%kR<&>eAz}|C3xzwtQKi*;qF;k?<#_`Nj~NilyV^Vk5H(_r;&EeP+;-&noFXpcs`I zGGCWU#zh6F4uJdI8cm+iHl%qZJkhSjUw`>Q-=cBjaH5BZ|m zH*cKXplayy2SWOj6^1UjKaX;!EZ;NKn(2MDvR{7`U4w@&u?VdGs9JTWevRMXR(4Yk z#y>&*AG`1L?`O__WqwZMf3Vaa&n!+U&w9*!c9+n%)bP3tl^{X!dc7&lXkp4(?-Y4N zYx6%hW9y>rC|5on+nf!DwCCIR949h9Wq7{#44B2^QF{3Wzozd;!tRCNV<=P?4nQo!HJe+wjj>D0pe(Cc_G<(Q0Bsgq{m-{1Zgv72XS2lB$moA~ zb~SojRJ5d5I-~{-i^q6Aq_vrIJq@|TrIISsBzB6eEi5G;sS#DLYonowd?fj|?Q=cYkSoJ@jhacnIpsxH2R#oG_MXt9` zRnd=BGnwWzYE4!=_plG^OmDPgZO=Bv!)eTCc#iX%($oesR?MNnG!`qpFF?ZMhHtfd zF@5Un#rGoO>^Ni96oRVA8z!5sRi?y{9- zgU?=u-MGVGQ(oyVX_=iy9iSHa znGMvndk};9@DT%>{_^#oV#dES3O=hY#ylPWZ49mpJ)l7d+f4U zi7^ykD^R1FA!R9TA`11;!X&+p$FTE?^<$!iI@Cg+0|Ez<7=yQh z6Or^t(oQkfhN4pvXS!y+6I`RrvcewFBaqQVQfwSnV~@qU7Izd)G!KtN!DbOCY8YMv zj?zP;$+QehlD6(46SRgRbRI5nHv@my z93-NTj;5<)z!1ne$O)}+!eRGjD9(n)DVMLxb-KMX1o1F(CNykBMVT z-QhJj6LxmJ{Fx*f_hEr59wHIL-O{Q*CyYxn(cLPRSiqEIsBC`k=63?g*WV@6p zl{P-}gyr3gG$mk?1@3ziC{Vk~v$!y}hg}FYXWWxEc8Z%~f+z2T%=Jx-?TxXTUJI=w|m zQslrx$&IR5h|PQJa{uUpy3?U9npXMh##J7VIk36?F>`Hik<;|`=X8y5T;{~umw~$B zEN?iqv1~k#Ai9B}*#-)Eh@I1$dOY^{aaQFN%YYS_fu`=vg3j%fT?ox~0=B5V#VfiYsys2Q!A%35wzZx6E1gCppzQifAK+ElSE6YvQ38%FS9XZ2sb1lSGdeE+D z&^GFLV;vG3RoRSOr$dnJKV~!#7Wk<@TH}DJ(&yF&J^2&;8BFen(-#X&1#-nIujWdD zsM#SCgy9nmCk~OTz%R^!nM$7t2!NF=;~crI(b7_I04fxdJW2Yw>Ik^HuQ!Bls{Y9Fy7xlymd@0#i!%&~pPR1v zCdVE_LSDkN^X(v27yyBbm5bSPLoX{Vv9tot+4xvax8(}jqk;G6!2<5d_x&}XU*EFT zo1!}%7op)d+}|!Bkv@b0@JD|JDz;pAE(czkeuwyZqeuro%DzH#({-eh8LeoS_x~tC z9XajhQB}HLTL1fz+mJE8KQhKF6`wEhB&ce&T`tibY|F_edOyxK^NYe8>DrPRb=fw> zb+|P`gsqZ*G&btjNFD5Dyd!KXhH+=uG7h|nqw z^e`a*G1zbOj#aeeLp|kUr+XM{Ls_xV@3B@#ny2I0w{%?wNiA?^#QJl35%6=-&+@FebkUAz|Yg$!rQx+TsXNBI8t9Y>Wc;<|- zLdEQ^i^5wNNrAc`2+G=8v0rdW!qLRUnzVRzLzYA_Ec`?q`>ecW3rp&svow)l5W~p0 zMKHF;VT-wy_*{Tb1vF(|-&u3D;w;IXceVRryGr4xgUwCnk0$pt9$jJ0o}3EIqWUb{ z=~^Y@HWXlvP6@&Z%H>6QVL*V}IJ)N~Br~A%TuNP56qHWeZ^l$@fJ$2Zy;+g6cntjG zQlZl1D=a~i!v9Xq<#KbO8Iq0yZq!JkebKk0b^yM&?cr&vc_1R41JyXLGd&R--MV(W z>4R#Qty_3=keYmdQC#;?@X)S~E0%oma~KK7-wdv3?b_4>wWUu3GZ2^&rwH0h>bL}5 zPhehyPJZsWWUc@wIL@0N?%_eG4y;>fI3=$qq-&oT2#q^BEl%+Df4kam@0Yh1J;)`h zddRAMQmVs0xJ_V8#T6k0YU3yi^LE%At=Q>fE?>+-;?#kPTz+R541bE(h=kWoD^@J~ z7z3_=Co1c)^uc!zJZUK--8N)1CE7lP(-~lRIAt8Q6qho76^@P+D?yH{fV<(f>L~|C z^cG$-GT#y{F;B02uQ0JCv+m9_WeR){_=zXoE<@LkOQHJbx0E`e6Illl+5{PfUaZl9 z(WM<5*A9i=oy;`EDD3%=9nZ9gVW0OY)e19kw`!)%cVRpiIvTLM8i8Z+C2b?m%>tMU zcDxlwnHdSX*CeEijADtgmOjV(9GD@YgZcsh*5<)dx8pPsRU#+?mSVp7;&ac9Oue)I zg3fGK8bCC~F?E?(fmE^z%W+dn%%rxU&U40biG3Jvo}lY*dC+~^xB(wr@>@U1-Bsb- zc+c%yn_hBlh8AoA3Ws`fxEwQz*yI{9`yVXg^(ou+7JfD(1I0u8Ty3B)JiC&y6heSM zlyg8?1Hsm_y5S~9EDmyr<8~r}^dV=Caf>hObrpKw;pJz6J00qT4b@f%NS{dPDU*%W zQyGGzTf{+dU=s%!8mi#VK}oZFW*~F&U4f15KI;rH`@+P9#svmlJn;SZ<7ZZpu%`2e zaIpl?vPtN!XZh8NAc1Gp1NMhavOSN$5LKc*VQaXlV(LQe!$IkqRnweL=0}cqz9RD7 zuR9_lyN)87QaC5eD;%V{A*m+U0M@5~dSH5gk##gCE~xUu>qBi2)_O+`#WIMm&Z4+D zY|=?42f<)!R}RDNLn$=$1Fgn$TCJ2J7ozd&EpxyOwYIF^3uy}@oloS1peP(uro4RW zyN?)gqMu{H%tn2i7X|%tCt zjWysTZhg1A9~v7_GSkrhnjIWX(_1~ZXS|A#y|cR4h!={7u{gwU%WE;bQG7?M%F$eW zfct?bnb>#9Qqu!Wn^w$)vSm;@uBOz1of!hl0}=#^bF9|fzPFj>1|L=-U*-cizA6W8 z9ye&i1p-dmkO?9Td|{rrv~}F@UGiT=*`KPHG)Md5dJepFQ+XBF3gORPx<@f$D$eM& zJ1m?t=d(N7aziY2&vfBjbFFF|t(43J8<63IYUABv$SZ^}M7>8r6c7c%02AXoJzlyn*zWK-N=%hfC+M zE?smG96EM*34s*0w-yny>d~{^IWUFs27SQIloK`Y!2~3z)*CX1-G;c7gug<6Z1s6g zuERYkU|?gg-E(mpHN5@C_5m&U%^*FC1hO^u+ldzU6%9-9GU5uBHU(|9>v6S?5j5d7 z(fLcOrQN4m_mrM3?>lpzwV6m7w zcUTl;a5fs0`Ez_$`kW3$J4AeD34V`TauDn|&pL};F zrmyzNV%0xm@b?-v_j5LSvkY3uy>M{ODiDzh6HD(-Vz;W}7B5)Fk}1L@eE1v8Aivr8 zh{%Sw@M2I=O*Z}DWmHasI&QHz3(ZbMR3vHcz33OO7p_MLx9O<9J*N6ZF$qIw#;(;-)YPB4M~x++H&1KBb+qHU)C5-0DK+p z{?L{ybgR;B#nV;JE6mJb7b~eB2o@S8++ph?6i$!yQ(Z=rbm@%k`3S0Jc#v9M-o>ci zUrj1Fi2mgZ#{U$T2Y#uQvq_7J(V7;|AL8<$)o*dRXjWVn;(vcz~~8=0;5 z|7gouzqMsI{QfMHS#7z<&#?Rdpe?iZ=1T5jg&@i|Itl+EcKb51&H62Nt2QrV6cwY; zyQIJBTK*K5|1;h{#O0A1Vfp_*ak+bgsm{;$(fk>V@So?dSHI8r=&ZP`mg26us=dd- z$GvZ-UHG$;cd_o76|hKm188gHmme!wTL)k?e3vMoR#Z4p&pnc~x8yNfe4D@f>l)xY!|_^boFSiO>d>djIA zbq0UyuMz8G%5i}AP&W&S1d_ZdJBQ*ro@O!kp!_r2&iU3}0J3H21hcaB>`FP!RYOGP zgvVkkdVwLq^b+dkQ9J-|R*LWgk1+?D8#Gx1?#qgN+?Bbr2nPq#9>1J#w8KsO zLup1A75}QKGyA61ziZCwN1WmB@F@Vzh~Fx7Fg@|u>5un@x-myOE@3Yjm$ivuPn9V{m@l#vxe=K z-;ed4T`Mzy2A!up`F+(lovF*d^7ghvgUc&vuq`K8Th;yT-=vkz z;S@fd?Qg#TFWI`?AUSI`iP9Mvj)Uik3U;ac+wWhoXRmQhU46r*vgHS4t&N9|91Um= zZ`pC|#K}`d#}A%9*HxcmFF&*)?0o;g#bxCgy|IJp{`L!6DzD$XbvtO-wD8WuM_0FM zj6c?Z{q=OJpYh`53-HY}_~hA#k6mx4-~av3x5m$JzN-7%PfZ?nM-^0t=3 ztMPtY)K^Hc=V?vT6osh%wHr`rG83Czl+I_-^GVI#C?zs)uC z1Y0Al!m4n&KDA^DtTBJY#Se3lk#l?`w#_svD@W|MuGnKsC;LcJX>ST@kYjh0MI+{vff};`$9YN|} zgE+IJL?n6ZacwejliD!73cHr5O-(kXpWleRqPYyF)-Nb<1V)QWT>|T(5(`bSQkLGW z!wz~KJwbPon%CxP;zwGxOP!Cb#K0gr(rKm@7w$aQD1~i1)`LXQ7)I0YKoF|Vy%T&9 zHFfJ1>R8OM^QVg>a3o&ccdD7EPXE-#o7qi|fZ416FiT7elu10-7ShZ_gcbmSxMIet z;zhN(XK7k>MKz2WF3`h~S`uZ*tX9}9(psTPHP(UC2;LEKD@h*~+-tD(8~?2Va`Bv} zl!zpi(O>(DBR_r@fp3qamJsBz4*HkJOKArxhy`!&j*!a@Chuk#fai1vQIyYAcgT4B zGP0HV^!whknlMt0?KCIWJj*n`T)|ZN_86Ev?8?qUYiSwB)TH`#I1n3%shLMpw#wkcZu5D={`F8|D}qyM`*#?_SRak(?nf zA9afwm9~Xlu!0NK5CNdrR3KfA8AVrhgG&TJw>3uJXDE0=rWNajPhSoSqOc?euWO}w+=wlsz#eGr ztsV&4Bf+=i;&~MZx^%mC>E7)NvsB2D66X~q_$Yw0{RN$cO2sb?5JNOR*v03W1{+V2 zG$Z*};cK#6e83kU4NO_Fj#Au#^DLja#s50AfOV}FO9GFa*tYNa%$((Aw|F~ECn`@fKe%h%Nb2g|2w6cUv>LdWu#zxXZ!mP*iJNyIf0DZFk&W+fef1bH zWfI;5n^hzxXk}X+hk#^0us3SKXG>i6!rPo~n3qWjPs^D75CUF#;HAxo{cid37LW7L z{(cE;=bt6mA@aPn7(p)u<`OrwdoltNqw*#Y4cfg=hFqB z$MP@p(|`=q*PqA02O6IrSIobV_IS^lg`e*4wmGsw5udJz*qNQJ-#d!Ve{G{dd z(wygM9k0K>+@WhT|5fK>LB{JYZ2Q(Xij9yP)2XnH-!#;pjQ^6H@C2Cqf0XHuy)pH9 zr;z<+9+Jod3f;!0MXl;D3N^tVF*i)0* zVlN$*z+fb5C*4EL$wE#%S+GRK3BBzpY7UhvEuq;+tGOq{GPHa4JaqH!O^d}6*l`mx z4p8-S3KUbBk)$=u>-Cs9EbUbiad*~MoJ>#>3$8ANXYO0bPlrIEJ|wfw-7*;ujymtg z&R6R1{;(0hSdgG9yjEvAwrl zM=u;_#u6(@N-&VvWJUyZu$&O|Hi|4J9usBt)|&-%YK%L%9-K znP(62g)8Sc>k=9!Sd$qdI(rsCuuqAa*7KY1=(FZLxXbYkHSXimG$>7@GXaDAjz;u5 zE&f~xB=PWElg}*HL_p@TX)$PVRLH9;Y;F)YFgVY_*oaHfkPE|dJJ0|gE0tOteDqJA zJ*D`$?*hAOjv<8RapV5xu@k=Chhxc8myTGmV|t*c>jpT-EMF!-K%H+<$1#VJ)=2^m zM;0DIYMR{_TUhN0;q0X3pXo5k9eVedh_59c%>o<*rPUk5t9Z_)X*uGB&)PWZ^X<$i z5qv?EPjxxowgf?V+P-<1Lm?6ccjHKNzkC5cd>YouR0T^LDceRYyL?|B2T&HUSE#Eg~2y(7L==2~*ay_*OZ zYee!M9f={TDtlbrrEq$&-nBy3JJz@AL)~+|xA1W*-hmLjaUu{_Z{du)6*N9y+My3U)TV89tOBHYSEGX)VN1^y9d0wfWKt%ecPnM`n5izoIQN3wv)geIwO?Z=PhWH{F4 z2SDkFWHb6PrY#NZsI#Tss1hn!(C1TyI(>tX8CvFBKgVc*aI;(AW?`KrpZS<%FKWNr zGPDrOr(s{&7g2MQN^F2)os}GSWU&4&-xCI#z7gEDX}0*1$h_^;*0a=0l3Wy@XUqe$ zz&mB-n?yY3zm8CWYI?bk4u4G~*lFqz+wP^)(Ptc{T{X z(`PuETCNFtH`_s3!!fS!_bDg3?e^Rq7Dl|Uz7yH)P`><1V#fRZS09+zcl%sT<^|O} zbU5UC`wTke`1{&vSkwH+KG!mQxxjtDLkm6)Ez?tNs{Z=&5C>TZiTig`4a~oVx14n? z$2Fx+V|$ASoGQ*r-c(O{_0&#nXDrSjlJ+B-o_KkEU20QZ(P$R32KC>LiO;|zH5{bA zB$t`~>^fUH$UDL+Wc_i5lyvM(SA^C18@onZ*hXbCJFn}4%C^POq_e@9c$&jWCG7$( zpsv15#VW-~bFkZpNgS^nykjqpj)+3V z>aM#^rRy2Mk`Uw_$=eN3R1!TcS{tE9Z8Fijie46&q#1!;b;dx5ppzV9sMrm;b~J`IbzE_jXS2qOq>?I!B*zzVca7GG2A4@R=%=EXBIaQb#4q8?1x77- zaN6;Rt%1y&sOGgK&Wq|N>J6$z;{!dLd6}3ygbjqVF%ozd{y?lylxmOzLu}C*^8ygsV7{|f`ym|Xe0nYW-YkQI#wVOPb6r-7$^SbJb^<;4Rt&Oa$r0_WaySdE*lfy z1fz7!s-nP)uf_~bBGUp;&})Tm3WW#w;#NGab}8D5ZbmR z4wM(5RDj+2Z*xF&XJAdp92H9oBA1g5G01da3D!5A10dg|D;_S_-*H`>meXn1lfKbW zkiXNHwYb4skEx>5G}%p_n9$JZi#{)X+7XE5*PGRS_a2zw(Qq6yA4(>QqSk3rIJ7*B z&A=lRiqGm!{=(vOVd)}7L$MB1P9cYJhpt$*Q??8s(sAZ%(XlVhA?3twyIR8o!^(!3 za<%(Pj~^|orvER>-aD#^whQ~6ne;*kNeBrggdUI*Fd!&sLhpnsAZn;0h=wX6Y7%-; zLlZ>6P?TZ=Y^dlHz=DVvP{b}+5IbV`@sX3aeCPX4Tj!j$^3P;S?pc|#_rCY_yC!-* zbC;lXvP4k$q~2^c##M<{RcovD$--TB*s$rQT6a~-26rh4!p-H0j#_MQ1cZgp4lI_% zJSsMZ$QTY#9aMRrcT!7G#oMcoFX5?enqi00X!dQu%9T`ZBRR&iI}ne!w_RDbhxc%ZE4$*M8f2LC3d%3aY=+2|NU%jY+U+&}~X)9$Sq$aBy3BxA&+Qd3l zlt052(dH5AJ2im;I0=3b8GZmuK64Y^z+iSk0U?>#6T<)LpQSm8!9mDFP!Id^X zpkpnKjZjs0WbYt;likZ9pv&t%f1h(1IHrHG?7!7b0MG%<4*@@wevw$^Gp9+Gp7El9 zc(pk!?(L=S8(B-A+c!2x!woYDg(mLh7K}FJb=W+SVDDV!U>;c;N|In?9Bkv0E}*Y| zMfvD}XvW_%NOCzaFp9n5?d^$(Q$#A=FN8tau`Ns~lR=^l3G4Ix4At<(u2DEF&QDV{ zLdOU@wOCVD=rC9F`bx+SUMd0l<5Ave5C?Niq3eHt^vHnM5CXfsjfrUJ%}Thk=huCRdaXn zzcjhUUd~yEX1pNcF8;!F2Oo^3&tza^^ezlfN#_ zUXe}YO-hiY?7UE!=^+XxFH`~K=D|hsb;L)XRTo2G%y}+~v59{Vx!znppkchp2927E zdo>b%-c?$SMT^|=sP`#+dHPnj?s_aCJTwCARr!3yLwhZ0+_g{bs~Q$k`#g!g(gDHB zj&r0uIG!t&YuDJ`<}q2Ga?x7HwR~;66O;@BmM~qoj`{vmet?R%g=8RTj}8;D1x*nQ zIN>?m*ey77cAF)#QX~daGuUDjOu!tFY23YDP?C@Bl_RHX7Q`SXgD0=nsYt6WxhE7M zkTP}o9B3sey2jKh9{{aYem|?LW8BFY1d&YU*j8TC{B_XyzThUN->XPemCok!Kn+$_ z0OFwMgann?ya5a7G!zXif`3uACFm?BvS3hplR|7p1{QdYTVs0U;eOaGE^NI-ACG|p zW*=k)3^Pp#AI`jTFpd~`4I(C%I!CJ)XG0*)ULdYGP&oUVE32V(S6X<=cpAOqb!K+7 z&)HrQGqwyke!NK}UlTSt|Mr}TYS5i->_px`)x*Yl0~7M5*YYl1SNZPa7Q3v+Fq-*? zxg)S>hjo2bT?;@=_5BEuskl7zX#fil@+NwYr2;Oxqh1}&#S2SBff$w$(+=ZJYwb3B zdPwe+N)mRSQ#<&>G?DS;>OAsL3;#Ye!XdUV!MiX$_uTVkkE{*PZEiSpkGDxenIoke zi33jmNgM90PpHFS3qi$=Sp;1qDb=&h%p#87+OMNzU^w z{95IFaq!S{R`;-W=waYon~8mq?w)~SvykZ=bvf6XKQA)7?Cp5VrjVn2W2pR`HBkBm zXdj+|up$=X&cw*EaQ@*_r+hc43F@(ztyd&Gx&oXW3Sa0gL_8iPtm!`>LbIhxBv>bg z-j|<76;NJgd@va4M&#@qFBn88M~7zzJ(lMKy93MVY&Q9o-X>7WHPQN+F(J1!b(M~4iP?TMO{X5vq#ZzY(SV?$Bxl$ zB)E@3;ymRLi&=~J42q{KTQxifJiH3U07f7ud9z_|H=)YyqT7U1+2!s{3pJ+Ujkl3O z;*Z@-a@5~eaHF3D*`v7wtR3{5N&m_mG9(wy z&`1B3IZWf*u@HAG4JkYD%vYH?+()MGBR9S)Z+CoE0^3?iZu&CXzViDvXnRxDK|qDY z{ih^AtocPYX~=Tc7*T98JJn3wJ!gs#+>&@Jdz+4dZjU#Pm*=eb(z=B8%6$D#G zPG`kM^DjRv4A&v>UrXDEME-&1o0aaI?i-oc2J+wrs88>u)6P(K4#jaenOfOK!kPcb2o(U5 z_QE~q=HRmcI&CQPKhMa7Db zmZfAFMbQngX>elPLfjug4+??txU8>Mk`;Gw85`dn%~KT4=vxZuA&isg5(DqD;#0HS zycen)w~L%4XOWmU8DjaaC#iA(IS>k<7IO^3*%evWX7(xI9EW8Pa-1g^LgM8H5@=}u zz~fji@hfq*9amJCjW3NYf@*hvZw}j7S18|Rl$|hz{=J*eg*0ih^+a?Au^5APl_}f6 z_}mc?EL9lf8-_4n69zn(NJ>c>VnUrBqN1V8H)`?U{nK@}j`(e@{zA z`@af(_3Y7OabNCU>R#`ciU9y%xgcIAp9l6|#hmUouNyP3nHYb^S66ZO`Ig93amU^k z3U2uOW}{@R!}I1cAX?kRX3I7UgfcY8sIAVWdr0!k^cg4y$sK_~?~z)TEo;a}2DC0zn%g4tH+MZ( zv(&nVyK0?7+ZWj)=5qZJ#U&$`c56N$w`}jy##Xz<_3(pj zQ?Ph7I!Zk+g+#I<*p-@;Bj^Zi93I<>-n~BrzZED>%TT&PS?<6gw)2SGW6McI6iN%* z1TTd%Hd5f_Xt)oYLX`*O2@i-Gc!nXQg0%J5#BzaH@2Ox%$6m~X2SIlT$xiEUytDuC z+W0>wsrH{R90{HPt^W%p{lA9cx&N94+|9AU{0B<9{GcMSjAhe{Evv)iJ?bS+uuJD> z7W(3>yZA%{>PFkW-9VAX`-9Egi5C&-I;ITFE)xbd!h@k@LDx*MrIb2RRCOsVG(ney zFot7v$q@{EU8)rwDR4*YkjxkabyLE26G6L!x<(eBtl4COQynZcBh}heaq*}uD}>JJ zcnlIrP(n=aS1dVz9MXl;bu=!R&z@6l&_!$Rk0+y0I+&-rr?}6!Ds%%Py7){C@4PBQ zbJ%4@55{AbM^XgRq^ku}EDQ!?ucC zHAP=$+Io@IN+fCtGn@h87nq5P0WloSNY_C>YCYJ5r4iWSfVdovJ#9wpCcnLGK|rkx zd7d$SD<7|_)N#u@CGbHrY*d%R_!-OXc2K>wt=<70Jm$O+`s;u*?Ec5inZwenLy|rV z;x<<$IJ*c^qh_;(C2|mjvGn4;ZE0^tASEwzlZxbSi0b~Qa^OY>?E8|XvQ^6u7UW|{ z{TtW$C)I3uBbdI+Ls6u>dT@tV4Ya5vJud@SlYNEG`HhL(tJ|?3u3&=p=dZpBhTV?= zk)(_Sx?O3a=NMSiD3yZ=6c?Q|wsyEUT7K`Fwpi)kwfZ#L%k@DaO`CZ(I+UMIVuk9y( z;mDP5PoXHHaJyv@c6SR_N=T9zblyN(Nw4 z1^ErIJ9n<0z!)AAK4)GakaV!0ls3K@8!E{0xL9!@fI7H|IdXXJ(r|%0=}n$-`do`J z5KI1{?-SvCJx1)oZ;CODQ6m-{EZF8bA3kF8qIS>>O{*F#%bFYH?m*K4V>}|U8|c_J ziV(+SNAd*__2l_kPdG&(Hyz!{Ucf|}2(``YHv;dl`18_-7o#Q-17Rt3q4JA)$CW_5 z&ArsSAUQ-z#}V-J9G>5{hg|rU=V?%27DWi8+8Ku3I9soC{Rl)e7qSt%t?jNu%&vLg zMbs@%G<&NFew06epZsUwc(L4Cr0;%q{?1jUrg(+Bra^`XGP(2F9e!))#UH3#ROgkE zGT7o+Chf9g37nb`M1NtCam>fNsG~jN68G=`nu6-1P zH`d|kw*YuJLm@Yko%9f|lb%^}*}y0Y1J`nVVZVwlXC=A>AhlP6Di#TbXP%2HMy9I$ zJ39b|8O-)7vGSwS4aAE)ezPq5{+Wq4mMXac8v-jl{&w270$qItsz zW1*Q{0~e;}Q&{CMvc$PggHg&hU$svMrcGm(>kR^9PiC|hQ{<{f`h^I8F^DYo194tg zdo%@=Z_#hzSKnfG6S*M9yi!yf2Ts0;2GPC%OyxFe_DO|#1bD<`zk+CXWZd?g?1drj zwZJl{)3TN;RQVWj6+>w&@!laQP@TK?=Lz8A=q4zX68d;JQ|oCe^cLn`UlC4b-3 z7>PTYAD;60V5l*3^dfWCp|g_av6e4ar^pTl(91LZy^b3AA)B>kO5l`=y3~Zy?P!JO z8$MejO<|CTVr!e^?4v`5OA%-z0AzcXA65QWcdlzjSgscJFiNb?XD5Jz`eDoV4S>K5 z4$x*3b^qYE$vSKOd z?J;G|xZc(i>!0@ozbE5cbcUDDTH&!*cc1I7=$+BEVT$C#BfDxa(?Mfl zldp>5;CC^QN$b`?+<6}ATA-y`fY=U?ORcXLNV`wp1;mX3N0RpU86klV{x_y1zx9`_sskq{Ne| zn;PFG`OHk+9XoU8?9KO@x%KxS6V4K(zME4I{QYRo|Lpmxn;)_+{QY=-&(;fC`)iqs zX8r7e`>uD};oIsT9(Q7K!+_(GQHumjru6LKF225&C1OL{&kYk40g$kLGK4+s=SJ)n zvscDvne}z6P`OS)N5yAnb^a z4Oe`AIvWFw`LDgQ7%LUEJ3L%&?I8!;-=ClUhmADC<-Z*>*<3ReXOUof{DJ3+n*AHz zZrN93hZ}{pzy!ZpEY$f&DZoPXA5XPrHNehtR)m}Lc-_9)yQJaRHI4O4U^*s64rmH$ z>anEW-M|G(Y8|Imo2<)tq%`0p#-@sR?v_@CmWJ{I=mpAiKSKeyjGw6a9JkVUySUY$Waj!es-Xfr~#w_Ptr`u15 z%m~^)l6ME?%Xf^60KOTi+x&7SP8wQ~m8v#lxI>m0sNTC?2yPFV2E%_GJmBjFMt?o7 zb!wWB7PE7)HhmXyI%i)GX&VTh-{^$rn!js&o|!G@{DVSXOs7^zU2LxAktSU4ibXZg0k!T`o}T!=Oz0mXne@y!o6Wh< zy?xc~pl~@*SYh#xI%uLs`v`6}W^K(Z0-tY%yuK35(RNc`*Cr72k~aV?+&l}eon@~v zLXjFYwJN^`x$2(Z$6(cxSCA_xFz|#?Yao=&<) z@*ThW#C{1?<@&W%7bc!99v2wyi~%Ey&Bz>gMpedE`>mNC!1f>D4pwFbJA1;m*vCXo zYuW@$1Z!%X0+F0s)|uK{ovRYp4IFZ_ka?_vmkDa}!^T}r*`<3SJ2NCEy+Awma;bb0 ziIbuqs}<_bn>kNlc&?%6JWF4K`f=R7OPEVK7sMKgMOXTSH;dfR^1V4fP|ZAmCIqW9 z#JkU6)<**wFQH|XNJRxAcM@qHl(UPQY|3>5N`TnR5=1IBCUZmR)GpUyH=_w;bOA*u z485hdO~LkIy|!)Vtd0x^s9f#$&mpyQL{$m6T~AL|i9p9AH#-v-kAK;5BBq)Y$ZB8k z`93a<*#_O*N}6(u^wQ4_bRj7b@K~9lS5fihB4ZXCROzN}@9;2GTi%&on+Q^8D{aFi zDwNw0QbhkMUy%@t!@*!t`4P!#>LsbQQwZdy`@j`9%63z1jzeJNJQ2CJrwzDGX6s#vNa&$%MpZVKP)kxXV>@##UnSZe#+>TLWx9iGDk?K!@=^l}*j4T$o32Tvd|9pu#C96tKAf7KYc^pACckMfXG()btEDexyh6MnK z-SX~}EQU`-MK<5xXN!FU4~&xHEXpkZC&}RRsVb|2ixWv*|yBA%`vIX`+S>$S?lupHeby{K@X(h zd>eoHq44@ctBTq{MQh}*LxR#I|MRWuG!G}_9QKnRiZ4C9>A~SuvcsD-+taX!P_wq= z=!{djCnVq5r7; zj6oh0{ojo5|E#P&{_)=#pR#4E*VC}|e|if=`VEg~{;O?P=`G}Vb6*A?x$UOXTWG#- z3V+Yfc%gr%(p%`EJ{hNuBml~!jGV+ks+Z}|jd7|v1R4XcgT*4rbu>&Vj)g<)>gen` zaRs+QrY*3)B#BPIw1!3&4}+gp6W??vKKVk{*woK7wyHU}e|{1oYq6z=#oA@ll)SGQ!R z$1#5APrbg_8gOLY%^bm-yWO!j{=_DndH=9a3HZ5r;d=la=iA66{-)?ci2T3TZQc&H zGAL$tPb&6$7Q)ni=-ie~%lobH`hN+Pnd2Tv;>*t_KDPgbpj2Pq)g%$3-V`TpoNyW4G9Z zWSCI42%qRsdu)!4JI(DN8478jP9pm`INmbPg{y`L#D-Jx)uM(i^&{WB3j#XLj~17}?_Sn=6)ar&!x^Dge9g=O|Dsm}wZ$pUpwI_oLX@iX zIRU~e`?U0|Gai0Hj8W!BPd1+81Mu%x{kEE_uo4$A1i|(N@!r0=&##O{J1Q2P@ecW6 zzLbazypP9``{T|-quh0Op6Sj1^3Wl+^Q@?IhfYgK!s!b8=p+8tP{NV5CrZBe%HM1y zyG{o_yr#R1xcLF1BJyDX)8Pex(RcFk`Qh(r<0Y(eKzGc&}C zM2>Gc^cQ6lTEzEN7HL=SXX{)Mc?WXc8zxjI9gLw6hpVBQXQ4%i;vzuQp%8)7IA0oK zOTxJHWA_v{hN_M?>qwS^|-iP zFijZ{PQaaGBv$H#Y{s+JH+R0vXGULCvQ~)ItYTKu=05Hz58)%bXl@F>ao8uNK9iMC zxf2bz=?HINo(;$>z8;4)wiIeaa6w$694LvpR&lxIhDt?y)TVPtD`URfx(qqsi&^vW zK?@T9We^ytL)k82?`2+Jx@x5r#L=M<1<)6TdmdPcNpM*c~yNgMtqZY9slj74Q{0Rlu2Puxgb%A;qmoQ6_Js?j#{7B_tpIry4x^I5J&gV)!> z4jmrki&mY?J>rR4Aw{u;xb-VY(Hb}IE%6jpMa|X5X!+b9iTu`Eck^h%vK#lua=x8B z+KpZI0iCz(65wz%34{(mKu(n2IdxPmMP2e4?$N>QAa)Um#zO}gey0K2fq_cnBT(;v z0DM~o`nD*O^0vFk%7V?E0y%*B(s^w0ZVGCDx2BH4ei^id7qHS;pbb$fC)}aH} zY3DG(4{HY@`6&NV@&c|`@DwzsLi6O)AhaUnY{zuJJ@sBva&d8fNG)sK> z-_VP}rHHW-2&5f_;qxbv&6VJ}qX)dn@Z$kj!pmCBg?Fx7r)<=4mK)g5i$>t7y7!jT zPITL@RWZGXhO^C8K4`&JCON&tKDOpuo)~1^_l)fd+-eYuF^GSyzY;4nwrdvYIheu@ zIFcN4ri-mHN$xO3e3&Z;S{W7UkM|t!ETufMjHc%bl~@?zE>)*rvzKLbzfd;>iWuF4>6JucUl9 zkvP}Lx$VSu1#gG(ooq4s`fbCu#zSB44HHG)w_cC`RY|!#%#`^DW|3g{$s&3*xYWH<6{Bgy~&0vFl zzhC<6iot>0Qbg|UT}w$K%L8}XcVFYz-ZNvJIcyC$EnsQC|6bj_$J$DTg0oi1KH9qw zv|f{YyIaj3fmkH5fuquAYj_$OY5E8@Oz=!&>px@Q@;acjFiJd~xG={H$+tIX&Jf|Y<#UB7t zPQ&kdtET&v6^~zmF=p=1-_M!;D7bF<&aH1|1s8l_bR@jJc+Cas&F0Oa@Z=>OtFRb?9J zy&KqvbNqJkic|y{&WP6QzNPK4U!~hR-j?hnYW^l)C*AVtpFO=;)xG$2=bR_R=X_vR z_<5^m*9;c5rQ2<(ahHSvO6B#Q9_p~Z`4CejAvHji8(Wi?`|B0PakX_OVmpRNU*zEY zdY6VzE`2#od@Wt!avN~_Ou<)ke#vz3&I#w9gGO1Z@x44mH(1FBwx**jgM5=Seb7>0 z<7xCwDEhE7K_|w^q=1vzZaf|mH5Y8v;EY{cOTIhhY0Spo?2YQX>k4o=0E)Mn=fdxBar=E+)Z%z}^JC!Z2tVQxMOC&k^Iuv*+5 zoG(43cm8hLfoAgbD}1dhP4vU(6venE*j8#}W6=X_eQvlPut?J9VFZAtGgvYWz8uu+ zO85GFS`Ehr4k<_JLO6D6hcKFRwPV!_o@(89;(@l6nZtNZnRn4-9LgCmY%zn^5E;>+ zhLFm(Rtw8hJC?rtYj*fbfna432YuBY8w4Qsd#CoOUM_%EPS{*1a%hw#o5=Dv4X@DT z0$POuk;c*QuO`0zf!7)coZnjD@9fB6`kDfT|2(rAsDWtnmdWv;L?6`P1IQqYe`2&% zuX|@1x@;)sJV#}%t1eP!I^qJiUOf!yC9Glvnbz~rQ`5kbvuSi`+@i6_?B*;J+gDp?VYmHuV#Oo+cucGf_h$1K>^nyd_nAwwFniD%sol~@b>bMT=*nQji(Py={bA&3k z+Dkc+I=_}2k#?56V$Y115Cjt@%<@^-_CbKoB_Dy;X>0>Tb{H)gog!2g!8k_yJZ`{u z-%Z?8H?@D{8bCMQ-gR{kYkJR{AeA9Ix8LF1pLG)3agtdz`RHkZBS#t?BsG!cAsnis zd&{#A8_qA&*2+*bnIL)FgBqj&e;YJafM;y;A^hassL|bJP>_{h$%%-1AHJ5=30ZG+ z7-X^v3#xWh7G}rftA4K9QjroZX;}HJx?*PckXrRFbk*WpaizPR(y$0n7+S8W(fbFM zW!kHXPE0B7+hj^iq0MeHMuOS*%>tuW<}R%zOqg8;%^l$_k4#ck`1+zwW5t4FUr3$e zT&B6^k|Wv7-@@2uzKVE23Jn0)V|B5JgF0Sge+F-;JaTh&#kWKU1c2e0#Drc zdOsIOOTaZ``1})0p^y8Jdnjw*_z9YN=Cwn0pJVNqQa=fhOj-5kWZsjsWs0qMGb#W+LVNG--v|%| zSZ7ylm)96t48w|=q3+NOMDCk?ZLS%)dQFY{U#{z!nr&ue(yNz-^ja`9yCtyMYV#e6 z*VdId!(=khite^o6Rx|&r?g8;lieIAz018(7AqdKD&}-vEMR6y#_Mb_YTaRKOxBT? zv}4tn@Q7nkl%;8%_53bs&`9&nIa+4YDm~5R9%(g`anyC#9r8Hv&-0bN)2UQqB1&OD z3N=J@GDn0-UL$O~yGFxbFt_-Aqu~wojb`l+(6vdBh5*puTYr|X!k?q-U7*sh7m5?? zi?9U`dOGkFr;47^b%X3FX4TMTv&H`2gfAR7TSEpm)N;?A_+%cz!SvutyZpen0=qKFY>5!-GETEjwn%Jh|z8 zN7yg|T>2KG$%Tu(Fkbcb`OxGoJT<2Y`?$%m*DqOb&N1*?@h{>vdLP}a55*|tlHmEB z_a=yLMX*^soQ{W0ejaEkR0=q@2*{okwA@(kbzfEz_zW02GOSN%eytPaU7G}zkwL3~U$yWPyV-8uR=V|n_yt{fS z@_u%}fhg09qo=JO664La{SSiY!(H7X2aH&msgYm;5~sAaU#j)%ot$eu*-m#H?3gINDq2G^c zjX2MG_WjL#kn!3hF9m{xhOi5S9j|?9irbW^f)zjZ{MAh~-E-392Jd2Mfp-UViw=Ga zo-Al<`yvV$I&6I7^$w3j>aMFq-$25n+d4LO&JAhU)PcLURVR6IE<=|^##uhGBuu5a zdCB%M#-GS{-C>*dsm;1@8YGx0Yt^vGh7Xg6&4Nz)8C#RZDo6tg_r)9+_jPyQ3cmCFCrCgZX1$AU%Dh&HX8C)&#{vWp8*llU*glg zVqleK_euGmSIBwlGmXT~1;vdgkb#c_dDSN`@AsN%ryO0;zx7@xj8{H2?E56>pX-hO zGx4yUYxkQ`Q8L;5o48Cx-EZ#@HEaBa+4Kggt;PqD|Lct(Yrk$j_UEr}Wc?Jxx_8^-q*t`f`TK5fBYF3_J@(+%Bd_jdBlxP!#+7w;xG5Xyu1@D2*~Sh{^f zGcnp#10gNQ_4(kD2R>s?8-=U&SUdJ|n8$=oE(1($yv3n!-itqsXVwJplVK`9_wD*w zckpNZUq2h3{%l(ODbxMcvg%jcQr;yf6SI^LRBGKfyG2ss*Cq=4#Ewc5^ERcKu^CPIL^; z=`NG-PD(PG0n0>H!`?_d)$88DKL$Mhk^v01dq4fcUih5@u<1YP?SFn8{O7a_chN-Q zqa|9`I&=Vks`Rgei$C|OL_RRwU)>|fSs5_n_FZbTBGvlm!NtM#JtCwht;}tff{S`G z{r*|1a?vpPALXit(#e2bNhRr6K(4jf9h(1 zMBu+4g+jSao7(?aBmydpi$4Q2|6eyy*4Y2G4HE$WzaK%l@xS-_Uw`JzE@rVs#^ao$NyXNfdsO^nEzb|@qbf}KUigW@ZSn* zr7QG)XiqU3`dnBX&1O4?W;Ny%jfZBJ%6Ixj*0v5M1eB^^cL!E95%<3D>h8XU)kd&Y zb=Wu+iM6Wg22)Ck4q1XGkO^zx6jk*SI9`>6GsCGT*dtV0Oc5x&14W&KBJ5_+u{#;K z5(c3R(vb<%mTLB})RE^*>8QgtqJE3~!eSfz@zcijWtvz7TnD9!(g|0msv_@Vh-3|F zmx=0@gFi}{7y+1l5XZG4NIs-I5F7a z!-r2av*2XTBDare8VB(gh^`q-G|?3En@=2J>*Y~UbpQP2ZLcT#5jDA%*&g$*AV!rW z3IRWf1623hVFsB4VmHiXXMbDknVXUd29_%j@jwe`k;7gf;;%UE@9&e`exbMwVVZp( zeU@{bYrHMSDTH?#?3HU!BwjwvKKW^cI?W*ay8Zi6SDH;?3XhxN?tnJ*l?o+;a5O56 z%fu;%ubG{L-0e1rm-gpi> zm+J_LK8aKJ#UU^q!1e$dvs=ImR)M!cNN7Ht8{B{FoY?W<%a(lyMM<((n46BR2u6W` z-LY0{SRCtbEX{L6^>i4O!p$sYO_szC(g5s+c^U+(7!U&KK}4>8SuGmVRa??#Ujs{M zdN~5A-5HnHU(qWut5mNSakb+@MH(k)FG?oCo*xO&qR+;c`&-wl#N~M-S0A0-3tMe? zGRf?SJ20oWNl(^+>WPjghM{Gbhs%CU_W{ToP^n~9qnRuY!cy7{Q}pNedm36K0yCkE z5QQv|acYj&X(`%YR+!_d-Okv({E%4Lie^%f=M=5x7fBUpHWV|aH$d&ZBdNtqxGT4O z%naT74(ti+_FYNLuc2PGepTV^I*u$J>}Ny)CN(|Gd*UI~JxIKRyJe<;Hq+fdP7qjT63?|BK{hT)zv->0N- z`GXOS^S*wvzqK6u&62l+N72hG(G z2%9TvrlLazEiI-&^FiGFcGRL(Ns7)65mP=lSS(~eV_bz%mV}I3k)uFC&jZQ1KT~D{ z;YV?w2SnEFxkETXe4%~}ckjDIq<2TmZUiwLo<6gj%pA4*AgtTgKsqo5zYJ7}q!ja_ zQ>BjyOR&Hg#!`BDKfH?Rj*Gq&qS{G4&QYb0p@-o7fVCi=Kp!Ie1sftxM%brA z=NzMIEQLT!@nVwT{yT1WMqr7cBDs0(elj3j$RFgFBQoZEL2V{j{PmEP+U=_Orqqt9 zNsh*@`^6X=fQp5`FgCRIt!;?+=w^4B()mUw&H!pbQR8s?jbr-MPt@>mSFUd+){ibg z)rNtvu@)|bR@9c)#n@zZauK<%CH3_@T~c3H;g1SXLYs60GAp9UVvVE4A5GWn5SeXu z2AA@UVMPuVYu9OBI`=2eW5>V}*qwU$=nEf++A|cqZ#513`#E`yMS;%xASGEwrlvk$ zYw0WVmzqJ7PY>-C_fFLkEgAXihjLIIH%ZsJS`OtZoCPc zTb#IgJnQU@#~+RZ7oMn`$iDVA?@;y2ezH(=+tiIG-_F-68?{Bbue5;smzJGBu<5c+ z%EN-ce{Ds0l%D|R);wMO1DZW1-;AueLLTYl8aJj^Y|a4Hi!VYKEe4uG_`)*uomNh3 z;6Bj4dzH35P)?G;WHt+HzI25l;BrRv>p*JjPZ1%3t!o}qTm_e{?$|z`8cw_|<(W(6 z3#U(rzbj-ISHS*M2#`M9EDDC|y!`DxU?)kKtsEf|Z+`-8^Qz@1aaC*j!#qLTP{t!? zB8Hm}J1PcFyS?jQIcjCb=?1q%FiGU@RMks$`$F7Fu4W6V5d?(|l{O6+*e;5Z%dbCF z??;b$T6T*!K2u7>xY$iY07~f;;N%M=NIo6K%tjRi7g;YYHHYC+?!laCNkJq;fa^Wv@-%+IY za*`3=f5OaxOOV<4(a6&>a_m6%rRc4-BKKnNR(ZCbOt9*ZQ%~-E7>8bUDDBrs9br{Y5Y{Nn^Rd?K&E@)~9euq|=MmQIm-B%xQp*?o`g&2&aS2}!d*|Dzn)gQ%XX#AGJQ_Fdew!gEs3C0rg1M61nCB!Dmc6DSxQ}5In;Pz&c%X@ur>@3jFe6F@7g?EyP@6% zE#QWPGJ!`PTZ+6M|IPx=Hd10Pz8QL$2lKC5xdO#nj4*UHBCRmcUlT@x880L}opOr2 z7JXeK<^62Rm`0+%n$E7u_4(0;P`Gv3AY&|yS~I&N%g{eYVEw{l{jA42XAjLEVLFaO zz@gd9_BQZl9>Q@VX@tpDa}mPH%psn{KnD5KZ*DgUP8ubij$p)ZX z?ixuRz6-<+Y!9dVvizGGN@vob*Xp^y2MYR|O|CB8u|$@>8QMvYTf4-5y@mZct6|8d zoz%tkIe&W@MZ(}&+YK<&=hA^%6RdE?S7lyJITN5!Lt}Jd@8JGwN+JT}~J^ zhp28kt$vQF8DEXkZC6`;I_U>jO)!I6QHiS-sAmaut{KxJY@Pi+Q5xmh@oJkcA*5ry z^gYq=XD7{~ZCA4vV&7aLznVln{~T;3Hw$GKc~N(3o~zLAgwJqsw9bt>4wZ%xm3AVa z&TgCWxk~fba z0cm>W-a=9AGw~{F+j7~VMGh-EHP=WTxj84wfS_KoW!s*;JJA(;BDdjG5Yx}K`q|KI5GE1B$qQ!Z)&vy5&_?%{|9lFIO66xN4ePN4{>y?O}X!jA1(VIORYQ}Hh zae@=o(aSfd3K{U!UE9{L-*xuF)sx#81&_A-0;)RmAKgB#uS37u)5njhLu8E4O;6uG zN>Qw{x0f3fbVahL+CrmEA>?q>fjC3h_(;2GO4lBX}9m<2j`8Syn6HP8=^P; zET5(CpG*r|qP#6wDs(f3OvL5oZ!47a>xWW64b$SlfAZzrd^CwIs3ZG0Q|VV=2lzcJRF5nr|A~6-7UK^C|<0 zR-^L%68lRF_ZD_{#AQ+X()fQZJAr?5{?CoAvdx_HB_G#SX#beGcjfuJ5C2@q`F>IH zwqXn0B_D(H_sgC~4iz$c==^IaIC1`(DG(yXEF$Ufaqb$*rfH>vgPu&hK381{F-mCm zki&H-eK!zDW;Yj}NRJzYVDI?6HRY+w+Y=$XB1cv7n0euNicDmpt39s{(LJy7?EclP zV=)`p%~c~-+l(ipi3M_af({dcveo*C5d2iP0zyX-TTlcclLvC{@(hu0x;fs~C(#*Y zL_zg)c=j5^KeV*VZ-n_2V{wg6WoY<;~tse9$DeY+~GtC(0i0E+&eND)YjT za{4tmqbKC0%foMy2ZAj%o6C)50-M>#v{qZm;}NTZf&{<@E}6t)Fpw(5@qq7aS`k@D zgCIrF4?0yc=;=N07os^BL14R1N~G($9m5+e=sY ziaYcAAgRa(`0XoHu2kKd%!W__nIszXrX*qW3n&5~%#_2%C@pqMtd<6+CWo}pn+iWQ zE?n1wep4?n)c%vJfAwghqyc|iqOPteEgm%%|1@e3n1*y z2%a|SG4W7iYxE0D`2A;dv3NU68?uF7@a7JB7|7Ece1cea%e zSriU`bZ73tV&e{d_`7hUOBeP;Jv4kh^eN56t`_IcV48f6hDEyX2QAr+X3iqQ-mqM_ zVZGU@N*Ex812ds+U?1RUC+AU7mqP@%RmU1&@~nbidG2tja0i=G%4=C1qhSWiJ}o~Z za|Xp$h4X!yC66+Yn~1BTSglw=)OjT0Kqn-}3q*6XAtx5ojSi9xotyX|4Rp9pN5c4c z5h8}!4Hkddx6BEs{j{-5tO$}&vL75GNr{!>+NnC4RV_4g*!+p8Bnye4lR0?1;F9Ic z_^4;Nb1uOC8_oL_J29Xb4m8(Pg^r*_`7b#j%3J^-@J78LlCaJw@jPyZ0>i8;3A2W9 z_d3#*rSTb1B$46!;pwfBSbTE7{yyOxPJ|4@N%T_$zA*U-3~I~$XA%*9X7?iSx$SA% zDISEQGRbfFP5s$zl$OU~N)*Ywk1yV_sF_rc^S}&dZ)#eM+K4n6n!=nPeW}QOg?e<` zz*Dl^wNg=g`pe*pR_b3v?9PiyBBy-4&PkuBvQq&N&F%1G@I1=(BR-U+ShYm=$MG!jzOQ)=a}A)P)v5K-+Z1;+tb1; z@h!XOR{at$Z~H`kInm=Ez>EEPhxh+6fA^d9>YsO4_FN_SbnwUgpZC@ZucoFye)H?q z&-+_rS5JSJeFct1+%k6N6jty+_$P+Uur3THN5=m)=N&QX>`N7s_Lku%F#n8oW_9dV z+F921z*uw@O}!wUwDFd|qkj~jZgUy=I zu4p4c@_!h^)~0E}$Jy@{Bp4|nys?t(^+oGCzAI=8=OGg(Bb7vfoWU0;Lg&-{Ao`*! za~lv$Ft{Pu>nYoAnP!7M^7$$A$TYhx+(LT=v!eaqgv=MRyNLqE;y3>yJDW$X_7rFAiLS16nIG@pr+YH(T8x@f!iU>`tKug?;dz z1)i$=EOhQu0_laBZZiA@ju;ylaL=5RB^GpxFPCE`9}9`EJ%K40*K_!;)1q(9UP3fx zw743`wBRu0(FEbOWti38;yQJOAb{WuJ(mGxZZ%!EQx1plKJjopPP6RgP-QIj6{>%U z0ZFtuqEM#t7&0K|8R_D!(C9gN0RPoa1BFp?+*1H;1wT~!++m|u~9zikK}S7&kWxXj=$E^!BR zD4-1`RU=hVg;cr*v%?dL4s_)EF@@%BxCcL!5f4fD0M0#cr)S^CtlTF7G4pxXD)w=6 z@9hL0Mi1|-Y1Hg{qIe65+Ol4};d4eJ3~)SSV5&%U*$&N4Qm2C=5eyP8X*n+VXpA}T z9I$jValZv8zG6rxyc)yw#9GAoh-NjV3qPym9X7~+k@ zv(wBb(*6P}@;tmjWHx>YKc11kE!I!K@rWWDXo^NgHgw8iO9(}e(^P>xDxDM=r#_bI z-6q7jaZjnUPGo}V1YDeumv$R7C2!LHI!jcV4Q3lT;5L(#P0t)}O*?)w@w<1n-rAXb zkl^mlS)7crpW}%6oNHZZDcDA7YXn1BydK~k+Q?@40IV>p`(hN}T%vE3%!q?&i zN1}Eq=LvDsR@v~QF{FNa8Z1p!s1Ij>%C}(?!Et3pM#u?Vjve8YF3#l>2iFt|9rV%_ z+=};)*{3jd=+PmnYMNdxUF10DSl9$$?%`5r29ma-rDGy4^RdHHXFjGUA5GXd4ll5! zsi~2Qmb{CAq||K(L-0c^^hv{*L@5~?%oEZOJrEn5?rERs1^e7zmZ%n!Sqj1#0Ga~> zUcw0UJXqDa<}{IHo^uPhhhZ+6JOvdXEKWDV{WS4`KY)Y0b4aS=*jC<-9Ayz2c*`{E zKxn=mU3qss;MEj$%aq0oRx*SNdJ^~>Ny{7LeO_f$98TJ0S>7K6F)XJP6R?~a1Nw&7 zbAi+K;LSErh%58a%X_}z%yCq?HH+Fef!lxUm=KkY;T=6TDRDdk5b6XAJaIwN83KKg z)@on!^OS}5?bR*}bju>AerzZrhp zNg)NU6#jaf?H>#2NS!;Csl3~RCiEUU)vnwqc(jnPErplj4-^T5Mo3S1BE^ZO8hOG_hnt|=mC(9&7mA% z$r03QZ>Fy@56QH|`?Vza)y1W>q}*;vy&atVs)Zrbn&uiLz}R(Vtz{sy_1U3jv{V44 z(>pH(%H3$zq=I0dpM587n%o9R`I=L}ufrimIN3YsAT%*%oMAqheR6xtGdJB;QH27| zu=S!?nftbsZdPaNo$J`AUiPXRRB9>rEG99!q_B>}fN#=5O2=l1d~QW1X9jxWO@bu> zXFiw|8N^ytRQOU+k!^bqk9u^F+pzg=7|La4kxCBRB%Pd&&#I<8;Quz+@F|OI*GQS6P~CG`=B|SP{bc z5C>~MDid2|@2_%ZJXX3BZ-RrJh`~G)C;9N%7hMk93Cb0}tIafN(#tZ|+cRB8S+D?Y z-?di-L0OSyzoDOb7cXeF-Ohq}xbjnE$`&nV)}O;|L7esXj>mToPo}8s5KH0UXv9f#P<<;OJCd$KO_iJnKQ4mf zw$KJi`hyrE;wLI65qo{2Cxz_dFi$w0{%-JDAFk7AnSq4%a2F z%uA#+s@Adj9l}ZlwHt!Wn%jpC!NqbmyN|cl>jbvnlM$o*XyD(9z^7T%q~_ap;$U7M ztm2pC6)|HRS-I@61m+yRJp>e?)^EFilh%Vd1i!3bNzLx6OQ z1-Lc<8lAf2_jgW`?_`?}3b14SN)rE6hLC%#?Xp#!s?P{@t2h+PBokD2wj@S=<6r<( zq}cJzc%9*ME<1aicdB)miAkM0pbQ$Mz&s?17vt(U_mmkIbdfRow#six0o{fi*R+&i zq;g2J(lI}RQ9p*b8K=sKafxb}6LcfsQ!0uNmdV7hf` zu|B6KezXOWU#qm@6@L$-jW@x3n=1GncN8!b?eQ#Aka34FlCuv#XXWxki1Q;iFSm(+(xeX(B2Q2o}{O#&<>ph2=Z;J3XBkdc<2~6 z@!Gi%7eoA|lechYgos09&kQV%(}>0RL6Jwtgr?&F`f10T)>SVsu2cyz^7t>cwt|@3 zx8UA|;u_m%?$co{eki+8@H(=k7l|eUfBW&c=Z`#p3PEPw z1vWg)+;$zq4HwQ}2VO;M&Hg9Xa~`v6Quiu+VC9{2>JjJ8Et9+({eB;6MiQSpRqM{! z#MS+b*GFBj&O#SvgU0@jpJYTUQjUl+l&gdFP6Aaxr*0q|l*i>=pwh%~7k1F~#t>jb zuEV+cX_hTF$}==7=MUbMTnCl7=QGhUcq;AybNPMy*^b5Ag~Kwfi9F?Wrm!LUB{ zW59Jyxt|LW4Ixss44l{j^y|(PKQkmQjH+b@^t^1#KY69lvFtN63T0)}Q~!B9+}*z? zIl`r1^c(miuf5MUB3A7_fs)1n)R=ndO|>wFTo-}lzUWE*Ps6~ zT4E81Q=uiGuop^!T6Bu?LGg1|(r@uiZzK$aKkuBb3@2(LFyV{#CekUt1JA$v4&iM9 z0ity7$BX)ubSvTtQn*>~E~YYKO!&%+INe*s9rG6M^D) z2$RcndyK}kq*EpnX{v|&MhHi(UfZx#UQzprZlLE9V%MLKPNz_qEnf1vK(LZ-GL>A( zlQ@Exj{S(iuBl8z%XY(|ttjBy9ptZJnsnOm7514_FC5G;bol5=QM!&UYtFNZB(k=| z_&37XQ1Xa88^p^#)#>n2Tmnr!>feW}r2;lw`<)XhZZ9!#in%tEnPe^e@pTm~RN zB^BI>|2h2O!rt!3eIP{ZkY#`f+{VpOK$5oMy9Fp%TW^-_&A5`ZEz5ggg@d2@wZD9E zFPi=4Y}W%Eo_??^-nFiekB5b^UK|h?@g5=g%zFl~&M417VnpD`Hz22Z6=S$&RfLhlLZ@GVPa;a_& zf0hB1TS#Tp15P>kaF17~zo~fF*K)3uXPp`ND1;FyxG?CFbZtDrzc2btd@E7zr?WBv zX~s`@Z0x}49U0FlO9r+&ZerK~%Lh;tBNSZgb9e7=wUxS#R;eFG%x>X3NXIMbidI*} zGeZ+28q=3Lgl_8n)$Qnp&NS}AWMxNjA;pW8H{@P%>b5lnA%XX&6n%;CTnSyje#HgJ z=H+0N&cmMV{aED*z#+1?4V-O2^DLPD_*7+!p&l&pW#Oi z=p8A})YO&9C!9p(H`qU#JuEp*Ic5BanruAH0(88LeF|TWJgMT(vT9;;e=7+V(zj=q zxI6Ey4J8>EuXOgZEZ=mUUeryxar4HFbxzovusB{;QCVGE-^k2NKR({d(({1-!2ul! zT(E!KPIs1=vX_Q={97q9DgoAeNBeD9yj4@fTeAbsUUJR>%P$Ai>BPP?M1IVUR(d@W zK;wlwI2>OlS~%L~?sM+kK!HN3E{m)LIz|boZ0G!GjaA*9D(~N4pc$t&R&9EG;YnMZ z`m^HCM7aSnduy~xgabM-pFmIfi zSWPGJbumn6N5e(L*Y922hi=N33JQ#G-wzxo?R{c?d_J+vPW;Uuv8uzbUr4+S`jcRO z4Jj{-%MHUTe4aT88l6%1F+8|(^m5|`Hv$+$&2liv4yl8h!U<=3kn zsvry}tq#9=Fl3S%y?RqreOWdkrGD7$__y4IZQ{v6gGe_R)}As7Jr-4(fZ?d?HVC9W zyN@Pj4D^8#Rzn;R=S(Dk7@A`rNC;b|g18kbprdF{-3vog9}Wv(P;3g!Bbh~ki4r;s z&)DnG7c#}&`K%%-FFG;@^5Jq|iMI%SAa=@Ikx??okq*5r&{A7@jhPs93Rcm^KKykl zq4~n}uNP{yVg2enwbj2rmOZ}?F=S8H^_jW_&0NgoG4{QDUsHG5)JId>NQ}m@NYX(L zWJ>C=Y(VimOLPG`@*M+8G$!`BK%Wfy;6i)WfcUcU&b~yWRWhXnP3as<(1~V0mN!zP zQjoS3{0jIZmB_KjQpww^MJFh%K0Q-QDg#!wA*spCyd|ZseBNe(#klliIVkK<^9Q@g zQ_LS~=jHxNe(Ak3|3{D6FFNXaK64?L(Dy+qQsW@-{-;J0q?fWg=%`CO`y&3DSO!uu zp3@X)V6{dPRZ^#}189G8jq!r&}go|Z{pixj!1!^ zJ=HFLKU@%iWEp4x_FIm81bt4_z~zyZqwd!Mbp5S2bvNex60RNCdL4SoU}1Fl?qJIvqUEj%yv2*w0@}*6+7cdb9r;gPo%JvC{M7JUSx^bAgCp*70 zV`l#&>@n6brfqYqb&WdP(XwR}3QT$pg$Wi|@pBkc9Enat2pdoJA!JoLpK~Q;==e{9 zT%Zpj6@85XdD{c;k$&rw-=-@MA7d`dFSadL(;vvwct-?AmtU=pK5u*(ay3G~K0|-@ z(D`X;Ia!X-A0dnSdF1V#zkfX5_9)1GJmy~H?==LWZjBhM97g0g!Zm1V=$?c?rXK7O=0Q4~n$Elbgf?mg##XctYeRp=MTqlKWxl6w-e7-=)cxZ1+!R&e{|h#A-T3p~K8$rk-W)R3_Mp?rG>z|Tuu z7Lj9=x=Gx zd4DzjqIOjq`1Zln(v4npyIaD*i--+JHTa%Og=Mj?2T7)FCnmn>jwcQGtZ9I#*-J_f zeP?=zYz7)f#E4QU0(;*x1#M~2P+Aefn4M-N+6ThQDPzVKpdeKf6B>(j*hZ(M$L456 z0alSYdR!|ZO#qTvq2Oc}06`6ow}WNzk9ZzaV67--F;+PNSK(^P{9L`S;02+kQGR61 z#UK>(EaA72*I#b)-qKr&HP~wxdh4%U;DAr(Z>~;{Goxg0Pz*3ErVDiAECz3+#j8fmnfX zkw(eOthj>AAEPIq`wb~1JxBa`bk^5O)^GeD?|S`-G4k_K6lZn6PV0r}sBrC9LqnoF z1yTaQJ`ATb&qVW%2}(eX&1jc<7iLr?vb$u|%tpRbmKK0MQq{jb4ReRUxhX8aC& zIJ)P?vvQHy{v%}{En&6q1NBe7{`lr+f6u*lh5DyH9(ns`HSGS@oc@_V9}h;KAKZUU zDF5`4{#hoG1CBThLvpNJ!SUMC*xrj2ng3`<$TUU4f9bGIMj4!XRp#;r*@%pr-(KK< z0j+7D6MF8za6C}&O(1B+bO4Q)=PbjW=Ks)D#vo@N?;)$OzSdOlOeQA^RZPjk>Pri_+U~QQ!%eShOLHfwGrI>18=+3$el@ z+$dT*aBGIxJ;C8b9(RfYv}FgY+|K**Y%l9ZL15}N|Lg039L@xRrN0y68rJGrV$t7e z9qzg`P&`Q#Q8LGC3iQ&F?5t%;h{s!Ffo=A;C16& zzLYNl|F=bW#trbTx&^{UO74YU@pzK+1%Qv8hg;$@ji(%7CUnZ0Sj-&}m0RF@+~BC5 z*S8vf$}{p-oO39xaYz0=2+yAqm5(!#D;#IM35UOaZFs!99_Cv@k?^_9Pzh~vkvg|t zoD?4;uwfs4%cyD1VMHN@&#J7w0pJ!z-kKtvQHZX#t)2p2+l@y#947N(s$& z4StRX6}+Q~)l5V7r2|7kHuvTkZRJs;@WaK~OARw9+3t@_Nz7#+iR6I_#V^ zBkFK!JH4e>4oL?llsMg@2a7Uiga=Ey=c+}M{K$797%-r_B}-Pzum4*kK%70N!y%1w zAl26vYXLH!Y`BNB=1(zJDr>z8X45Z0SAO}-NZJVf#$jm;u5>!WHV{tdoXeR;lCq~S zUbq+7*Yhb*WwyYsiPz)Bc`s25(wPT+{C3UusYu8dN|bfD`CnA6d6e7-tVxgg9JKQy zIdv+1OXa|~V2-X?xknu}<|g&4DkblxNm7-B3f^XzQyxXXRk>TW9>7qq01!b`AtC@b z$ElBEusG6>4-|osAbo_5gwDjh+*iNHUn;sXEx1+nzHjd>`L9O0JUU-;dz#}sx=n#T zH7fX!WF#e3}(y+p6BEh=L%Cok*M6Wsy-PK zeYSmeFLeP#Gy~QP69{A$CspoP1i2X{e8KXrBF;V++w=<~etCf$w8Fe^y{Zl8$C|SP zd|qCMqGPLurSb9aL6tWacvUvq!aPd<3?O#Mjr0m;{}MKXQ6z3(ER$yl;NE_cXP8I2 zk_T&-HC}e?XDZB|=l&wd^LgjQKv09@>X4r3`qrW%9J>f=kpS9)Eo{~ovga6+0?|*F z6j5P`mUQ{gKU+r((Iv1i6fNmm6B>}sBJNVuo5a3Su z|58D8)(hkK2nM*-;1h+iHe^}AMO`IvsVX}%@HMxFR_RXJnJKRlUN;9Sw8QG#Y!Tt7nBIc5k;P_EQ+$eTEYy;kABg)i&)<-xMxF zJ5O`8lY9b(mOjtfW=p-w{?`ZVXQX0Ub?w>MORewW{#cYexYBQk@hj>F4n9@FvRaF5 zKL{7ROSKJUtp;G_qO@>og%iP@zz2&AW^mrCXOea@1+9tR{KK;A`-0i2v6dU7z-C)> zrKLR~gu`)MUB~I<8*uurv{}2@b#J)dmh7w7;>A=58{2X$_^y?V^d|Wi4pD$$<-nDY zw;ae;NAwaBTT5)E+Q9?ArWPL0(AsgHRtl88(5yOK!YwLB)?BPkEb91VUTLDh`Iy-d z{ywt*TRM^K+dTAUL^t4kc!5l(O~3Va?V}rYJGoJ4wvwJXRe5y+?XjfuMuvRzt%8mq z+Wl zvrvucFW18{#!n4A*JHpgzJg(OmK09qx}Q{`AJAZJ9qBv%Y!=foCB}Y zifO1X#x5#_BWPzA+Yb~Zps!O=2Sfup3X$jDr~$@G?~JwbXR_c}pm-0XWaPX+4yxY- zaP$3we-6mruV>B3Zd5FRUfV0{K}WH~&!?hLNM5Z#0Ovogm+hD}#xctD+QRp)#`bla z=UPFoJ1^Yk=aBcpq3EiL$nd{HXre}4j`77;-D${ORS?|t(s(pK1`iR&_Td{x^tF{h|zWSwsV$Gq>j7S0suWMG{^$-CtNT{eQ9FexukRL(HC%0|$IsW=%%9N2HB5UVN)MM)Oo z{^N|uLuOMs;)C1e$}PgmS8W46bO>@Ng0KMr+%fe;!_!|FI-LW0@N_k^b*-egLl^C} z9`Bcmf9F3v9n==9ii8wWA8*_;R0{1k)HihO` zH^$+o)Y4kz?9wh6SW5r`@jRy|c7N_?!n`Q8f7SEqeVD#>$*Qow(dX=`_N_-NovvoE zeeGR4j88E)c0b;5n~dM{dSlPK_`M%D_I{7w_h;jHD<0b#V06ARqwttn((YY}c4?X? zdB@yApxrEDs$A{hGq~~r{ zY{J89I~^d6<>S8uYdQTq*2V#II3>qeu>g+E0MU!g+}=xV!I5j$9iN3{RIMBk6vkJC zo@UaG1N-n^hfR_{n-~_N&s(`xj%4+3JU;lH&w|f6a82Cj!Fbf2Qt$e4|Bg-irJR~@ z9|;_5l7e9Nn*8F7fA?|n{o@sIeA-&T`9!pn!s6z>qX|d)(htH8Ala&hdGeG;N9Okj zTvM&d8>^G}G_d~PlPmwO`Ajgw@r1*nH+Sp&jGRM@-EfHbSP@KcOR3s4g#Ua(l zE9`&&RD9O#Xt9e{+Y zMn1>~OkYXMSAE3Ua<1Du^f<2RY@BYg+2LoUiVssd9)=sa5^3QI6Tp2qAz1Lvb}JsIcsrgS;}oY`4OA=gM!+H#76%4>}_#VdzU<-Np7P?LWE$ z)Q{V3BhNCj3oBuisNCse8S=g=cazleeLeUcl#-Vczk&u$?)@-Z+HO!m$5Gl@M;Mft?T#g*s5 z)BqR?8dk~3Rm;|$ANoGCHJ@_PYm}uh$9Zo!ZT-RF<}Ahk3Nm|>G1$MMtmefe4@3Asnbz1W?$4@wuVCsz%ZiaJY> zpKrU}D}E^n=fg5qLQn{%)md0FBmR%8an4gH)+_5HIzPcJ{J(I{YmKlZV2aB~oEnZ9 zta|YM&Y2R4urRx<$`P#bbX8>Vu0f7Um^|PWaSZmb+yuAfqguBGW0ju${9wxjuY8yr?j{bQzmaQ?m1=`A5ebu|rL4@p-XP2|*#}l7iW4 zQaGh1WM9oI*AE)Gh2Bd{8Bx{4#OBqe=4#-ow^jH2-{0b|PWC>naduB-XT1pq5yV9vM!Ip{{i?c;l4d5QBBo?<@Mo` zOGpjA6~UEk?k--~*)ZCao!VDjq&5`fgZ*HnL5>)iAW%Sw`97 zN;2B$y$s)GYfmNDm%#;l%xP=Hv2iB41~BjB@@zEVcnP1J>nrxPA}C9ZEjqhj-wJ6Q zx!V7g?~r&c<>tA@_TpQJSNW*M21hxCrKBS#Fdiwir@OAIDlbiWz9L+fi`Q-o*h|=LB z%4#yyiA>n<;3sOzpPZ(V3NiyvlNL6du$gKVhHv&sUi20z_%O7UmWAlz9<+f!Y<*WG zbIj*mp#it|tQKVxm0oDIo)cQsWwR_Mf!{wbm$1fy`>63mv}|*%&C(lPsqr(# z9BwMv>w5TNMNr=1=IUdkhg$@aF;T4zMXph8%?Z_S+S;#-Ms-}d9~ITU=U;BCwDd}F zR`FqTU_ttJwG9Z1kYI64F29k9gnAXkeCtsvokEu;_HJu?3LrLUmoG^gwCG~{)mGJd zbO`MISLUn1zMefQT$`+AQza(+An76C`>!dNiQt!qm@suJont7$jpbZ64VJRGWuQZ5 zLYW0TpdX{Nn`sgr&+e>aTw-yIGpnbyZ;;N;+UvlS0Z{qw#ot$Yj*LhT_DVl{87uyw zqL_hr!I>RY)z6EAvbLM5n5K#<47dAYxV1;xdt?Uy82bDK9QKU_i-EZxFt;6lIKhZv z%YKZ|7*VM!+!UF>QO7*Qt;#8ANjF*={65ghKoo0|kv__i)0ArpRtM?7s%e+J?x=R8oQv#7nG7Ms|H=@VksHCW>EQ z^Z4WVd$Ifmb~}s%eT{QlHy39L`iw;?(>NQ##pZqCp^(#mQ?f5ib|Rwz=&g~I$OEZ# z;k)5c%cZZNr=nHSWwylv<_vgJPLY}$2f(&+5QD>P4E@RxpB=-gZ1o$%r`RB;R0e8N zGC_@eB#>1ylJEC0ghv{f*Uw7L$wK_0*od5FfUuS6GArsW8D6KN03A?# z<7AhhL6c=Tp9L3thAc$2PgiJg5Qe>gfW32?(JV!x-lk?FtWoFMlaPNtlwN)h7gBGf z308C|!tH4^fuwhEjyVKPH4%+nUWosGsq^7;gqUst#de&2SCpUAwJLk4rR;L0(Z+GR zKq{#^g($yHy zc^i!XhOlL%4rB}%ty;d4 zcOpMB$tdht=os83!V^6G&GpDk4g<74!uUSKu6ql;!Dt2FLoTY+pm{)~5Laii0%^!9 ze<5J_?WOSAnid@^z3+^FJGdqZSj!)?FMQQ!`hFSN9p!DW>3rZmZV4v2UAc;Azg@`L z*#|3~mc1Ll$;8HcUNX8qkbS!A@%85Y&J!A$0rX67!eAVH(bmSqn|LX;8K)oIPyz@r~LIMyoz6+ALd+Da89UN{w#hi>!bs<&5s8r z#42|Cfz<2mBZs z69}Ju&k!^DwV4Ko1FE)Q)0N!K{tm`>B_ix0Pj>Rv%V&vClb%R>=sqhA?{$GXf^*n(7CUTyU(>j_^)zX&7xvffI<}Bf9?MU^z>M zWkNrq!HX}ww?1tMdr4=W>^BOayDAOD%0?j2Yd}~tL(o}FXp3QtW18U@kvz9O8>(}~ z%qR0OX%--Xvzh`%am*H6tCe;x4WMVnl6c z67XM(x{Z$^C{En!LZnEY-fgZc%#Hn>2FddPUvI0QlOQ)vBtFTde$vdsNNO2|wf%9- zX%Sp&25OCDe^R_w?6bSZhV9y8Q6+5 zM&iJg_a2hsl+M-C%Xdm0{jDa{<|$egDDc%pU7^}H9foHgZLAD>(0_-F-=*zI`_escuT zIA*nqiQF2ae-o7Dg5IUUPIjQxA|W1k6d>;#rTCX_h!4d;P#HmsV}}LEeqDeZQNK=7 zSc+)9EkoY@)7CxCNb-9x;FT*`!9Mga3wjNJquoO9W2{6WCt(gCbkiKiHq&*9s#gsv zRsuzNfW?rt#W^tQFkGJn@*#XFY!_X$G>TF#-|3J^-_xKJ)Ls$SFhndTC+)adil*SB zdi8p2tLTHaDpS76k39M|vvkF!rMWxrcT#&u&{3Pf^XZ^Gz0^6*`ZEUzStLAWRN-QU zdtNrrcx4@PVd zzcG+0$3htqaZbN7-Xx&*`&e3>!#ddQd`)(|!^EvsNEkQ<{_5K6%8DYhincA*_8E~q z1DyB%mN3)63G%(Q-Boxz0*D(zd@xkr;-m7!Q6iRcD;%V;jb&h~kUUq1uvXV&EN#uU z`#`H*85AIJZ6&D{lzJn`9i26Vyi?q;O+B2d3J`Ya_}K_yIbyjh3TDGXpz?Z6AMplCP{avM^1wb@vN?E(E3 z8;>n!Ko-lAb*i2+bVzKCadZSAL)ndG0KdZALW2%$=55Epu=0NT`$ZcHnAxhU1U$}? zcP(u$4HRcL$4ufo=hGd(HUHzv?BVWUn}EjZLPBIPR5fQFN0Y_1Ej0Qys^%p0HsN5H z@A0TGxF0R-{FzW7#k<5uMvIRKfpafTBAUfj!;L%4tX2s_1 zJ%N!u?4UQ#H6890>YfQQKOyjD(tw8E^5>m@$Dz3F% z(5gStx=WIcfdkSf?4;VN#a96T%b12}pu?4w{g*>TK!{a*E*gz!2i8d zV))3Lu#f#%u>U)lJHt3{0bO@Z$tjAtyiJFbe+~KC!JD-?t~CBgWV@fD1W0EHsR7OQ zhcREO71p$_2IdL2oB(*aLelxbpZ=>P3h*!vR9HA#K0rKbgFP>vKgo)_Nd|FiID5LW zA0E4h;{Xc@rTC}`ko3l#hhOIa9>4LI2Gv*^y)URvDW%BNs-_`{5i>UzXmyo`CwNjq`b~ncuyQ&4b&Co>5u!v;FE2tc(jUKAQ_Lma=r4Jch9nghBv3<9 zDB6=@H_6oig(kP0AuQ=7Cu!wU_Q~^_o-?QPDA!#)jP4GbE}z8RAJ?7Pp|TaAK4u)X z8K8x@cPe&-i_slu$p*gVXeM7v$KB%+E|1HOjT;m{mKT~((w$J=GogBP;z*}(%X0Q1 z!_!fQ@E#tp*#k-Q0Nu~p7HJy0`c+EAOy-zbowhm!%MqgstW9zSQc`&;n%)~%`29>&{4V$^$rKcE?PMnX1b=6(arVUlhyXKzWf zxIFqypJt(x{yZdB?POr!@z0auzqxp_QAx;)!_=gtv?+7aizssIZTyU(sH%GiL})By zlid#1=OzCwcp2ZNMW5kidScpgUgIQs8zL~cSiadS9#_6(Lnt(!3~`n$y#Rk6LMWfX zJ_bO3vuHW`B!f+?i2-}|2M(_iavJrmcsV(7m-J=;{2JgVny1@y$wH9u-O+lx={*Bh zm1t2aLH3cmdQ|cY-SviyLfy2`+qjr|yK#AOa9>Q(w&#H_-2(U5GUR2B=Sm!}cQ}~3 zwzG!Fmyb6y+a$xMW(j8PdQaM5=$ScKpZLbvPC7-MRorLhdMVQQF;4ii9kY2qkQ0|b zbt9zfv;050Y&;(z@Z*RDnmiAe!uA&#^it0W@j(Xni{Y|l zw?mJ^U)aqZ8|n%i&>m0f9iw4|9F^uNsO83tBN~tlPCwPi;MY2^Ebd~>l5F=z8_R?D z>B7;;pqCKrAq5q(H|lzLuL8q35Vf-&CsqJM8DF+pK%=_eOMWc#*lu_kS#G2-Pj-p5 zKx+HEH!t6DGz{z%s0O`VL>gJfI}L)O?E(lvvP4i|(dJ4JBo0|=t(o3hMlm%&Gl|CS zErmHsz$1xDG%?7}CQ?8UoadHbKj!gk<+;&Ou<+<&BpcUL4}XTcYCFAnacwccfhF_! z4iEqFcfsBxNliPR)6I_Jo zXP&whz_qU~l2j3FNeu>!fA~bbqaA%$I${j+mJ0+8U>f46Yd4l8s5XUOz_#pz_wtH99x)(qiI zqUhe=H6EKlqN&i!BG1?NFN-)h-CwF*I=yJVJN+30fYvadt!L+{#z;*xx1F~kBzig6 zV)nGBGUuz(AsvpvnYYGk`37^y<8%AxaDsL4AFKIw^vvJ=xr)y&6l3seqi`>~sQcV` z_6h!_pMTb_PqpdZ2m0aHFjB!H1x94WwBH_*XW#0A$Ec`#t0A?{uU-qCOTHRp%yWG7 z%aK6o5Oy|UPy+fJC3}Nk^fUnu#j-YWpGa%_l*zsZRCEm3+;3@@&v1bxk5=6O{8Ax5 z?ClS~4iNK;8kpP{^>yaIHSAaRm!YOI-RB1Ll)-I50R4~~MqEo~{5%Ce;(gSFXq=|^@W3Z2p*n~GJGW4hhrj0_hi0rn#U1jPn6 zbiO-#crg8;QFUFH?Ii(-g2?y?MGHT)&Fp8PVeqmO54Lx47!XoSCvuHwYg>5wzhD%P zQOPGkUAN%n*%ZQs$T%vMsSHbHUEdD)b`$7_Ao1t9YjnG(8mA99o+j`ZK&QUqCuasX zq;ID!ZSZ_sN#^@(jFn$}FC*CBf~Y%j>2PV^#2&sVwV-u!9i|8u&{+d-a$pdq9{?HY z!SER*Pc4T!dV`WxTtw1j^D-Gs!T&LIChkyuVI046XJ?GT(AWoqu{PF_>W+PHNTpC? z%~lN&TKw)XwroT8&?K@%mXP+bOOmb9uA)>bN{dqS^Buio zheL_84tmUH)p3G_;xxPxghJzLyl3p$upvt<=jF+ap-%LmkC>N#B!6l1ucg;Rr?pF~ zgKhNznj3K7HORXwx6Lh#{a<_&@`ogSE$BuKN=%8R3LX@NF@y+(S%{Z}t%=#^EJk7P z2Q|HCr;u)?5QG(*0qx8ZR=&MF2Hv8P8EULf<;r{swd-F9n1_ha73gqir zB(ZyVSbh54I91cPuh3#OgIO6C%|7>{fN2Nyy)t3zD?4hJ(tG$}Y3SLFTPwK5DjZqH ztK0_D&>nbtkM3pV@ZbNOc7_qUkY_5|n!UqtvdZ%OSg@)g4{y z5Bbaxsq@0^O=KuP0{`&wh#qxd5XjZ@6|JU3HKOO9*h*y23nSui{SH3S4?_?1O5J+B z7pgA5<5FX$Lg;}AXwLlS8xNXY4I0ZO1|y;@r6Ebg*IgTsThtCl9&}NkCKWJ-yV%V+ zb~QBYmdl~XpG#eQjr`dES7p8`G@{;!cjNQHsKRUCf9xGV$VRLioweB1$R=YGEyrg3 znOS`Nh2Lu?;ywpzWfJ(F~8+Ubfx*UM+8J}fofDn12) z7f)Y-IS5V8*WK{!A>DnEsg6FAT~{FCXUrYq_}5ym@o)9w459vq2auqX; z=%+hk_ZGv}bRi;Q)&rx@;%=H2fYQT3s6}XK{S2pYYqD$-WG$%OujSW{r#OxRq>sRB z-pj`r;xlQ!jGTR{Cwy+3t`L(v%r~%?$BB6q+P-8Ww3ASiq!&w=m3gP&4GE*OsIv4G zc`8|pU%MXrUG_T;PRB^i^hLxD=zD!Rym_g5LWs*i;d;c-Ftn+iEssx_1(kubD(i6o zAP$Zn%h0x<0k@76tJ_RXC8vhj)P#;?0(3qgsT)*hF+7SGe@YV@wm_X61Ss*Z_AC2| zq>%p@tyVR-mq>=75K-Z5_yCVyd!ca`nT$lYH-$-8zK8F)owqrqWS8o}LO8ZN?UVTc zT%>a}k(>i4To_ETV<50ghuthzp?}8hkR5?pv-_HGPYkN2#&iX6&0-olp4QS;5_-^> z2t%q9OtS$I*M~ikgiYAqdBK+50wFrdlbhz&YK8IHd&-(Ben22Fl9+)+H_rJ)QXw5j z6YdMcR$)S11oxMZEt1eGa9Yq?Fp1dsJsi{77v5Mr(H4>ySJ zPgqWfx(TWlziv}vK z&1eKgL15&9@V4Bl+1DQ|30rR$%+aOn)?zUE-JSeMN-uy##o#^%HlkyU%za3zr za9Lr@XA*>10rE$VUXZ=so}A%};8Eh2BXs_ojU3Sg^U0(W$UBySf@Z^3bpY^bI2YlKC+V594K%+^5?>+>3ZHw z1RWtY6LP`KU{Xs?%J2N=ugkzOw*|Va^%+K;`s#^WHlFg%LAq16M#Unv)!F$ z#{z(3M2Agbjw;Zc^dSRo;`H_`H!3l}`Z_Ribcp)z+8O7>?+fCya9pPZaOgY~zR1bM z@4Wz?tb$+abHstdN$0P(;@t5pu9^oR*D&KN-!OIwcNF+=Mw_w&BmBxY^+Ia8xqMW% za@9Q=9D-2Z|4~L{H!v?GWS7i-YG1&JYkkj_iqcTuci?J<9W|t(Nha~sm08DnK03yH zv+TbYk*-JfRe%0wEr$#f85ZwE9?yn1dk-P3ht{5LAF2h>1mSYHN>Iq-r%+{+k{~E- zm>9h4C0jW~iCWyrpXB-5M3o=%?m-DDYCsP7^=Wv|<1&HC0A@oEygCgaqM(-m z0IpSj%GoR54|+-2Xn6y|O5Sc|2o+o~3Z->IT+Nly%UAo#zEe;WDM-en^}P$47IvLu z+0AEE2xQ`4?W1=fe>A|lcU1g(5L+uM#r$B?CY3J(FJ?Yvv;2i=-6b~t?&54f$tu~1 z;p6-fmWvUBNw@&p`N) zG=fzv2ShOMUeCQ|_+KYk6gMSp{Y-M<9xe>!;|J?0_4VEG(O*PD&~wmDkW3mgjKE28 z)QT5`Gs{mcI{CQQ50!5LjwGn#S&ErhN@cJH0=N68l*is_2(us{hEZ5fDWT)G|42O^ z2@vLlKZ4=#qO~7CD6|^v!|c(J3IjrY&SO~`7B#A1EYy1)kO*of!M^ zup(>tu2nT`k}Y9$vfR)b;XRX~&fiI}cxdVa89zf{9V~;D?Atjn z>%(pEtPK&RgEFM@w#>2sXl4V-q02fWlf9p&YiIIIWiLe>L#864QmrQKLo!6gh5Gk) zc=V=G-rQ#Mb}h;U2-k;!oL%!FPATR{CVV{KPUC}L=E_;$)WT4xm_(qdlE!xY;#V-z z!U?_RCwQTY1q^8oB)`Ysm}&KyhsO00Xmi?g!^w8LO;!3YFdb$Ao$@NvXM>k!R+8y%2|po_a6g!%g^HE@gg^im z7_fHtBe(IwIT%HN)2W$+ah&R^2a7MG*o^B}nBjM=o#4xrTImtQj4~34-mLmTfO~;vyHNr%rDFv>u@P`=^tBiz0{vwoH=UX zr-OEyYVQ`0x2_#o8By=wy|JKSdTu0cyzkqZ+Km%E?RKvH&*~YA$tw_|;W37<*;leJ zMQYyGx>sB-t8wcy6miF-ccU#3NFQKanR=Y*yV&t9d+#+OFZGV+J{ zqm8#lN{d}DI>}rqZ8$v7@2t^L>p$hBpLcEArpqVXa{%sG2;bD~(pE#~JKrBH4ZoN0 z;jBV-^>C0&aCWDm)1_U^RaW@E-wpkC;misr9>~-CJ8DDNE>i2S_$TpP`Tl3B{bjnu zRM8MaZ2J?t-B?)p9rfN*w)Cw6m+RFRdld#Q7mp;GE<#ZS3#1&+l_E2@Q&9#{?}CA6 zJbv_A_Q$ZM;avSY#Z7mr^hYin2je0Q8WrZaF?O=k_$#HvjZVf7p~rihaR!v^Lk{x=OR}U-Jo41)dg?6&p%;TmT^m@LBFXd=y|w zneiBgWL6KL^u5%|pt@h4=nT;S#^(uoseySaIsKTN5^CcCA8-9?M_&fH7C$_ZGvDmv ziee?FLj9MdoM*?+ELNhJVy8CPWQ5N2d>eOA3rD$&si#;&n5OzDN&(V&iVz0Yn|XlKKcYmtrac6@L$O){$Q6aKYNfp6`TC#iO5J%;VZM}fS8Hl;$oeFg7I zv!A9H_e!U59eZ4SCHhD&1htLJx&Aajct>{Ov_eN(l@*WHHNV02kmm{gnF}Oj)@6F? z;RAv%l$M&e#nD^Nep&rsB7s=(JHG7y?y~PoY59_k`%3hg-!9?#NIY$&A$1}og85>3 zSo8$3sT&*L-)D~;v1=M3CBafk1ClK~r)`7B3VWRWUVpxp^(@uMyXTx79Zk+I-{IG@ zH?``Rwe2V+U4XOQ{%&ylMYza^(?|P!fne`{9P#tQ3!UGFa(~LNp3FEM{R77~!;eY^ zN!nxw8fIFL{7hc9j33uktod~zj`9UP|J8(Z>}P@qx$xrKp7U3>ig#?e_%rc#oMMZ< zz03gfqA=teqGeVDJC$Cx{4d@&zD+(j`g1{i!GUI_cP5FV&V~zXmBj>`%n%g29y-*?;MZp`nC5qp}*jwKgdnpk(c_hB7i zWKq}EYFWLDVFOdb4yEkCs2N=g@&m6!sn?v-E1Vn;gmF8Y26hQ{*1d&tL*N(RT+{WE zWY~@TzSuX%6Rt+w)g*ot6lA6F!WZ@Ld+kf@&6v2g@V=2ZDl?Kv8948haXx=wzPmfC z0KN5!kf`q=-f0o}?tap6ZsX;Vt(Wzii{tg1KwGpFZ&~UX{_x^r zoMO&^`?i5Hm;502xuh{g*XE!9ZJTxz4Mc2zmAq|y@$zcj?cXii{#?2Jz3}#*q1$Ws zZ@(38`}Tk53dSqyrlo(=0W}2HBm413Ifb?O3-4=Vx4Dlg=fCV zt=*B!^v`C+?rqsN2p%^G?p7sewxQ45Uob6~zDfqFasw|hNV+SDt@+!X$PQ=;X;oPT z37HUiWeLoFRTL46CEBXsWU&f(O-w9LQywEBX-$xkIAe>JJ}s-R0^lVG`fY$_+cizR z4238rp^QhF>0IGxio?JiO<4!+E1P6xRST?*D?L)`afZI-DeC$Jt)YF1vDYXs(^)Eq;b@ z0nnIHuNQWSCMgil;d<$ZHaQagJ` zy5lZ6=pSnq2*@St2Qp_rpa1ewc#x;GituPZKM^Q9>QL_A^Jbc)~z91&fm#vl}tm{=Nl1i;T+w|s2@%#WkAs!<` zTfR$^&fuS+DJRI=YAFRf?d4P){orY${y-oOx@0O0p$D=77zr7faWTy|_b9FRFbl-V z(uF^{h?v)0VPKhGD}e8z^VJQNP6!a1#ez84Rur~{Q;L;e3hHTvqF^m!EnxraXj08rd&F$G5J1Y>{UzPPES!QO zS%!QjQhp{&8}+Eq`>UKpjD|xL?){juC2ltcFPK+>mvG(W|Rx9uIdln zLsKK@dVoB_ADJESsb3Wo1qAi>pjLHfrUGB9ueSmoL42O!WlHp2F)bMa4id;W>JFKq_>&-^kIx!C|9yO4m0PdYv-No3B zXBa&O>gAJh`84cxdfgw6`NHLx~~8tFcb384FW)BDdnscHDCM!D*;X2 zhfx=BZM>4r?|P-Ws57wNrlDh5OhBpYE5c>^fYQ6Cg<`vChOSA%1Wmp_U6q|3+964d zFYQNmA>qH|I;V{{h@e8~FmXweJ`)Zcj5Sq8N1fY_<)99RUDdZRYF+=FsA9PBy87_w z9mxrHV8-sgoD)l^`+ExTe+492C3|`N5jDa=8Tr|U_h~Ut=eH&B&#(lB*edD)JZ#Z_ z?8y$a{pf&1Wk50ctIbJG_?c|&j}dA5H;xP>0d~MS&oW>@QQ&Cewn-m%BM20|Sps@9 zRvZjmsEXnl5Yj<`D?3^z&*bCE1P25K5L;3=uxfzQo{C=k2x4%LwFf%RZel}mMcG;T zEDdQP!im(}ho*1iAXwIbUtsV{r;1mp?b$H!WXfKj0Pr)xdy1F&tlqtK1srfGF{)SQ zK0lFV@?Jlo^hR3phgYsS2S1c+3sn$w7OW)Ue)?1!AerP?x*^xON{^~foOpbOp)cHj zWGY9`-Gy5&%VPf=Z3<1dEpQvfnoeB?QX$ty0*o=`r`Y!Y8iubtZvIj;qFKX))lS(L4l-p7SEsIcz!IqWKz%Vt7O&5CHQw^Np|&t$a1f)goDBrL^)vvw ztt7G=!x*}6;Bb@Wi?spN-Da`~7%$Y5qWZ{3hA@pQpYApCDJ<)X%dTL+TV!z6(qtB} z1-GPS>Eb|et=xz0$khJ(jJdm#a^OrTjw`kpr#$>T64g$@eev#B@(|R8mgw7!Rii_!iZ1_2=#2(*4Kq2(;sLA#g}X^({tZsr)Qga}BFL3*C@X86x3s28 zgMvPb)<+e*OE$h=DnHlv&JplY>>5l$A5)}dlzGeBiy|F3_dQyRA2$>rDdvO+tsyU7U z@ms{e{jO}I?2m;Y_DQ5ELns2W@2|aS`05*F`?%WZ<}*s0PxdKj!9=i;6iBnUO48aI zb8B2MZ`ABR5x;4f3T?!7h8Y|-$|a&$)`}ZftHk;_OHlR}#SObRlbVu}^ZIUn_H$Gd zduC{BqJ{{NPIG>uVzb<@l?`spla@Xa%B$LV^7Zj}&_s{2G=>~9I`S~<-KR3d5o}wV zXJg5Fk^WpBgfOxR_NsO~oO)VcU6cK*vOWC%pBMLqKZTOS*cJhICr0$EXAkPf(Zk3l zBh}UMby@ZK?SDV`ihj@BzxCtF{lA}&i~h_?-u^YT{omI!qP2zFJ#iO4=2sq{JA9-B z{1TE2o?ZPWwz41bEwELTDO!0+*gy22DE>tELmhMm`eR8_#}H1(OX=eQB}uyfvk8Kk ziNrmIh8e(JwcpSfqe5KZ`VA;ICIcHED1F4nz2I|xIuc3rWbs(1-zCX`9qQRki^H^( zU;Rl&9C$5#-RqEo8|H2sPSU3ARR0Bo1+emP62gmS<78*Gz*X)^VbN2?hJ)qDgOd0t zr(fJ>QdCj`^PQ1Hfj8~Lt5oza*kOU7<`W4B-LUOErLfewDP;GOQ>@=Ask@VaY7Uh&0(HAGl~2%WXGXtRc&DWY5v|iaa%Q z!$G~5(s{}C+$kB^ile!74lY%ocOb?1Vtv{k`8*Yiyr1ikFr6nglS<>tMO$X>P0YvY zNJPF*%^OZRl#xlK=f7G=A?ff_%2G&s@-r;c)(N|;hWsDi0D|hgSZ8&QZ!A5IXoCu- z`Va*j6hrki`5j82i9f|&HEsUDM81WT45_fppyDKMT z!`>tmo0P~sr&mQLmP`suzAhx=R=J;-tJdgTWPPa}C6y4K8J1Bh0#~{Jz0X{IUuuIY zJIwH>P=UiF<4&IpG%m z&gCHXS6=#gKFTuBna&+ZRADbvBJfqfWXAil8r5?Ck?7i~183P0JYH!j`RZAbyg+_6 z6ST5J3D{^S;0ae;xm8elhpcHCq8SZHH|pAmosOwKw={x`{t-=N8N{}i7JT!D_ny3nn4(=SiX8a++qg+8u1Yg^QhC(cPcD&rlguW28t zNp;FgTs^y2jGx}upsZc8wZ6);JmtB3)%I(}ga$e9h|+&LXYXDEe>h(V>%Jg`FAe>D zq5J`vJh{7wEO&Pq-INSqI_j{mT{u*|z4HjU2&RmJ@_nJIwV?bt1!30)# zfLHTX>^2!STty`UV5D6`!7QZDlcRG1aZ5;6hH_fYT?+5m zQciNSW#j>`w976Z_%a9QopO;k!mkgfXPd|QTOKbE9KIAp^U6%4o;fe>ysiY(@XnO0 zIIVV?)%H!JdiQya(`>o2tEOYE4fYuq|2WsSH*P?O7nlFZ<(K9QbuV~XT|Ic`sy2n| z`Q)DpsFH|5Jp4XBuzcXRQ9YE%L4U>ngzKeuoqNP8IzwH~QV4^?R)Kvy=wB ztOvaP2Fg37Wj7<4+5b(I{I?1Cj~nz~V%xyDcC<2La973PO>JC`fCTyKJ>i0yQ~c_m zeSSi|Fhcy70F!t^qSJ!kBL(edOCGy?Ja2xxG*w#(krMH2xatZ3Qpp)4KK&RNU^BF~ zx%Z4IyjEv^0LnYAmH-o!L}?_&F^%JUl$=!?hGlw&bJs>(yVm(ZlsE(MT@p`SA-1v3 zbcfm#qr|xO|K&YgYG)af4sq{K(u3Nvx z0bJlq)&azIUD8`7&|09G>W#2oIvA2K@usOWcC&J4uZ#KuP}8CNa9q3$ZZ$E@>F`OM zg(dZVNeFFs>A+@Z_d0d*hs%Ux=av+{Yiw+WG-F>!eGA6nL6WPw#vt}Cb&ExNg^iyx z=^_%ikuARUJb5nzwB{COdkVktaPMvW_9j4X%lb-G{C)=TYMiNlepBPZQEWdgK&_HD z3)I)^Cr-%HNmdS%bxkztjqiocm-nYaw z3@JfbCUwJP;U`63zRfY2x}iQGzEM}FS(jH#uzSQoj;p?y-Ef#B=t8SEcHb>AR8h}p22nwpV+$`ECk^w9J$a9#&MtX+Fh#@2yL15YW7U5h1oWma~} zHEiS$0el|XN`HEy(OA!MSxAo!G9&zM4?ZT7E-}wv? z$N%x~=Tas9ph6%j`{;w~egZ+xDGn6UGmXGKF^J(R+yN{{mVBu;x?M6)LpYl1K@wwu z-3#`XJu*AuH;)1_5-)5c7Bvk2OxV_cKnyEmd=0Lk{z@G6mVDkvOC@1K23&9r4+~As zRTaH?*4zmH!Ic4z)Y~@Sy$3cbS;tI1Sz_#dG#+&tHmBszD)E660j%gL@?uZopTG0r zj7dJVMd|&`D)-qolGZhreFe}Gq?Qi<(cIy7F#h3mAJ4YVNjRDw=o^pY& z&>f``!cQ;pBymi^>#mGO04l?^J_rz+Sv*{zdcS!^+63LI`DbPnb&oPFGbhply1$IS z*BKX+`fR4%I=6XZP9W4>06qrA+*#$$msmU2NW};NANGrnS2x`)1rXB9p1V8HZR0Pd zKfTkOu#az@JxsK@21ZJ|0`C9B;)%*gVY9B9u}!LYJrP(7F|sAS(~YJgH-i)vmrkER zoiS%U6-RmgyOZe8FGJp&CaId=B6LD#R{u&~>f)l@Pg9KEygHd1LG#m*c>$B~ah>1+ zqclj}yyEwb0hiyEE!dDx9S z)!*g_@k7_nor|pa7WUrgmH&$OI?H7o$h2lh#`aidA3flbWp(Rc-S z2|BlQQ5%OOOb`LFS9)lme>7kzrK3Jk_Q4j{(M`0^3fpRD7K~9$C=ipNUwPm|_a)1& zx+|WB=~9Q8GPg+!7x34=$(kWSQj8ijzdb@BTkMPcTu**ND5d$H|spbf{~ zw+=l1VDnSo>nGx4Hj_{TfP5h9n022Jf-urpwe>MBy>nj055PkQt{W{$`&^O^VGp`< z9(giii{{Z20Qxi*A8HbF@5_AN%YTTvorn)_(|%Q!Js1+{vP^!A$4<}IeU=2zS`^eK zRySO0Z~oKUWTMvzSl-q zqqd%(vNF5=DXH(xSId*%FTVNqd&})F+g}zX5HK-P!EA3S7OkADe?lrLK;P~;)q8|+ zO5b!o>R;-q+}rL_Lr9oqhsIAJ3Y_e0BlWBlOm>ZmyWNk5L_GJkC9QP2)3VH>Ndpyu+A)9nii z2EN)V_s02fT8$)%;4}jT*kL6`4QVj~!TLo)))z%A)q;K8%vlvEffdL^=93jrtF3SP zQsU@QfssEyXILNK`riix2eR=1x9KV^K#P6jM0s+O4~P3088b8HZm}eRxJ=Z-GfY@q zs=FG{tqd)>*rthN+zO_a0>Bj2%?V^H7T3=Ji>Z$J{YO}kPLNfmePus@z*YryQ>b{j zG>wQ~1lM_ZlYPnn!*2HHIRJ$LC_eSmup)@YD|s(1QTr}lue1+#`dDUzMVf_tC2azk zSiDgpW8qgG@6#Agmn!2Ltq4k@wP*`B3ojy&^3|q?9ck3@uW$SK-%=m_!s1Ch0@Sz@ zfx##m(!%7>Liiw2iL?ZZ>}6|yFfno`G??l*XlZ@c6as}HnZSarm4?lFaFStWJs5dH z@`ioQCufBS84^o~Syz)2L4uorsUhDKs=i4jc<1YVDiXDO$lxsu+e3=Ndg>p)2q>WK z$9*7-4GV_C$YfvwyhmwWuV2=y2UfBw=+~z{091Y{E&gUIck3pFdLIT z^sL)A-}C@P;CvuPTp#c>ik$Y!EWvpv1XwDP1vJ1bl)ocmYpIWN1iF4Wuh8`*;I9$W z)~GKE=bNHr?WMi7CD%JDeD1AUX+Ac2#F)J5gDo|?geC1apDF})+D}RNHs{U z;pW+#!9KJCas54}P%r`^2{Kfu1SxovauPec_AboAL=KHmR)Q)AJLP(~IEuFq)a>@% zrT^Hq;LA+y_9c$cM(i#f47Sit6}nbES2H*yQQ`AwP&wsO*(C|ARBd-B1Dd~sq6t6S zylf6S9Xnrvgl-<*Yj7X+Y(@N8Ezz{`lQPap-v;~4Z3(_7dxaL@_{um=C^~C?XG$rN znxgVs9Ce~p2JkeUK#b^KUaLkEG^pQ*vfyO^wX4m>87*b!Bm@-(v?M1~DSr`|?#T$X z0e5lzULyxtYSt-nLN(OlvuU!sg+QnB+yjH&nCb5NtB~RTZ1B!aQjHPJtW{q~?dfhd z-tp*qyc_j!rqZku)5WAV6`QSb1~eB`sCTEaDWz%dl=zzm$LGMU3HY`#siIs6+^haN zn;4~WO)_~L4Pa z9FW_W@hPJx{mkK07=q zutsw4C&BKBt4O3e$LxOWTTP~~gk+IGbakJnm(6*m<|87z<)NG4$eVzncMjo`-xla6n58Z(;7;~cn=bg#W{)DNXI{z zA09i#{W!bvPcp?`2wXB>#>gnRC@uuEf{6F{tcvyDgGHPswdJnZ;S6ctO=d z-=6?vJfdK7osh9krFIQAAx^WYdd|+k9`+H`Zk-u|gdhh%F9l0ed8lw>W??qTr$M=< z_C8wZA+47pZY>m@(_j?tCqTK#owJDN*QI0m4=VDPjo91xj{0X(;4Me05wae;uosv- zX*dCO;uxH{EAtL9Hl**9b4<89YeC7P3@r;oJnnA9UN0e-H1aV2i+8@GuK*nM+=y3V zBUB0)O@!8wx`3M>(?9FyTfj#u?oh`9X2$5!!%!xkp%pyJlV3|=X9mmnBs~Ko=(!1n%?Yla|onjs;uh^%65BK#Z!d@NMuKei|H`q6gugF0V&A9Sv^4}id zg-@hP>l=)Y0rwqyv!&1c0Oej~#e2UKzZ3?HJ|AN;KAC(*k^rE+f0x+?BLs5#lArAO zsH9e~+YLJz&ACv{lKW#S za@_!Em5ckj)+^-r-LLzU(q~Nuefmo)hf_*<;ueG|4&b?zd6qSMGB$O&A6JE&eeoRL zUyHy{mr$|wd&z}4KG$CT{E_UYm z7i+>`0P;Vny-Q@oHT~&fCkTz&mqEqrtrw*VU2W$FeRvMYxjanG@PAH_Dam0w$`RXP zb2RGI*OL>HyVB#9C_gh$Kve$JsGpd{88o2}D@Ze?32#*b6hweh;;S9^22i}=&CmwJ zj+bH#WwGF)NA{GmJusS4QaLB)(&2v~WjAaoEX$PYrI5!8# z@j;h&PzZcf?OZ!(C zh}h?UC_H01%G1IQQ*r~gv?h7l<%Zu(|HQYoZ4gZZ90_}TkOaeHFX~ z{8Hb+pH&WDmd`+2Q?+^qI1#+?fncN3j3d%QFud%61r#qjLTw~^%%n>sAoAw1WSntL#=L!z^@95MU}54R$gFL<#~PcYl2T*c z7r}7De}d+kY%e+C_Pr2?IoXka0R#yiSk0DF&cLmy^oQlC6f0Dn@H{Y_VzGC~vnMzU zA!mZgJWi>#b=vPir!o(XimmO%TPo7#Mvg39ShsSP$BkSH^FVhOrzQm>yeD#V=YBWb z{cqSSr~-$nk)N#{rpd!cephYe+nF(68bPC+t?w>NL0JrT+#z(jMZZ_BkjABZ#3buf zts6ZY`bqYLA`;z7^O(nEB^(CJZ`bbBCeYA;1zZ`BViW5C2B(6A-0My-XgBl7T?ou5 z59s5~2H#){MKTgrk0kBVdJ;}y3S}G^bpHt3@rn#jH@ordK4L%X8d>b33Vd;lw>(;P zL|)7~&dxZIO#YnxHoK3*dtX+Tn^m3pwKNwt-8WxT5&o&7(b5J*9UG1f>jLb9gJjk-+T#o%tNm`g&5i0WX-FnIkwLBpxT&U~od)R|R9Vi#K{dUd|aj zOX)*3sr#QO2j@O+Y1m!Vsw?cMwrkoQ90s@UoVNQuBCyeXupAL*^{%=d`1tA67O6sA z#-VW{EBrET37g~g5p)1_@5hR8CF5l%tG=)3~5V3xZ zwuC<-j5o=M_w0es^GNu+fbM)nhT$_*W&>?hA*92Vi z-iJK~AGyhmeJ-ROl(Sta5gW&KvYB6P4*zca^7}P>xvF&Q(+BHg`31jnTNLO^ za%;Q)jR99}}vJ5#A)wKhf!hFM$kemT)v7y}izMp~1Sp zRXgfoQUQ=~ZAJ{W1MLT>-?wLY?N`J`n?G6KKcfK6w#`2bi~c&R{y{VAMHkadr--G) z2V2!Pw+iq3^f$c?N9~UZ^Vi#qZu`-ma>O%6DxO{d+a#FgtsH7GxVtRA0-HS~nOO8u zaT!4Dkc~dF*j6KS}lJ&U)CoBU0H7QQ*h*Hl@L6(M=cN;LbJ^% zb!rSAFb#3uwi%t^R8ddz3az>L*dKGCru?;^ad>VvJFR1I`apU$pv){(5Ye5P-bx}h zU+cS5QcRpjGdJ^d>8LwVz}+qyD^bzsolB9wON>9!h@Z{ z7R^%@Zw7ci0sRuW`(uGc#Pa+4_m>4v788l*PFB0L@;2%Bs4qC^2FV}R%-ZMEnNlk5 zGOjSNo~i2T93CdGw%T^J+tIZu1G(bRX&39_30)A2^GZySE$@(A5ifGu&v6>JIYde? zdONREV}0hk8xpB(7Q9EXklbaeAftL7_swPM+_tMn4d0ncQoenjaPZIFqmoxvJQJAf z{*9tskUvs>=hvH=f11plP8s_~L4m7>DQ@2)knjSbS+Og+uKc{ZDGD+Q?=speUJ$jS zcsmw_zVY4csw|@ z-G*8`>IxU>Zx8ObZK{scSTcfU?n*KS{jgVFk_P+?ohAvuV;14m5+rVQg=Otsf{{oI zw_tLg;8@|#kBkQfT=kRyl^BUU+5@xA1mocGhjhJz-vH^XUwYKk(|ip1H0+)O$&RGC z2vCn|F6gKVJO5qb&bkej;|H2UNY?md-+0ogXlKL`m*aXaeO<2$xAVnK-1Q0sEk?E~ ziTWk6*6H2Vvnzkgf4vuUKU^jqQgn$M&&c`+W}Ar4pPJ1E-KNsM2U&czPbGg9JN0cv zC&swJ#=w-h%6|84WRnG|`M}0ttKUL%)AWS%6J~i1NyARs4@W~Io_KWy-4$)z^QewRMC z@2ojj_;1!Oa0mol_bwQw9qhGg<>B7Q)9zY+_j_S=({?0v^?)~c77E_w=5VW3tv5SW z>s5wXY+z1NUYt|*=dPDwU-)hU*K-Q5Z6HvSCQ9owRcrc2ZCpIzR)Q3U?Ju#rpjQ3B zRdVzlp`Q2i#LboL&jhAaT6>)0=OAL?wA?}j zMPh8$CuuhKyh~1J_B77&yvX2jkdY_`FtQ%bn<_m&cq$cH+=igGrJkXU@*ou_Fc%H zG-D^r*w-}nHBCZ9HDgy2LdrIlL|P<@))_mghL9rJlQ!gA(&C->_dDMAADHX7?_-{O zyUz1-{&BYt!C8pV+8J0KRA{el+0_ryzV_6^^R&2tdW|EO^<&;X%G-PGMU`3V&L2MgCT^|OIpY3kp_Tnt zed3$Z$;ZMy!bI!Mv(|9UlQ#o0B>r46u(dNg*1aHcZXoa}_3%%YXt?vt+lr#?W10T1 zwH_TyIdHi{F>{zBt17-PZFtl1c`yep5)re7P7 z`{RB0Th}HmRWzGuG#MsL+SFK=8kBxf3M1pFN8_Y3F?u@2Iy%g%95|eG@f-$g+Vvzy zDJ5ygxIHSH`(MHy?7!3xX0w7S+6Pi@CnPUuv<#TdUT^3q@|}MA(jDAHyV|Wk-P+{< z!uoVF98F^|;7<&6ED?ybLJ+QCM`gjpGy!`k0#{hA^y#DPp-7e9M0i^A4(HZSe4h-EJDvMNeTi9)j8uMPCmpqowGTarHly)@LXUWHYa+014pcg=m3a(F zFK5DKb;J1}QdBC#P&jva+T-i5<=K7Ta<}UvD^<(FD~4e^}P1J z(=2+^B^K5!$MH=}{pAx{Ur^8hz{|LF@>O)94uB9g8v-FB2{dOAlF3ox!|+o-g0?ng zS85_B@5-o_{FhbleHSfJ5INzSQJp%PRqkE4FCGzCkUAV0$vX19bGK1b%^ZECeMFTF z=d;Ol^=_;<&HV^Pth%y+2E%Z57_l!}?B@50%M&+mPexv@?Ha!9btn2BQS;V3r$WMP zpkNZMs#whzdHaRKIePa`YN`WNlul4CnNGUGfLs~5OK4K00Zga@o&g98D6%~&WEO_% zPyAh5e$8eSdw)~eX-m<$k@zBKmTkYoQc62=FjOui%SXEsXCmiS{n=d-SJnXSKyKaUw-wC%*J!MQ@;!I$d2LZCzJ)Soq1yo$>1jd(kh` zzN?i6{MoR{DiHqf#o-I5e!M;Ua{sHb6N0C=7sP-4-Cn7=@XvKwA)gHyxij~~%0g-D z^v;jzr>DtUR3XcK5`#5@=tuw(lF9_}bjAg4D5j6jfZ(~Z;dr)zB8v@G)d4Lv*#?kt z2GXFlVn3VBi$q`}Raorb&8^D6pt(?#4~PY*5=c9M!i)D4F4xJ@%X89gO1gl-Awjr& z@R>(h%-8D?5Fu18Ux9nGuo;Uiy80e{TeY;D1nfc#{{RO3JYhJ2oxKlNmq~I1Rls>; zA!8uUh77y&oXRr=ks&rX8wn~I;y)aO)n{9}gpwinPBP*!sUOV?@f7Z4=tq+6gze@e z`3v13=G_bzPX&uY%H|)>QDo1{OsVlF6t9E)JVJ#s&T=6VYebv_vh^8vh|#s+Xu=eRxDFuA z0LyS=HuOQ1qkC9h1!1?2N7%L(^mHTqyvQM)e4TN0os7Ky%mlWQ7$fzEt)j6d;L_M4yn%6^c2x~0SOpv0XyVic&@K+FfCfTbpbhU%75=PkZTah5K^U&1sSs; zky^}MM70Mbu`>}NoqWs6?Ew;|3WVf+(LOE427x%%9m&EPAC-2wRx!g4&U%@Eeazcy zbw65_bL9*OcXP&wlzY3qK8GK!yrw+rhs?ll>14Jr|CM`OSj5|aL>5;-m3l+j4hT9( z&Qyq?mP84)lxL1knwJ$mCo1 zhFUo7NV1XgpZ#6WHU{+hn%*e=nAv`IRNzU>V%i6@qj=4e0$aN*%&AqYJv(U1r0~0q z%WeGkC#8-z<3hOXlZGa`T>rEuxkr{wquyauqbs#I1zq)) zyx`dAlu0o{^xQpLvxl)}OHEyRBlLU+G1`qNLX4DB>rsB?fdhvsN##v*oG8*9D67xF z1aDKm7SpiGZ)ijNgSr_ywQDnYyE2WLgi8DiSm#FOrRwSnlh4w1jqRLn7u`t;?mR`H z+s@?s5Ajz|e2^&G4u@|U7>Rh^nj30u)kTqP^$*vT2~&Z9fy)5giFR8tjdNURiNQxF z)grSXXa3pu6;?2E2bJwEd(V=wKxXb@b>5+%3s^+#t`ro?-gR2LYnaHCjDqjMay zZ5IO&XHejwQqJ)dGNd{<8kH+&#U@D5;LZBV6)>TitML;S zrbn-WhMBrEc)scdbAwDv@p35vu761)cn3slbNz!z(q%XsijN83dT0RKPLyo7euD4v z9GI_&%~i2J+?qis1nrGcJf)~OH!dnOy|FvXT8F|z?{+Zdejf+(bV86^1nwRnO+Pxm zdDte=R%+Qg+0Ft*l{`ap&0eR0**UdE(D>!y>f=Xm$@r z6*FupH|&y?S@DF%3}LR@;~?8Ycd)<%6fCsTi--#>caR1Gq7(sCu5P}ftx1tt`36ar zZIxXJKy~zDjiB{Qo}M(%aSt#K0Q$4R^kSq4Ls6&LMwTKc@j85Y(i^;F{|5{L7$A0C zacc?m-|`CVD_AA*Xrf+1t&rA@g4VEu{1TQ39HKc1B$NtC%qiM)-MU8IjzA5z+Xe2H zNsi0=H>^+(Ogh_bLQpWt(`1N-lA$meHu)1s{qJa*szGZ|(Is-skR6CB1WkUK*c5t> zhMskU9qJS+*mVRf{&|sXvUJtL06{va7Yf1i5N4}7Uyp|jf@Nn|2WPB7DSD|OPnlFs zlV^gR3yCDfm784?^sD0LEGgH#&Tsasa2F+?<7_f@9r!|b5L zo+b_bvQNN}r(24AWU>$${$!LlHURSx^4I6op>{x$Y_UlVhqs;jV(5>Xr#KSeZCNh3 zrSmX@)2I3zMd{hj5TVwNcoCHXKCT;*>%Yl5FDFOn;mD1N2UfZQk|Z@0JvzV0qG9v~ z>xirGr6p>qJL$c?PdA~1rDcnT$hhU(!b%58^OTL>z3XDUdi{}OBd}v7& z?>Y!%$!&e$%nVC0k|i?TB-1Ixwm&FZxvUaDU_*^}86??IB zT|h=6&^#ILE-Z-xfUegD{dyR>C|^Y}>}&%dYwIC094$+YNM+Z|+Eyw?>XeGy!1&bw zj8fEt%pdxR0p*A|t4UcF*PR(5|#%3tCdwvRL^dEGY}ga()*R}4 zQ{C^XJr=+2Q<&&FSy$_(=yxt!9m)#Hd;o?eQI5eacFW1_qZ4P8xskEkX9k;PsEj&8 z{g&m@=J~`JRu<5{QCsUE^R^H8ON{~>5&j~GIr}OZ+F=n>T8KrM?`+hrBt z(%AYeek$Pa)^f>(cFpElt9?s6q`?3`*S`C;;-Kp%hgZF|ZWL9^5Quvn@-Vz`v9wu| zQGG2N(4zwXmShwxugkPra#@G3wXy*=AiI+}Lt?)W^G4MnXWa@9g?SV*z}f8k?>D_% z>X3zrX;BV(5;JZY8ul8OgrGCBGBo7qB=}k$YWud8OYEW&EYYFf-{}Y6bG<(^b8I|r zYL$YfABASOFJfkD&?Q1PYprOjkYiy0j_QK5@tr0bS}p~>`$-dKKzB}3>-XidS2F{e z$^1Zn;F3_Oate7o3)brYgn2a(pN|U;c=k~8L2o{8-ZD3t%i6u*sy9`t&PNk7+!i<5JrMSZXC>pF-S$u`umB(l+04jez&L@iK zre;GwSdcAOs&sxg^pSs{t(9DY*Y%a4J&FczdQ0aTK)Htu6zXa<`uUwMZ%rC$Y+I2) z2L+?4EdD1!dm~dj0w%JCqjsFbjsxj{fm%a|gwy@9{^)y}j$7010k3jl-MR1Ps2b#8 zROg|6L8i)uU@58cYGF`<0o21lPnJ>w`zcgZ2&^Og6(ZSaab#!$ zy}1F|U(44Y1iSR!~9VnQtTKZc63QeVR`1Awl+CMeO|z&--vhH}zaK>s^#;YUs*4;d3)u)}XL3 z+#qm4G0yVubJ<11H;rRQ*xrgijrO?UX_YF%Lm5K1$4?u`!JHfldT~Ow$|s#(2&*{0 z>0KWlVnel#bz8jP@0k?1Xmo_sc z;m3g7(-QA5USu@aSU*|2`ZaYB@SbWG?_7T)JGVAepxpM)DDVM9%m&KoKcpYNNC=a| zU4fUvrTUT;38Ei{xGGb$kN1N4?OuJ}UuRSuq(4@$VYXoO?w=s`_Sb&r6E8!)@n679 z#U<4VBie=XOj`3IenTF?fS_JEfD7Ipyg4#$@Dm(dw$yv zyIAA5uSahtwPq+AgXC5qqH2w`bv7tcz;CahOkvS?0OmTFB@|(zE`E$A`(I|5Nxl#> zh3b`JmtPd$W&0pxhpJl&5KUvd3^C;QoS(zuDwZf?`fPhiBTI$-ZZi1y z5>Xc0Dj|a1dfU5Q+9zzcPaHjAYAH`V`fKzw_RBGyjSa#jmoK?;(x-)tBUDT?j_+td zWV!Q(O=rYK9OQnOT~;6u5UgvGcMAru&LDFw1ua9idma2}rzAv`;s`mEN%o+S(=5R$ z$L28CTTTZJ3d$EYLdw3qD!ATQME!pG+)YHB2||G}9Ib{4Hhmj$Gyd8ShwUw|UA}o| zSXjf&6LkW;vW6dJNMtF2bHn9EK$k-0VigT){)FK~~CFB~ga6!engHlDV}u+0c2NnSBgq_jN%&&E4_Bb#?58T(~OO ziQ@ziAiNij$oYCPNFnr$z7MsY4doM&|KlbS&SjZ4{g{$Vi58Hu4=-3Ff46HKv8d9& z6J+|qv0gt>fazQf@6jx06Nc#e0 z3O0+9Qj^IP(^koZ)L>=Ob(?JSBy8cI#G1^pGDoln#Uq zW?9wNT_W!7PLw~Cnjo@B$V4eEl9le+g^T`5pNdL zGII7$E!;Zgl#KT|_YTGT?{(UF6&YCUq7*XZv_D?-I(S9(bKA2Wn&z!X<_DJ~xu=8W zeUgMU#)Hg*Cd3N3!Wv7RCrBxwB_GqD*5sumn0238zzmy^oUl8j&~p zGC^U4S>YayaP*zq?ATzuJEV)xuLpwDnFB%HH);W<5_a^>A*glD^hH*Vzht&+{mfwT9nb0gHmgI09oIhvzX>iXzJGASM$&$6 zp-n08n00@ZmePq1YcZ)K6}!uy=3fgu^C?NT{dcR#K3Mgp92~}0J|G^s&M_9l->J!# zCA7ZbxX!dbKy>oEW+J|s0Tc#0&EpOp)ilWjqv9U9K&2B}y9bkn2ZoNFuG^?UNN>eu zk5q*VUN3)2!}YxY64nob$X<6CVh7$tG5yznoB_X~`icV{a8c;N-@KP#p?}M;m_eBX zPM&0M;c5$@ynY0MJMvjmhb4M5H^7c4tvs%T=oN+YOD;M1n>8thXSK$btMYr50jTiv z3%<&0)Ew1q4y;R6WLzZk0$o*0T0YoTGe6xsGtsiyqb`?W;#Qoj`28dsWNyDZ@lJtl z?kGeXaSbCt4`rMF*MVj^LIfCD0{Xqf7a!{tCTm(B8^%5J`4_2nvRu;dbjl5%1-+!+ znPqRfiP4@v#pUdP747=wgP}j-FZYdw$;cNS6qc|n=gy?>eVTGJT-e=bF|;we;g@*A ziW_x6Uq!Dr#W_W0Z}sLQ73uHIFHddWt@+om_-IG1*N>Mg6}zKiaa*W`c4cp^fRk!R z^@Pi=o|#7t_NJ=MsJ!cGL`jYKb5)C|+|di!3H7A?Y7Xw#-A*MXT-DBsG&rpOs?2YE zkKSW7XE1uKa&xP8V@-{kp*~(K+|gvSU)}BE_3^8E9nH?C)II9eCz{drU%gZqQZPU%n zdtSL-k5BU?h5I#wvxf8Xo#&)cH-{bm?o3tLEFb0jFJ^gcQY-Y0fuBH|Yp|up+tDAQ z<^ttxxn~54Z$9A=Cb}G^&5OL^y^3}}gMZ?+5K=4a0GdYt=?OerDZMb=RJ4{)xArwj zYuE5rR#i~M#ni62L#UfC+O(tYw!Hh2lz~E+3?d7EF#e{|06`vzJXS<+-kuE+MfpH@ zz;mAXIULKiZ%WpYW@@T-o$r*3_uS6u^NPgFuN_?>foJ6*+qTiaG&`-Xj|#YzJrsM= z>MyEuPJ>J0Prj8O$>y`9e2W$=Pfu<+`rcDjxU+sm)#9NEOX;q@f zV*&4M_P*<5i{jdlEQ1-*Byp&3;+sUj@)n02N1uA7^YjF}*?EB>;wx2=2i#rn=f0&P zb{QZ&MJy)XPRu$0s(1e*3udB6opLlXvNi6EWd{`8^U?^>dh;AwT3LP0zsIyit2`Rp z6pGdQ=fBn#BG(i7#NLB!?8+iG}jqu zTn{Klm*H=0eJ|U1MPnEKALIBVGkV6Lzu}tFHH_w*i2D5r8<~8XQCS4H@!_Hja!pr0 z=zyUE1HjWROYU0+tQ#*vgy)O~)LC|>kqz_>e7Js1)JRmi$iryS-D@jnB^umde^&-a zQp}_ueLq&cxAJw#%F6@V|24ZyH{Wq?&TP5$-_7HXKLzfZPW5R2xO4IG=kNpj-oCo^ zqvP)5FL58#A76E7J(Yf>M(%sWyHTgR=d-k$j;W|4H2?lk^=7AJM|d2*IyQL54Onqg zWy4^*$%v7j8VFQGTDtA!dzyKTpxwBu8ZFtkRo-2s$@#HftA89_Sta2RTT0_3^g-i= zTmNGJ9FVXWCwTmW@~{4x>-fZYiJ60#e0*$qsXh5jG)u)z)ok+>uE=Qf`{wWQN9HE) zT@W+pn)tWVw7hDIxB8)cqTCZmy|yCjjXl_s)2pB1N;Y#p@6L9wT>HA4HJBnqJgMQr1I(6y${haA!|#VnUkO5p$1<%qQ+3#G|e_lmE6c^Q{Tl6LBki()u= zFd)gj>LhYmZ9mKy`IJ7q=u2ibIcU#F8ai`f1afj-9*pa3I|v0~QUi9GPkrm1R&7Ik z=RWf;DZ1?1!Lv|(;P)F?>#5n2QAGZPD_Y;{B@nuEr#r=w4PO`3G$&K;4W~s0H#dm? zYnU@j0+6{EhE&;RnrzIV7sj+MiBgC6Aw${$0US)7#>=m;z>{p34=tny0ijD5U(u>j z>+c&#@3*c}<|wn4s||63HY^qyU9gK4u6(KAIb=QvHCZk2I8BD#AYay0aR2Il@;Uz} zRW>1yoH)p^ZATk<@Nk`1!gKkJ(V)vCg{Qh>;RK+b#sGmFLYXURSV-Kr_uyIfp79LP zCq39d3cF4{pjAIMc`Atp2r!&}CWJ{-fZe|cIOBN(zGokfvAYa`kOS*9*};vRQB88B zAvxHA^0@W_#8~#*RZc|RQc|;G+|6N8Wmn0it3uqNUn#bZ0d@Zsic9x-eS0idu|1vE zIz*k#L{uWyah5xYYVSviYp1 zGXR;elCe;RO)^>>cm2Ls4(!>k_|bHsq*LGzy&hV=@K6GP`>YI+*#2L8xVd52b%dc0 zJDxU+wSz)6b0DhZvY>15&?Y#6|EXsEx0Db_xlMMPm2T4_uTOnE_{}~Vd1pvWxFj-L zoyQ)2zo_;8R5WaCu<`q7c+wkXT{<*Z$f^XFE=^&mbQghl*qcHs?+=CT{#Csw9C`dS zLz4s^$Y1&;o%vr-guC$chY7&cDswOt2A0!wlMY)lUXP?5hTTw(fuP0AopSYxrN&gG z?OBoK0q{TlauN>Gre(FEK;{Z0KH2j7a&KBtgajWJG+t%KB9gAm+ve70om|w{2P*#c zKc3?&@7-lw{a9_A#(;jw2svhx`WMoA^TVPvS>j7%%Z_T{WaB1o`QV!JAlF_i?L1kB z0cUq#oK!L2s)hy2AaFgSJ~@|v)EJCU>vh`Lw)bBOkI<$U*Wo17CNU>@zEQ@tj_V)2 z#XHN9 z&bLg-mfd1JRUH0|Wwv@oUYuiJ*2&kcWIVm-Bah;kmZu4rA(+&j)>KAnWeKyP0uv)kIjzz(@oRONV=9*5@ zLL9@lnNOPwtmc}bYf(mRc^it2dIUysanui0w%<;mPy^X9S-JGOVBp%aO{9yBBVu#i z<&X3*gkA(Av!C4QUWltVaLhi`rfGIEqu;z{z2QAUn*4o9Nuzbar12V#hdc`)Pke>W zEnJ8`)qs!9)Y#Jc{H0g=V^3{oam%o#e+pYHK3_u!xqp-)&Bs|ZVZg@4v44t?s5%y_ zNAkZ=BkEWC{uwAeV~EO->zlC<4RRbTN~B-D5z3YeV~j?lr8u*(`(;;7k+ywzGiv@t z5%p&&u5ll=oZ2H4!Cf)~x^)lRc8l-Rfg4#t!cZ8;gQtEZm@!Q7Bpi$0jO4 zw?E2#zFA}*xktG3=y$)j$Q%}Qy`FT>*H!%6_!Pc-B40@*l+)GNQ`B(B{dN)m~Oy(B-qde=2B`4Bd>TDJn56-7!vuzt5!3Ez_dmAT+) z)3VZ~@X>t}AlmYc|y-Z+e(=wFo>?~lxb2CnP5T^>E+KK_x-=Vy!W zsx-&r#m@)+e1Q><3LYa=SA@?NJNGzbYHNnzqZ&yUMW3~e6^(v&-=W`?zl~S;dGQsY zCM*e7=MiFCIiSZC3VnNZDxO~{_?jl`jah&+;k3;Aw(h5PIsewlBf?~>HCi}*G4TAc zC%n^BnANNdYP&gG`q~9VEJJ_pxTTd_#?|NaCcNq3_F0lpm}E-J=oTZmF(e}IaqRRPNHIl;(f-o zE!rzlWrg!$VYu7>c(Q0We~<7j0x^3jpn$hJ6t6PE31QFwj6?-K3yLpz{$;fxx5EQ3 zL+nh1J?iPE>IG}KAhR-z(Zrto_{YGofeVuAIzrvVQ>4g zf?W-Dg}nGJ;BLO!^wBW_ntVM{F{1cd03hIT4MyCx`kD-!>@PDI8N?aC6gjq5(g8NY=ZX-%(%znogCh|=97ceY_4fhzR?m$K9+bh0D z5|xHc1rpF(&PyCs^4CfIM+qk%^ncDp`eTOUHh?=yY=rShOp?RD%Ga=9a>y@7^W@=g zgPW>GFH`nw-Vi=<%$*`mx}SKnzU-_ozYaOhxG6y+v-!bjjAqZ%%GXSJARfa0WzylG zb0qUI95Jyas&83&i<7p&f#E=FnO=b@ej<{p%xZ9565$rgm=6V~?#Lcyp^w?-}+@(Az&UDhUa={%9Y0j>*Pv4NLkPWJnFcG$| zAfxkL_Z5j`+|7XuiM8>^g|;-&MLfc$tW_2hx5YWgxyqpRCVm7 z$1Tt0y7F`^m6yn)$!y8V2i2DtK7PD~*G^^}lbpZ9w_PWZDGsmXlu$p^O*aSsQE)+)j};XVe6M^&+U zU;)c;=f0&<+R5im|$(^=O%6Fap&S|dfxyb;iQG~fu?m4Gm8zxTkLM?*ENl-FCtxik-az)Nq9PP ze9I$m%-Kr9S>=#sylbjX?m0MG!Zm z?Ud2G`Z}yzltw9}a3yE%rA3XB0_iK2=3*&osA_6FS6r_QLMho>Y)Wsw5*)tIjIE0r z&Jd8~wH?+7; z)b-ZpnyFeSQaG7VgcpFrzl`@kN$41>eI)mhC2}j{$QGf zK7jDYV%s-K(G+MZV$ddtbg<&fbm#F;v(NVtjkIl9u7z`K!X;;XoK7y-pcsl9 zDfzE@4Wmv|kmwk z9H4BQ;L3pMO%;+MX-aks*gH?1gG@slm2zBIo>DrPGe*N#oi> zkUI3k;!`)}tl>9I@&+Neq}9VEuH>odE3x-Ju2kG>237X@*Mcu9r>q*F)oBY}5jC%j zDQq|eLUdwMX(kaMF(GobzR>3EwaQ{IzvrZ~@arE9AV(h99cs}*s6Yx%Mz1wjxSd_Q ze&gQST1#ucdP}p&cytWz?#lH~x86{FNVnMtjn4)kJ|-4m@A9{NZo6&jdg=OzjfU2Z z0b<4HX0ADLVeho_K21Vm(dw7ZrE0$;cV5dp(8@Bs{yQk}@Sl?vxAl_zOADfnE3^z6 z1~uw#&kthOugJCizRelgKDWL%!q|V}$+C^+Mr9wG9{XS>UAxW&Vcy62G>42is<*5ZE&sJ)3kNIjlGGN@oRRy z#ywuw-{OXaD+!HsF6>|N_ z=Qn?%9*g&0>-#%Y`bzfHipS8eq=&Elvws3jU8i)vouMrHjV*7WaqQB}gLYf9e;@Z`5&~%d zgtzbhzI-&D94h^JJE>^}{Bp|U*|W*`h^NZR`EKvp#DB^pKTtOnI`ZoAbWzGlyUyQ7 zbg1sV^ZyXJ9Y5i?)b!N3C$p1}QHYK6OT_nmHuI9)sF9!_6#)ZQ%KQHn#fA3&mX$r& z^gy4-_Le`MjhwJET6w%4G`|zDVCK_DG%Y^qJ9>GOm||~yvN*tU$s2z_UnfH?KM-~B zt?KvxZe?|Bmi+sDO?wvVw+|jAu_wH*gh8|Uu2kH@q@mYzW zC|3MgC|QDtfGj_gFCH!+B6?m0E-Z{uiHh6bT^X_n`56MUi_^!hJe?&m9EB#HKU|SUyz@Q=sC~5FcTxLSV^4|5>~+wl?jph^K2LvM^>WJYoR%*BBa5QQA#5WbwJKa z#3nW?tJ%0WEV2vRKOun`9O7ncW*bJBi%D%?O2?@|q|FSCxlQTJ8Zq+nJ}%o4f|AxY zGnQk1>lYCvqTYD3KY+Y+l5n+Ts#UQd#<{fXg>+rE zBxyk%0AI7329}t=O8Z?2G+eb+6uo;2QHQ7z)e*o5O8uaa)V9ZpbBpGSa!?4llHM~1 zYpFhQ5*9iK9b$eZ5WwHot$amzI_^A}(J}6gxbQ{+=_;a=obG{0KYj`Qs{B~(1*te- z5yOLb6OLo70Eb{TLmdp9T59qP66eNZDGFGTD5>`i*2z%u@?#91bAu|X1ys+C9TKvF zEc1G5Va#AeXG%Aei0T3$d;w%^9np@?$x$KT^FiWL2?pwMa+_j9G-ELwQOR3uQ2fR` z10q-wWrGkyflekdk6RfBgGbvA2e#AL0PLsT7@(T;*4u0Mtg7Q(fpDY;i-Nd4<#j#Z zkL%-0aIPy|$zEU;yImHc{iYxxR^(_n)U!1^Mn>`KSvc~K#28YoS)U>fMKw2OQ9ce3 zgQSyb?ENUklNM(4qBOp{S9QDrBtO1T9E3&UhT_eF8`)H!Q|l9bBih_n7nx*ouxQ^s zkBRsK{Z`Sv=Y2>bpx~Kx6~kAGL3N$7$y`o3!uSLvPjRoMT@T;d7#!NSc3c7h6bCTR zig%No+iWoO@iYiOFS2^}RuO;%Wl0^Q>ZWr-hmSiHa!ex6Ybb;no~AN9jwH4~hE*Ec z834CAmkGs^ECJ}NUBAZZ?&5iz<7|8^YXdlVWXOKBJy~%8EVUyMG3WowjCqkX zbVup{DWa7LN2{`#jxV#XpBs_#sP2At+X2h*5_?u);r{u^-seOGAsQq1TNUox)$sTf zoK~qb*TLj+ee2^mF*42!R;n_Es>0w%Ovo(laaWrB!`r~0-fmok^SD?z9(ugNf;_4x zd|p85VBpS1{=8!mfR1fKFUePit^CJ$)^IL}W5<_-0yviohf~Gptl+}*vf9{S=sgO1 z)tt=~?CLjqw;%*26c=FmtpJ)I2w5tWK9qpac(`u}!tyBdP-FwN;>(5zCS;}R1N7x@ z@)ke+&epL${?|Q@P#xQ|yI)VurP@4mz1MGhE?E^04|C)6PMzrXR=aRoVm*Uke~b^? z85O0eI*obJ51FL!!&5?Y?D5GLgs&f!6`jI=Sj(;Azw*q6ZSid05MPJSU3a@WgoQ+} zqC`qG(_*;Qm(3CA(TP1q_E)k%HzPu;tE(-MjMzIu26|28LGb{POK;Bh51ONEQlOw? zUgy#vzs9X}3Dr;pI-uxtxDBSJ?#4i&$dnVbOUUmfbptSvUvY0Y{;<|-V2kfnH#*I^ zQlWa^@e)Jc=%7drkNsjNj@yJDS*0p2F_@i>E)~je9dhj8lr)ER6UpWQv|T}FM?Soqm&A#uj#S`e}B(Su`wF4EDEvr%MZWmb#8Y7|x?*&a!3EWTnT z4?;W7nWL{icM&5y?%5W6TOALwS{opo^eFw$kc#U8D!LC=htR)aulP`Bl@U!2Ik9)2 zi6-7vg4j(qT#0Vh&$c+))7%TmX$W|)!VfZc70Y*$GsI)N|)~J^+GJIVnHwOz%P{M64q>+d{RyjV^22MlCw}u+6N2tX!3X~ z-~)xoaN9(6?pSZA$C)hEzJr;0i^I7vvQi0pVXXVn{Ae9i3V8RBsPJ&A3%QamWWOV? z096^WRUX&j`?1p>(T#tt#NPW9oty5l0U&mB&*g9JU?t9bGS%@EM#vil=_RIi_&7%1 zKi9k6GB-P8;h7vC3!9NqKMHK7-!h#lM~X&%Ip2|MD6DrLPGAg)uwL~_KkQZ97YMPA zNXR6r{2HP@VIl(g#Tf9t!#*$KF=hPI3dz)GG=aP21CA?FR5Fm!a{049+U(e|; z;^p}{4BY>2_gDZ8Xhwk9AP)Gy+dZ!-R1j=XrMd{lVx%Zim=|kptx~UzZhgBa>Bw3X zQoVp)xT-LTD(>&0lb6m`XN6H(O>gy*wRBs{jY?Z$W92%>$yym%90pS+Z#iL*-KQbL zFD%RtJ7cy#mvZy~W544814RX8RTX}@|2b_S01-g*D6hQW|CEp9l@Cm;YJ%O+q{Ha3 z9iJt8_I{b|e9{Av;t?B?rTC_gc$v^t&t|zH*aR8mz@90nCJE7fw9A1kkgMClwF{8R zR1^^MW@^ro;oVp6 zW-Zpy75nup5C%o;Q7tj4%J<&ieIszZu_h$`Z#4w~18AQ5|1%x@e}}4DwQ9wiX&s6t zvS7OVcmPz!y|r_ufIbC}=oo>;mwV74^UrF_jr9h~X+E%(1kE~EF*vk$cFzeZ)lX(O zZ0m~$*HD`IiyME&Vx!y`_|pP(!_3g5it@^8|8Isvf&V$rp?F}r3n1@2H>Xk%ss?Fl zF6(=6vbx1IU$fgT>&+j`Pqt7G1x55O&B|XeHa&omzVPFQ-|eXzNzcBVeCj;J$q*j! zQU%n)THvk`qW+;x>w{TPNTs3DXQktY0?2)bLyyT5B@{8jsq&&kG79P7j3*ZAA^6C` zBEE1zE$31^t@(P(jhnZ$_en%c#YINP+}G>aPuEVDX6WTwr=`maBb58KGqVG8m3l3Z zS7nP!$~?#PruE?1CLheW%eC7dRzI$NiV?uVM5L36l41g(!kd7IxGcbrl_KZPsX5gM z3Tj$y<9d2BlO^#aRaVd(5+;MDHvwR}iXcqUXKKARi-|-U?mE)Fx-F3y9C~J$%gESo zfHwzjRx9NXNVI{1VI-3UMWRH{F2Mjx3m9AkeX3k%liX>97!W1*GOj@3 zP_sj3KoA2C&*!lnTbY&T94O?;Vv^iKM=Ek~twMldCgu>mKixejXrkd3<6(+LT6X4FVZy zq1(Sg*z*=lh(n{{0`3$9hD52-R9TNdX68d>NVvQ8>FIESUi&JG1-~f{6KlV=BR7|X zFg|~J>f=1~GYo3X;J}6&TuX>@o;KuNe!)mC1eWi(%n8P%)q5gRac)k6NC<-o+2+hK z5SY!zNhGYqlbt?ECsSlNO$-X80>{g<6w}#c!d}0df`AgMa=chD?zS6-Bt18U6tBEb zE>Q5OTZ9Ws-D4oCn=S>?_7NHE^VYq#SY3Wqmb6@|k57>C$0Oy_74-gQ0RWF8fl__7 zG3Y>uP8XH?29jTUl!{e;f0c~#oc#=)GY zUi|_JI;}H=Q$E*yo68grw0^3JV;BP}iNC(r7${ce-j~O(U3jlZ&HJ7tb}5wEAVA$Y zwtMwp^bUCKUx|&3e#_XSn){9BY8R1&@vkkP@{lUXsV{b29MsUXKtQS{o^W?2#x5qHpJg+VbSH<&t1#%_I+9R95Z}=2j&VD!%GH zwy=&xMum&GNpc{pi1YS7&r#y*V;2(?nP0VB?5L{3IM=M3N{85Ks>F98afAF&wco=iL0$1um978RuXbg0z_M6>ad2;UJ1oi5SMXHwOU$mHhyqrgJc zSf+v9(9jRt0D>Px#ii10i@q}ycDWut+_Y&xs8bcscU?n;ABX0cHMJPgMRGM|W^)QS zsgz1*`{D4sSw&Wh>`P~R*8ZNa(eT#04y7)Ioc5{`|0}yR+FU&LXS9Y^wC{pL0!7{D zyKBtP80^qnBJt)CBNK?tg!27w-JjlEm_z$azY9(pX6pKK6i$d;r6WC_@#VjB^SrKx z%G`f8X7jy^U3@iM%j5av0-ewV3_JOC?Ne~|_lCFf!YOn6v@jr7)7Ad)fwH05ln~G{ zutOx=1A<1y^lB0*c+@)cTDt09d}~ge3eoC5UL2k{;(a-NtS8NrdBaYRf{EEOyyeIvf`-Qj+7czLsEE6fsVG=nB5WtEPz^6eag*huHZ*TDmUzYkg{Bq~c z;v8vN=GQ%bXM;bXZZRYo?>0X(j<=SlYmjg} zfUy(Oks*HO*~}$FR-k>bX(-TUALRpqnz^#xyZ-bTbnro%(NrzNg~Kwp<=d+VLaHaf zkBZW1+RTHWDPsc2^!75O%Vo{CE>Y+o+Gy!?5dsmYQN{+UinC+(6?+2Bt`3x-WlEj7 zt}hbw@d;(>Kr4w>QuEd9s(^D|O`CeLu2dhpu3Yy|bmK4`=CO~e#U(*>i2_Xu(va;8 zM+vH%tS3R)39Q&exskZ7cmZ$xIx3LAs1#5p+(rTLjcm-n8-SLIS&nI~21KwXt9X=# zylfSXL|5|yrNdsu(uu0dQ54Lo0$3&!A_2vn#c#La95P1)`UU)wKLFs^ z++ptN91}C$7cgN>Dad9%__`O?KL=cy5QE-WI%3GY2b{2`yS~QKp^1f`yP){b=>%1< z*q6el?#ejX4&vM!3hsK?*R=+(xS6x>JIOLPzK^Jv%TC~&uEimb1?!S`+h_Mt9i@;wAhOsEI7uEkIw$YHvDWc=8@&7iDOXrZUbF_^&Wmh0x zh1XTokjG>MI&Wr_kFkwMbM^F+36EyLY!;9MQ~?)FtDzJd6`V zk~v^YnmxYt{nT`mUnNI6oDS5pt zgZgC=4XjCs2y(U;dQ~3wAQ@aO|56||)Ua;AcD@&AZLv-E2Uju-wMr=ucT6l7ONAbXI#^dYTxr$y-xRa4M;s5=E`W%W84*2> zLT)FXHLjV(OOKJ(D#|Uj^h_>Fmq~l-D*`Dqn93_U&=xzJZuBG_isIP*>Q`@H&aIY2 z+j7&Rh&#T{#NA>6PNRTylhub-gm){mge9G8-1({J(iy9 zbLLAIqIVduR6i9;ECjH*{xdsw5Ykg!rEc;Ie*d6P*yqNNCSR7c;ejZf{5*YYKgd(v ziGI@AXRxv#Ok=mupuPIEjR1m4Qg>)YYN_`7Rn?12dSspglUsefRf?IY+*@4JT%3 zR$vM+_5iZeN9>1BSaq}ibA^=*DV<=;onYc6aoH{jOrOd~PmMebxU;9!(Y5X9H4$X9 zPFnK}AVKt$;~eR!DBjn^xd^cXuqXV7^zp|HkPL%|CYMfYU6?6TtfHz&b42c4x5abH z^7E>qVGkp%C&;f@9=q2?L+wn6EGMnHxtQ46d$?Rgk z=7HS=D2naTxd1l0r`ml1QTs*GCM0tMjfaRz0Z&tmq;Z!Z@cN|$cdmT$700LcRRjtm z*VtRszWtl5xmO0(c6#S2Vv{cwRQPiIb$H^!6UjFVK-+#Z$pm>EQ2yJe)ja#c%^ECo zNfwQjcie0iU6SkxSKdp7$%H78|HIYc*FN<-p2YIeaKUR%U=vr2jA>9s zhBtE36v~0l*I%*M*!_JSnOZK39+xL0Q9qI`BcFqhnxrE#S%6UC|mqkC|X zLnaC*$YwH|8qHnE-DCdyG$MAG`R$$;lil>)c4(y03j2ulz2AR?XUO$waFO`z-ku>m z{u}g9gnbm*?9ho=mjk9&PQZnYzFl0AUMTxRq>H{9TgItB{5=@$>to67Kcav<_}lML zDC*wxfWShZ{1{(S3Zf+kaLm4rQN14eUc!fAU{UFN(?)@AyWiuzZ^`d{dAEEHzVP9% zovGeqY8k3~@J0VWty>NKgx(X?6&{eY?{9V}^!9fF=WN0&15}$*#YqKfgX2(ZV)r51 znP85JtrPe{ZSa&oh$qVY_yzNDynW}z?Ni}5`+Y$93FzJVTZxJNqaJ}J8-NJ?&JG&z zX#MtDT1Rh zLpZww56mj0>SA(k@FrFA9$kQYxi)D?z)Q6h!ovfdxv8(5@2RR+8g_T=Fs%qsq?y$FrcanbtUo|- z)nn85Rd?CZ(|8rGmODrO0CG*jwcgdZ7I!HKk5?tAbyZP3wZ&cB zQ^~4gv}+$4R9<6uTYoUaawBYcau6ZHgd+d2e=;s;_%;*uSY(v3IQ~?B_~oD2$)vHr z!q{0>9%5@E7IRO>Q$>GqqVT~P$66FJVK_|=b%Pw@mhXpNjemX8US%czYFM2}|GnBp zxU7TF!rUVb*qIT)6W`Uw8kOhx>2SKCG#45Vzx%XL$~4cq^E^)4)I{10xPveb8{*fh z%Wl2F?fag7J6UJw8wjC6+rKN0n3%oW>igyk=&U@lE1D<_Cp-^&_}_;V=go)j?MCfl zpI|i}onK9!^%-}#J+*B-GLokcef{9fT>5x)?c8nsC$ztZ{et;7Xncj*g!j_ZbJ2(M zgQi`0he+W-6n4^#U~rv@7hwb98`?EhKmaUW;zoJCi1sDZ^D`l1UUIXt>JVXP$*$7* zSmJok>)e7~%H2{G4V+}nK*`}Fw@)S^PGBrjRz>VDoBRwp927i%vgrAtxF^OdaopPr zm!ltf57NIa-X~q1*H{?)~Yh|3DqRQ)@VHb?RyCX#X^JciqTn`~1yQbNfEb zw{gyLcwoV;X%f#iVVVRfkC8gXN`S^Ee)W+#pdvRN;wCmL)r+)W0U2NNB%^D~P9ZV| zo*T=9@y5~xnviXU;e&h=8BVaM$mYwve&cQiqXR?}W6a3tj+fMrV`8uFuf|Tfe~C_F zzsOJ=HTv+Vr+98`dRY79FgN(oefGn#xJShgnx^VP9>mShcHDFKe7RSURerCgpy4I# z=F2TCvd1%*dFp; zK;@3E=D`sL(dXdb7qebVupKX->5s(JjePce9e~|sair~^AWH)ZEQ%pTn~tqQgqO4= zfip1ObO3lQv8_VJn)o!Jwj?(gIAwP5Fe@VL;uwq~x{GdiX0#bsQfFuJIMo$cTXzR2 z$B(_nCED5-G=PMkwWp@noE{3a^pz5)rtX+b74>3}3H_ZN%Tk}xQeqcS{&Rf?pYT2| zu>X!-sjCnxosYV>^mFi0@YOZbl6mUO(QxmHveU2|=MYgLjdsf>+SetsKZ!e~fjd*A zews^fn#${8fTP1|XE8gSwD7XVKoFH7a;NOk2$j?X%-XEU!LG?LOvV1^{+EJCu<*u! zgocDep^7Q(b#Kpndid=0lOLX+&Ri4_3{~HGE{zv2ni`E*{O?Rr#oBMoocG(MIm0FQ z{|#5xe%;P{`6zcv@Z#d~zPP#H2VdJ(Te)`kDY)bYoapzwMI`#nBq#f6cdJ>vkt5IT zbJ4n9WFT8!I%J{oU*?J4kDWuD1Nad#E>_e*Qnf!haAu0^YZUQc%dLF^(L34k<3+K; zvXFsd6`wVapJ$bS%KzDS{_M{y4ZPIPagR>@tpDH7vPb)mUK*;u`;!m6Uc2zCKIh>% ziC^bW4jqty1zbWCXqN4IP`A?xzdoU@RVIdx-urf1T+Wsq0`M135-^!tXtGo7XC_Jva#rx^^cQi-o3UHt{Re$k- zKCd6X^$31SWB)b-4w>32{r|e2YI3JXo=L%6|D7+lx1pIT=q6vHlEtJ~=0>2dsNC9^ z@pDDd+Iw&71&Z1i&o&MvV|T*x;afa5Rwl36-bBx+IdO#fBRtws+n;BUOrd!GDYasR zHvs_G)h3zlAImT!eA3retg|Zuyj<8wlmA>q$~EGb~R)6 zEhJ+3Zj{Z_1iR@v2s*JeT-Tg99qros-Sb%`!S(CHADbUS>Kc*VLkd4bmvyZ$UN~gn zG+l%BCuLu_-URK;K*Z6k1R-TT5OD>&A52~dOQelVOUBmo;G&mx1Bs|G77TU9WSbVq zW;$SyXlZ?cqkSe=MR3)26|j9Ea5?F4=4*;01huB^AIRejWt*vBWTOl0@fr{@JnBLv zmDL-_p#={7Y@}r3#N#{A^AM=I4Bp17BYU26HBlGasz&bS-84$4^Fotas=`Ka>fyO8lD%L5RvJ z^MW#%$!kZZwtr_5KY-2v*_d&R7-q@0s~VxJOJI>Z*PmHFu;RIS8lyo6|lfDO-d)>$jVlnA)F`*BF{b| zO9MlXZ~fFjV*-hykq1;a&PEE3wepSn>bowq-vp->98}V20L<>V0Ia`paj!N)If%>x zG0`+lK!mWchy=(Rd(wBOTm~SeHQL*VW4Ks$6A~87JMY3+K{Er^YTpjQNSf0@$4H*n zhq}vy(v&-HbX(;x?;hy_z$|GV@8-5X4S=5+@h=5kxT9#umN~^6{!o!7-D=mZc}`u8 z@Sc9ixR}n`Bb~nHRoP$Ich*+-FMCLM0+dScN zT%HQWsc`#So~a)3{;c?bJiRpxB38kKDEeDSl4jz)F+s;KCKgC#JD3?xFaf(!K%5;d z(fVcE=?{us2G?&jxOAXV++NFw;yMEBUoIUOOs=C#2^_g$b*4m0#``~@lI)u6a8q;X zE;=2m&T$73T&AN91^@P!x}B?Pe$}*hkn{lM@#)d=C)N|vPWB;tKh1SMrT{k6at~I@ zmWX7#F4($g$SGG|fdr8zYh@#toocuRZCcX5dQUi#(u$F-G^Umw^(K)f89Cn#L(m?b zya=s}=e?F_xTn6O$|?i0B0@c2!qmOBObuYsVk>VEL_*ch!9F;sol*laPs?waY6zt8 zG<8#h=_%u-M}Ef?i_ghp%^$U@gIBvW)9fg)huks9Uv&_w#+-PdBwn}!(t2rIe41r^ z#kph@Q;#P$-uRL4Zx9>w4oy;RcYB}(eKvH+W(T@M*S#z3vWw??#!NhzxT|G!r}d#r$I0w+$#6eU zQEu)b;JAfMnN#z}><49KCn>iWJFeY032;r;az}#V& zj8#>pZ^{%Q*2?3fmzPh)qNz&Ko2Em)Vqj{t1x9v^=k&%z%oZhs{P0X}?>-0pOKP;q zF2J}!Lys*Be*#S<)MV(D)SCs! zaGX#hkH)~s6gBxLVBk@@*Yp&^edzv0NU{fi)<<_jTOS08uR$P@-C=D8NuOR43$@{o z$kzjK`kI+dca2L*Q~Zrj;26*I@$c|sApE2U*Pu{R(vXRR_EPg6?Ep)CEI`V=Canjb z*pA}&OSms1%L+X&6l1^_33%itnbVtY0s+X(?(Qdhl)+55u6|cPvwvIh14Lt z41dJwGJa^vaU&*hc%1dQ^pU;v;7q!?zOwD0+Tg#_PU-yUuoeclQyzOM5cN&lDD~{g z1Chv|5{%jR^#gXg3u@P&N=g(QlWzgF0)@o?8zkiA5>=dtLpR^Rk2T zgFs#JR&$X(O4^2voeg@`w)^<{X%)DQdAr4}yBF(L58 z)eaX>sYmW@&kVEp!pXK@n^pOfDxyypT?~~^43+!TfF*GZO&m#~U^fnpnnn?(;Xs?Mcg7 zhhLC-!_bE6>N}S;x(h>2&e~0dc}~}9o^FKrj%ux{p_7@%{cAw_#6AMRszqITo<$c( zUtscO>2OawUu42Omeq;5HwQ;+G|LtmNva-np=)5&qW}vI^y69O8TP9%~vXj9RKQc>3a9YpupheqM z|4ESn?hv?EWX^Yk#o8NK%Vp>n8hG1#n!VJ2K-fb7bs4CSMH7n2fV#iqjFi3SMmBY> zM8nXLs@xdD%S^e7PUO8&3g&A}5&ok!IZmhpPeyHjse2eUxK9;$Pdzbd36G3yjQrOa z;mZ$ywc@e!wfE%8t*U0rJ3fckS1b=M9qC`4a*K@lM=~jKQFmTFQWG9=k>YVP%+exk zcNxjWTNT0JX;<)IjxgPLriB|5JQ)zVPX^e?HeI1Xr^S$ez*phkyU9Q+rNj;W>V_ca z2FZ=?SUjlDL$Dp{qa~3<4H^JhgpHvn!@If9Jd*)W5>qSzP&LWblG|?R|2tw_=Cu99 z8$oeQ?}*$TTWR4PiOGW4IRUT_<=*{IY2U|B(=@N?({Hq@Y8$?``4rB~5jBmBj_aOM zIJSyjY1(%r)FZw@C8NdUU_Ch_{Dxvf!MRpO!J36dr1$YPH%(%R^lLZ1$6#wqOc^Qq z%9`_Od^+R}HkvZCuNifUiTL9v8JL+=L$U4lfRaN~^$U?Bl6%dtGpN~f_ zJ67D$)f6;-+&=V$WgJt@BunlTu@+yvx7xm|Lp-&03Ppl3n_36W!VdkKIi4&`b&qBq z9w!w|K@xY{HaiLv_TDv-m;XiP{);TX)0)?!Z82Yxf8@2-x$X6<(@mCpT5>YNO!pi) zdNK0qYIIadP20S#mFBx6H?Kq>+SoVO*BF)kKi3ORueMKK@ycoG_!lV^I^xa~lL&T& zQ*w}j?`x00>yDmN(NyaY8Za<`b+9BDUO+Otyf;MM;RDWrZSQqhZI?SAQRM>0I--|L zN@m!|0UZ--f6Y{yX*8-~OXHAu)!{0U^{eMqT&sjHTpgxq@=Sz8ZNLucuWKd2N4K*DvkMSJtoQTs_>P*|V_j z`S3&Z*wuT^ZOJ6>?X^en*em~{^_0vj51-cCE&_t9BhhcR3;Lw=ZlzN8cELno9+Ul& zjF-7;bCb8bOFF8=^fvUU8!?W|NqdC;tvRG;d?T(e;EqTxOlN)4&X;^pw>#tht|{HI z)%EyKSL0JZ#Mc~AKXQ2kF41Iigrr)+gs-GXZc|J3Pmo2Mcfe0%90|L>Du;2hH`#5!=i_M(3q)!b-jQe>d7Q z<}X$iKeg`E?WpqE7mi9We?Xp%E));XO^hagUp83X){g%k36VaWv)=U1<}f+_Rn|wN z$+08(Q-yCdbz3dkuKZh`RMU&qyaH`pJGk6(6t#Q>z47~sKH^%dSz;rjVI5f>FKL6k zcf(blj1!xH_Aj8iLr{#s2`D%BOhR7DNaM>1aAf8wi<14j;A)t%usQ1Sy#bV>Y*?2L z1sRy<>Gc`6F^(BhxELaqIE!v}eWGx3nRin&ynCui=SA42?92RXc)P17#G5tZ-4-G| z+O#RKuL}ba@fN?XjYB%)_mk5THDjbd#Ms2&-|E`^sbObMv_;7#Sbin;j^@hu)+l?O zxXu{$v&P#Qns608v;M1S{Y27RRcBa{pJx`5zMbW{A9*btyoOTgu*!Vfr=mMfU2%MM zl(UpDsH|=^x{5ovX`Q9lsJtXnDS*=z|F8_NAKI!nS$}vR{i|DWOzQB1LczXpUi51; zy3$F_)4|j9+H$wjm3kv1Lo+{h1?MEQZ6BNAC7W9z@In>qU8ZK&znDrI?ijAO$u%uZ zTx*+8+VOeO)~M6AF=XdsQ)+G!^|k8ya-xIeS9>k9CD4pkkXSLlguYg>3TafiyR_a- zu+yry-T&3KJlXB8s1Bt|V)`W3Bu>KQv_E%= z4ho_)-x)w=RujLXDZD__$01WSViT?%&T3s>Ur)&Ik=2%NGjKw<+DEe76@(zB{>Q`C z#p-skhnn(#T>)&naQSZn5Z$HYao@tf2oGO0FmTo{>grg*H*vf7oeOeCEq&;#-)v#i zn!nu+#FBN(jy~M8dS*f)>*_h?v{Kv)nzow(nmt0!vX={NvJ9uhVw>o<3c|u*i*6PX zdY0&|%DxZ_)fsPQ>pP<6WqmsQJg1Rt22}kkEulJ`VmQ|7zbdF4x*mUjU)Bh28rlO_ zIOpYk+`}<3*78vK1@cv?P~Tamz9YkS+s|kvtK%Iv$ILy{*r&@e7olzZ@}A$Z;fT;K z*U)CR{bac}_SsQa@o>|}`|6w!h&0(63cBmRSUJjN-VmHVMzPK#WICgZ7FRe9ATd3p zF29*-@3fJBya}lm1c9W3_Bl|v=Q~p|>d2o@i3-OQ;+|m)@JX$X34^2do=oMuqw{}9 zy}~X1M z$E`9;G*JNqgF92s-twV*`A|lvosb>aeY*SYuiDBnf@Bk;`M~*z#&!zbf{(!1YxVk_ z@8P-lQ0R*==${c^`l#2>ZC_GucDt`*{nxGv0q_s_K5nWT?pKWw}rcLx|TW<5W8NdBEM zx?1uqS`{65GxJZDv*I80gpOf|Q)X+k;quuPPZ%b>Cd}_#^r7gXzkSl^~}lxx-J|dAEU#8)1Kwv&Jp&4bLOu%WDcuNBo{QQf?zFNZ5x9whk|j z&b<(I@FP1;3J&Zc9sT@b^yu3OaOd6sz1=A<`*PT&plugT^hchZtdiV_4H0lK4L>7N zdv5aeBb|u_QSEJB>?t_vBrN09YJSW5WdA48@h#7%-;3Xx@gzjIXFO4bsMqB*BKllm z{)~2A`b7zHo(`pic`p!4*+$aF$90QI06c@bkLr&$t>;a`r)G=JKMh0bc_gk zF8!*{+oB6Ulv15c2wE8%&f^MGIuHpW2jAzNJCaY(+dr6R+$c)k$<()^Q(3T(IO4XV zP~k)qF#htagyedV5)ynTJ(j-A$}x-4@1>HdEVz{l#$Qmc3Kl|;p6=1yEVim>q{+nK z^t*!j^X0k|v6N{?t$ki~(jUlAgb*7!akFQPZU7P>lrpzyLrj97wCu(Y7uBb%U{?eq zk2wGpJ*og>ka;w!&_`@>k16fA8!vjK3r8kPi@3rOv^F|`XRkuhf`iY#(wVbb^7@$K z=FdVrCE6@{!jadWjkuPtLlY)`^5IyuBPYdo-2D3PNdORXxIvH+#y(v$Wzt4y+R!>j z)Q!j)Y#}&i^;s^7Q!Bsb=wF`h#Q2<$`0Vjh&)6+>IeRbs4E(`1j&6BEq??lY!YhLY zO1N_BGKJwA3<6QY!J<8|Smg{jb*m)%MHiEu85!PHXKOkU0V#@j;KMwOyxHoO`Mpi> z2)}FJ#LF|v>#Z+J{t@UrHB(*ap<`c=YNBv2>lx{{~^A3tvMM4lF{@6kk zLCBQ0T{m|xI^5FhW(HUhvp0o>6|EGiik2c-7cHqcO+{!~F%`|9vqT+8rF2mQ0zX0>^?3dY>8yj6 zKruS#UPBbP=uTu76sd3b=2qw)N-I%53*!Kh2&5hD)AdXK*k=^`BJO6V``2dd8~3bF z*F(aEgn#kC#Efz6kr=shZEeoyozGyWTri(?^+f>LSgI3AtcTt=90XTNC^`XhJ9}g! zP6FLeM3kU@H!O}NU~V@JCN5g9g|HB;Q#6;jMaLHD>xO+zB?sJWc`v|Up&Wp}d}N># zuGDmyJajDMH^Wj%dzuN0)90}EA_umYsRydH{{6>Sj`i!j;WMj#P1)aLc@C(GnWw<8 zW@MO2pB!KV5hHP~Ywlk1&oGrVn?Y!IN0spBU{^hA^iX!+Sfi+DIt6;L*evJ!kA5G_ zwAukK6)L6}!WEIMN`NnDQ_k>Wm1#g8!iAs?9{ymv)j79%g@T#41Ei*VE)g+ok>d~F zf<3k%6rdUz55kictC&AGK)e5vDH2SROQWxFR7rn8;S8gE(R&~Rwx|wbqaD6GzcIK* z78=JlE^J@nvc$>6Ga9sdJw;!F$OZ>W2xUSlOu*Ye&L#p?L9)70SK*v7IZ>f4Qg#}g zILU*E3ly=EsoC&te?fZ85jRWyY18XMSChZC8-(7QC5oBGh`d|YUok!3^nivzP{uwl z0-)6COe%H)D4Gl?Ad%=`@KxnN6Q(na(FM)P%mkGI&Gw>6m1R8#W8r0prO#s*w`Rx% z2!^$9sHqa~^I8>oAkwp|g3TVmNA+Km2nPx|WG}>?O>ow~C|zj)fSNroeqg|a{Ia3_ z5#I-B3)EFRX)ln;d}g}Aa-0?icvoa)Kq&CrJG0D_I31Re|5HugBIp^SgvkVZS-`C; z6s`FHNI)JHDkqRbu0XRco6j6QtF5iB$GjbHMM_4}a|g>T$me}1P%*1<<<#YAFMz&H z)S>LVp-CJe57uAO4}wF~*nn>6$28C&Wxx1EmL>pVf@#W;%+Hs(FB*T#84QpsC zS3;>u3}IK$!EI}ox`oQ593YlN!|_J)0B)dQsEjf9yi0};fTqqz$2sLbH!;-j{&v`yjlR15%=1`4H*Z+bGf$Z%;sLFBGF%dck z6vgU7qkW$2-$y*Who)=vsd)#WfjQrI^J&D{H}DMW3Z(d2m<|gt|GxT~bgCV$LSV4$ z1N~&hU6G9PWUq|%RUk%kkgM^wzBC7J5%DJP&{X6y}6xDz50S`YYu^ z$WgC;XB-&{j)f8)*HCCdb+7@L?*5-U8T*2{wz*jB3Kxj87G9Rhy%_>|3u`sS4S7 z^UoVyMf2wE?g-}2=VGR$1d#$>Jlv2Q`T6F)L7lKddM&f{Hn+NcmlfO`YDZvh0; zm!8Xlh|z#M#B#O-QqHLA)?(c?DM^%-VeLi;9jfh?!560}{8|PlH*UodVNrzx!>=o^ z&AogrI|U807&`y(O-hu0sf@(hucovl#fu9EZY|DPLJE%mgg)4j0sP2(8a6{)iM6K> zvv~|drZX-bp5#6QJtam%BON(Kr3XOT&608``XZQP zm`3zp^Iw2Hx~@*{c`jxB^qChl-l^@~-R_Qf;+79OX9HEvM9cG4o;VZho)X{soXMb} z`tYpRLub6oPOFEW5z7NNUE&f;;Sn@ZP5c?DG9d(k_$z{y1e{jmo+cS9d(hCwGSp$q z5Pdvp)>P$eu262yFrHP@Vyo$eW@v<}IIXF9Ktil$)fbT2KIym$!A?V3wr?J!iedsj zj{OyIxT9MGPQ&zMXGcWmSg_6Y({mn|nj4UJ#O~F6IjS4unwt`xo0gY*tTp$=mUi9+J}@YA`bZ$I$0WO)f*D*^oA%W;+0Yx#$kdCr2=vXf=IB>J^tO?d!er;qTwi+RCy->Fs{{>XIgQrGkTn2W3J!-z7^Qf z%{00DYEb0s=5Q3<8yJpg4bj~~ov_I~D{ERJ(*n9TLff{Aj2Yk}R_pPE#*^sm^U>MA z1EEhd6JOmcLX9$XvGV3k_p=}z`N;tR=^|McTSq%?glA0@rKkE^jcniMw^@-#f_J7$y zeQp>PWhm`Pi(QrO7*{~hjGRG~Fxh%L9pSEuog$k!9)lMT$HxPK@f!saS&E(0_)@OI zF2EunMQhbh;w90jRTXo~r!vzYkR~Om5dyK*8Zw*ZVgP>$foWGB@&(uJAQcqBf*fAT4pYSTdARgANlVyUka7+@`qNe- zOSDtY^BAWnE6Q>qT!vRpIwkPx4mZu9I128$JZSQ0eq1|XQvZPH-#H@wAICV7QZ|$> z;~8=(?*_Ox3;#fqyiku4B-+K59fzN{#xRWJ6T`}AaV|c4ueTUYjAP;`AeLQPTW|l% zL^M0vp7gZ%sS!`zVwPn`)T5AT5UrZttt~LpnE;$nuiz=emScg%Zy%r8um5 z78w=8Y?*Hpd3FzC5aJw^ldfYXuAZ#~%D;n?{6rr662zu=OzDuRn(IjPcj? z!2WPTzB%}J1*0Nu4pn!%{9PQ)^OYlmd^qy$4Fx!p#>Z-GSJ|+u-nAKw8bq$3+?g>xer4iK8TjtLUlG8zG|z8JVdfBOp<&W^gp{YTY;JT`rs_?38)uuQFO40K zj~piSmENaHEgjb|$UL;7YYt_SJLeoSeL6*_)4}NIGmT+dOxlN~w|ZLu{jTT(DPgvkB?~tc%6LZ4CZ)sL6#;GMrY|sqrv5z;^XUSEpa?4p z@yL@sfqWhiht-f}qg2O*8BFlr@F$gE zqsKMi%N{KTXcr;#o^4QdATH;t7?19nbzI*IiuRy`oDH}CjcNIDZ)nC!dNXBs?fbr7 zY3tO3XsfE=P(3E1dL5jEfM!1jKoz-BIY+Pd42LKJ@ix@Eyx*`zDMba2k>iQM&N)3z z%i^OqdZl8+MinqOOe07ZT^Nq?>w8@rguU z&%JN4=p4I(V`5Q9pA0t_0LA|ud&W;0=^7qfN(uapf4;}6iz%NleXvTB5D+_fhCrG8 z9rECW2c;fVB%@skzEvQl-Lf@J+ANNZ51|2($O=q?ZDOsbRCM}kxwRqw_K64MrRryp z+GyP>HO=$tzZHn;TSQMkzsl(N3jt#K0*TZj)@Ue3Vp&(FdAo<8oJdl5Lc%ak2ZkZm-B{>AQ~=Rwl0Lgcg_#Dc{ZZ95r*Xa~B@VShVIPqCqJ~ZU5PH3`T7b<(NE3QaPiNG*I9S$RC zLFMR4r@XXOtTf~ppxagxnNfps(_toxiuI3|bm002vdyK~WD2B8>_wNsiyp5ReQ__Y z7r(gC@#5BB&CoTVVTW==1AE(b&c7J<0J8YD8>?Yz5NYcE2-Gw16!H^Z7;V@I3e1N) zT4oX?9{J>rO}sFpfZy`buOZ;u-~c@1)%#$8bo~{p>D3Mpz1{3U^HsQbpQr4vyuG{2 zo63FV0o~!|2A)qkk6*4nw*)(b~X;O9_w5-71d9^i5oyf(=2r7S@yzXNIGUQwf=1pZa z17X~2&Rwf$CAU4|a05SOarRXMUQ%UZMT+h%1AtS&Qo)cO+n_zJp{R~6=yW`##M^Bb z2KgUR_Xpqx-}6#x<7szpRb>rKLLeT#6U4sOg*`>VxZcb6j9}I`&qXdhM?5o1+^HZA zP20O{SGIhzT_)CknP3P#LnRn(WQ=g^i}y;2bBfyjTVadIVQ{-5OJLhUk+YmM`BN#k4;H;GkuDWpo(*0a%!R%%B`vx;wQPslH;w@ zzd&d2Ck}}qj?F~J9ln+B`*s|H73;ev_I6c_ChxkHI=#~erXp%5u$7F3OA3S^nta-_ zbd70yHBmyAhdhe(#H-o&b#JOTzWWz+F^Xxn-?Ywc7|Q3QWA1t%A0yk2UQZK85ZL%b z5VFC~;1@LcFT?UKEf`&6FaDWBZ0L35HxyzoU%Q`F93 z{gnpg81W%)Mr;%a{2$`#km)I$h?ok3>kn?)t3F;AYbO&lk5m_HfkSzyU|)+ef$hMS z$F{xILp1PQ^7q3AW8JR`M{hJZQ4whk>uLTwzMEdsKWY`XbUYClExITAitXFtZ(K35 zGc?`g_7rrFkJLpQ^tUq6%kjG%6768`_D^PZj1gPr~PDqFcw9|A8N5 z?yDHf`Ok)5B3SQhG`T|4&KXB0w31DVBwCV&%rEv-b(_qXqv2}JrYw2QBDJ_BD}Cvz z-G(8KeAV-w{Npdb{QkF#&JhTtMu%QGB3T$_rhNgL2Z2HFk2;RdI*hwVI@pUYQZq*` zILc%oVD_V9(BZ>HK{;a}gwF%TM@otu_BJ6iA=$a(u-R-!?VKzrs*?i}>I_+dkk5`C z0FlEP*|qhw$f1IX48FtnLingC;2=W*-;A?h9?03kOhiWBA(Wl}w1CA#AXU(Jd<2v% zgest{$lKtBPN>9D+ct$Oi3H;j`wH!SaoTYe_o) z6zQ@DZMbX-Eb&{`lH-D#7T*<*W%HooBxb&o8mycNH=wIO71fhZdJ2ho*SvzkU^b|w z_O9e1^jGtFiX1|lGAu6^M3jL;dFhAT_6DA#AlptlM601rJGUxdy2lwQJUa=giD|Am zz8=$LKSOCU{(!oKhI||=Mcxhm%5vFD%5YNnM;Git!(ur|U|^zo$rv#aX|JtlP=Ahu z-Tsh9fvMK#f}no`E%2-f`G+&&XeF(pPqq~|Bq<=%9ng@AJr16-6tbk7!Vx4;Ak(qw ztXb%P1F=^*f0_j|++gCItBC;sRGwiE4W5_B>L4J)uc_iX6XtTon`VN>Oi};ouu9X1 z8+@8mAJ^xGmzrXxBbh8D;>V@D@N-jk6}-8B0mlO%a9OsH!rOVW8Gf`0ss-q|y9Cv# zEy}&X@arD)r}S1fhT0j51#qa=DIQOk>AAlRfr;%#UASb(&5ml?IpD%-T{va=%9OYI z(E|3vK+^N{@Y@oBivL_dGhwle$5jcZF>jzzxY`?6HNjk@9A}J-KyABjpq^Hq!j5oM z{uZl8oqj)9YMvY8m!k77=`K9!Bk~zmpT&M7SX5bT;qDn6uV3=uN-UR}E7ssy>?Cy~yIWQiUJuar0K}5U*xN7}SZndV! z(}9-D=_YrPJaU5KVVk2c z;KuAzOIWM`lf8yF=+m0M%+jt)6=K7AELXlw zm8nUkaqRlHrznu)wQkWYl7-v}Qs7M(PY4?%^SenZF&N^V_HZqJbL6kgIOA(rKl~O4 zMVto^9`lFGXgHW6OCD9=my6iy0t;LLm?j3sWtq%%ign~iGwW0o*OA!B-P$`&Gi&+% z282UdD>Lnkn)`ueY0-!0}$OP4mp(=3H~M2j^(Vqo2DYEMl11?xR4ZSu)nCd?t3Pgcd*_ zbbj=p3MP9?PrXiyEv>yTZeA0T7MDMDZ6IOdTUsw4N>bv4Z+q?Tkmf+*Xo~uo`9rQI zcDz+>_xOWq7_<8{aY4l?RT7bp$A8p6(b8D_DDCoM?s4yv18R>Xkl#nV4iiqI{AT*N z4J%^a>1Ag@>u%dh%Plsa^Q_=Do(2xBN#U5B%!d6p~?EucBN~ zgUh&N$NRoJUX1;p3(AikXk8Q=Z|c^2u5~@^{*u(UrXG`jTC6y=Wm&=IUUL)e%&hy% z3OdbwM`O+(O>pw2A(btGwTsabD6o`5(Ch-(Ab^J=6XA zcCp&YNrdBdtx#F+O(P}V&nrhYE715!Cp*z_IPuB(;>W)S7U-XL%OzC5#RX~lCl`O0 z8~a)dRH%DZ4Y3jRU$8f%*G+Ev3lIQv~ zX!@hsaGJBD!hhj)u`@G#U;ozL^Rivq9E|!8J95{#h;h>E^}Uiutv^c?MuF7Y^2~&P zIuGv6o-qrX8tosF-}@Q-$J}X8B%c{d{_&Hc4wXP+%2idrSaVP?JI)1 zeFdZc`-q3?)o!J2U~oRCSq-m5xZ!UW9)O zS=2MeMOO%L5pe11R8Hx>nJR4~;D#Syj);s-f5^CHbW@aAgv*nv#YAC<4K|`2iwTpX zaJ||)zIIWEg&6VEBJ4Xsnj33YHz;$z9>MuUnWx(mzjR);wYq-7W6~+Et~PFA`X)xwX;xWUo6<75)e#vd$c!b&$^PR+P!Wr=Ii{Q&gRyMsa znTTE{&|l1e&uJf;yThs%TN`cU9gN+hlPnX0U!eqI(fYki+w=Drd_~>@*WUd3jGW>~ zA-DwA{yKq;UyvsM_{$?ci@o|=n=mC`+oS5}ehO#0D}wW<@JGj24IWju?0HmW?+Fqg`6(a*fCeB6;n?Rh z?jdVf{fxn9ISfiI7k~%QREt9Z!d+DM$0QcTPUQ|^IHp0fIB=scG;D0&iiC<8`g~LG!GuwpKKT^y_(-+j7T!Zr8HO#)^z6J zK6L!Tm@jsq}6RkFGk6D?RrRpXPZuk>oH-DB`}ySgkxE=E%Wx!82 z3B|Fzn$Iy~`1EwnxfOU{v=cNjOV?_M7Y&-BKmvdzqu!={NESfn?PdQrU$#1dZ8-S# z8HKVw@&rGlHi2T~L*6UDcg~9yIrvRbLXAj0K5+(kK%>=0k?V~ylnm9}kObEn#4XC_REE-)v?T|7*`Jp3* zg|{zQjoH1fbiKDv;+;yMrg49LSS< z2I~0xfmb=`I)$$I=*phe3A&{Tp(n8JTkMjqMVVyjQv!0b6-54$Bu%-c58sJiLzRv* z%IhnuX+<>MMRT1Qs{)nM5Z?8@2R?j}d?clG{u2n(AD1=;;SCu&NQaxa+eV@!fY58r zZx+Fexfro97|ifepV@X*F)Msm6U}?4Nx^ugR14Q-9h_uxdJLJ(hGQU<8nQ&Iyy9o&Xc6EAOTjXY#yQ zu(A!_qgaU0*_{RDgl}Uk3X&oMNY^Of0o+MtP27FraiVk19Dy8eISux8Vv~olYR->B zrXLO_ zu``XabLYHj>iFM8*NFew1_3kImyI`dw8d_mQlF`)nL)KreFe|aKh7-XJ?>VYeOWU* zr#`n@GxtgT?a!LG|J3JE59WFIFNjayADyXpRj&fn6BX=oGl~|!f>=qH*ftUG&fA09 zK0EX2;x2Ha8fyjS+VSb8PaYR~`}o2t0Z9eU;nMyz-$7|kyl;`g4m_}kz8-$BgYxDL zf{Lc$9W*d&0?Ak{#@^U~I=Q~bID@*ev*Tdg-)9^Y@lntsDI zoaM~GU->BG#K3>BCbCyfxn>{6$JR5vLI}M}xkp6j7i7{9iSc8Dw!3j5qH}U-1MmA~ z_IY!5sX9NqLj?Ex?qnDF>d_nOS^X%c=_JP=Bd+w!0a`UCi~QCK7$70!_- zFfdQrUqhU23M0ZpYIMX>IUl>#hrEryV_L~U63}0|7|W5hpWPrNiSt%H9)bB{E->g* z8Ar?M`LT2R#)E_}24-q$1K(UUE-@ZvRz7S+QF4sH->_KW*C&1Q+_t6pcf@l^bGH46 z_7{}6{1B$cR6O3D!y8|Vz6gELCRjes4U6(l96+@H6J z`?n+W;^0I9ZW~4@5PDtILMiVvnw~k5_9#(|sY?o}+njUE#*WuG{^1SfZRn(L$DOAp zX~;lS#5eIxbU+kICOP;6-TH&*TlhXEC0t7;5;>q-XH%8|H`4UR&BY=M-qSd+jlzRg z`#oEeJ*do3+IO4pQYK|k)BUzsev0*0kGWy}Y%6EZG6@WJ>&yk6FTiBH+O4`qo@FP* z;2(-l-SB{>C`J@!LHo5vj`RA06ruo6+Mu3g zyU2B&4|~|eo%Uys#m$AOj_X+`)2YsCG_I{tA7JBdt>5r*0IwEjG=J<#lcm7YJ^?}~ z;xXssc})N5 zp`!dd?x&c2NK0L^97;_SHPwhWm7T?;ou7$Mh`G{_yspVbK28l!!oFwpZ%80;gFgms z<)0=MY^uUxPW7B%$g_j?@OElo;&wlkt+n3Xr6;1BDcO;wsGP;MHP>pZV| zpMs;@)Z^m8x` zqIWLECs==9x8VqX>dcVAuo1Aop~;DznE8VFtExOuRf#xdEcv6Ll6~O66C`piXD7hxL6bc(p zXgpfjG`|svB~CH8df_)ey$obf zxilMOEj2A_`;FEP{gB** ztHo8-MEcn6a)PK6CrVd4Y1A%6nHp#Fwq0eyHNKMw0; z0IWCQ>ryLPU2Ys;4P{)S=2u>BNp*n|w@47ZFP(|Hq(iNWnQpx3z#raqIO zWbGnjGEa`l<}@=nT32K$td>tm@{&~uRz^}^_;IoJ&Y9YeJLSzhl(plz{4=zjk`v6+ zEIlFB_%z=IEgKJh@8bECLndO}D&y>y*<{mI+lQ||Z?(n@fBp;-(9!@PgXiZ;;S!aS zxW7mdmD5Oj%7mo-=Mw*xB0f`%3z+)m)_dvW%B&!g?3lmNe zmq80iR|}%_kP^qLfu3P$jOGYQ;5tuff>=Ed8Yjbx-dnUVM*?mOGD2KR)in=XIy+=g zRX83Sl_gPN2&FK^YP*HB<58^qy$QjJ{T|DSvKYmH^7$|mo6YZ=@V(xp=I%Ysk&3A# z1RfV1#41ipJSXz=3JZGB2FEpe#Tdfh11g6w(xUTp4uncAASjbYWG4(`a72Eq`6?zt z!cC5DHxH1LTn{=Dn`lr6LDlMch8hILf|!{|-@FmXRQ;ei-dR7lqV0sttbaPC6g9{+ z3Dy4XI?zHK&NRY#T5orplBxlaZ%CT0fwQfTzY09hPfJX{ zl^6GlTPd0u+uF>5w_42yat?LDv`dZ$nR|&ptwG^5FXGPZZkffA_3LuXxkN}rh^+rZ zcGE4EM;nr+JFTpC^WMrhFBwvAwKY#s#aSd=;GSZ?4Ul%rlM~{7xrzc`AK=^$zBySj zW4{+o4<{UC_W&w0=~MHBo*ztj3hTi7a+cjEe(U}A&cO6<5BlD1_B#kKn|Ua2!9ZcBLN?3X9C?3+!x}lk-EdhedwogQ`@MgQJQHM z?U$hs%TIU4TF&ZDOFvR?-59z>>0Vd8pVTqtVRc^Shntc5Ju#0A20|<3=_gZ_U)4pH z@oEgl0pbgzux7$rK?jTYc{{H`(JT)p3WXkcFnrJk0Q1jbY!I^lw!pg1hyuylUT*hN z^sByWXV7()hnKX*4!}RsyH(Kpq^9jIN!3m7yLn3JsU8u^0tu3iOf8_e7O*tS5Wpbt zgoJT@2N!uJf{zd73_|OrCI-9(B3D#5}{etqK7Vfpr)=1|A(rfulh(_B`#RFbR-7 z+pP)oGM)SI<$}8B!BLi3!n7NN7-NDML@#$iIFYW;YT=TcCllrY!KTHGRW)0)o+?9FU9Hh5#waQi_K!KT6Mp3PhE;)W)&QTMo3` zUkpAihN64F3lTEhN&*LbxTn+NsD8>w_5L6A2|xqxW}k;XHSWc!pT{LDPcI4;zXwLc zh3=|fGEeW)>o#4TH zal4$tSFE$T(mnN;onurc{>!5PR?qq2a=E1kxE`dU+)`X2Nk*BySorB(#S!Mr1sZQz zD?=v{bPAwm2+3C@9pM%HPaxGC_I6$u5C)uIo0+PYiJ}cEbulDWt*d$L3JVO4d1!m8 z$BO3%>n)pEd|^^PuH)Xo@>0c@67j)Vq$+U z^_=p4eRB3^MD}3$nZKybp~&YG%%gqZaB02(M-}0O(LmAIs^k(Iav?rC)-e8fD8Ted z5zt!|KdoMJ{%y;g)}*lB`g^UV5k~uo)t?&gu6({T^VaWo>Q=Ymzb~vCt;n}`2q(1YvoD^bLuisU zQj^EV=iq*YrPxRz zmJq`i(8PT>WR)+E@j^IyzO`~D>FU7Pr!ofD{XW3WI3>CIoWh6Q)4RL(+pp-=0&osO zl#YrB-QU`|-}(`Ffe8axfbHlZ=S1^TJ%kjVcZ2OzQ)i&Y391@V2x;d{*wk)M6eIv# zLnJbNnKnkzl_D$eUg5uR4Kv0wS70eUeNkOeyi`#+v#V6&668e>KU0*<{k<(HT z7jX$Vf!u=#A_Uf~_a%#)5LgXRI|lC2;MJWdj~a`5lr2=davFc!$q zzS{c(^?c<#KL=660v=Ry>6X~aZ2Ab1VB;M8oUVqwt{`gOL0;2CfJQFbL{;%qv144y zq==tBd-Ii0b3GtLL>g!c3XdeC%wePn&@m4#u9>2naq7IobsIYajYnY46d=Tgw7(_+ z%_@5Yr1qCCSV$)O048Y*m*K~QE;%PQ8R7~D^capGBF#>@(8zTn@Ok(fU9?Ow?!y=; z0?A+!!RtTxc;f^^NxZf+oHXl*8;iHOE0q}s{x`*IvVcO$%dpongGNyC3-LlOn6t9! z((}A!G;)^qRgV>1Ar-y5(t9$3Mkd8N>ah(zG$B-2fF>lvHJ|aEDIjvt4@0E%RN8>e zxrN#+xH%VubXI1wgu7UKZm2ZY-V4uIt+0N{u@`7?6NHSbC-KsD4ARJH>AE9)Ol}%d z3V;N=7&28a550$H@B89nG09Ukl9jORuE5Hw4+A2YibZ0Ws#?X5cCcM?_GVaihO5$W zg3^Wg?1Qz}4{ay(C_9PHTW9&^s1U*8^287NiHjUCv`^rSJwb{C?x2X(tfcZygM9yp zkf_3Ia*Ts`P|OaJIGKFG0OP>YtjggA1CIlA2AsTfw)e4W`}2K#CLtqwZs-DXx&Kn6}LU9N)9 zR?PE_6SwFFN*Cl-az=Cdu8NZtYiPH4P{^eu&_@iZ1p#AXBwom7D!+7E3_vh|BamDT z|GBC2h~aJ+_8SbtLPW275@^<<2>%eRf{9Y#YgFEeHiXS4zghQwd2tPu5-B})Q61BO zdjY&Q?YN~$ORUo=3iirxNJ=(90d};Lp|>{qnH@AlM;#Q7@-N81f3%}qxp~#q@%Snp zJv5K{#F9Js`v#SRYL+b^K?P8vqB&b*MphQ6Pm-W3b}``mbd**gETtdVO)4p^tWtXO zi>Jp0gQJTs&%<5Hf&B)UPxFYU3fx~rGsaM-mgjM|QA90+@~_(w0?27lZZT#$0t?RcsjO>H97N`=lCEIQm8MfF6p{RKpI}!4o{nPTLmrbU z*Kbvc{vl!5=lFXxt2k$?=V6bD;`($J4=l9hya8k(fmV@_%sljK0jArIfYZ~Yo=EO{ zCAIA!qi~)uX)pE}9q~JmyCqbv!ydKs1*JbyIE$55=H}l0lM6`w+Be38zpBf6?<94H zEdD$mC30Dg_5@$Z9~v=F#BRo17!lhE$m zlLzgA7dV$+oHx1I9_<7~gw;!5nzwFQe{%g#(`5&f@Y<}4(8c?ifU5G9vqYnG?&hLD z&1{L562q1<_m+ywEtR)hs-Cv!zPljCx_X@nK79Q!?w8`-n~LbM=F4pHmOtSSikn9T zuHOICtaX5S!?(Hbmnb(^Yib)k*#XRbi9RWz7Fc?DBq}K@U+R!(vWA~r_MSS_SN=Q=~_SW@bdh`oY$+YTo@i%ATeH?bW1%?h3m@uOE6}Mq~9ab+!|x=)%@p zl2V)c9w1F+OY-kN;s2-z9RPfa;ntRwc>r-0{s5@20*~iZzC-_a|MU^OWk&hDb%Z{x z`);0CIO^^OKjnu3p+%P=5BF4#|GHp%mkYnIPG?k8DR7i1uLpu-#-w>@sFOZSZfIl+ zylUbKt9NSog7Dgsx?Sn)E`#a|M1&yatT%)o;F7{4xod1r-2U)VAv;G4=|5GwwJg=J8az{ zeV{6$6kQKyiQdoyfS#FTbxotvAkc$qvrDk&Qn3SFe~V{!?Uw`u)zQCX3U2-_AqPkA-MZQWd8x|*2hNDHbvpknNq4^-7ZV62ds0k&u`QY7jEo6^ zZ#}9+Ct!0T4v)ri_Q$CtisE>-w=D(2a>a^sBtRZXaU`0+%?(U+vJ3^XUdF$8S!4|$ zGWKWu5X?UC>OzgUFc1_q*yj)MIMX8bTUQU)kRUtAC}2Ve@-}xK+YDsJD89Gt(Uy| z58h50!y?oHreEKsGxkpvDDX8|e~2fHBh~{ct$W^VZ*XtzxuL5)+qME4aS)50I2T~z zv3=G<50nkW^CW3W752K3BeBm!LO@eD_P}Yh;ywF$$aHQRT1Pa8V`PXb6*TrJ%w8RXyTI!mLz@X}T=D6{yhxK^jIHKX6ig7%* z=ObyTr*<(ubttZO*|!7m*^F@f&ZDvS{o4y_*{zpGh`M}wwIY`?G#mI!Vq>~zT{sKw!H(QfP| z*cu4&1iH)ADyLbw)GKD*`dA!bY7^Ek@OaWsQKjUu(Nnto3-#l#r>lw7U0dF)h%qtC z^2Bbrb)yq>v%)vNp#tY6LIqD4TW`m>vIEGkE@2M0=B+3xJ$kg#`y`qstC*p3G$QhR zHfY-_E5vCH5LHZf`5ApoMQq~f@kI-s4%<_%p|{;Hb${Ld0B(i(UqU}r(g&Vi0xC`$>(%U{WPkk@zuj=Rp2Vn+ zxp{~C5vBQ@9oRCF6D8Mw)c1hica#`V41O0|QEC<2G&nihqu!b%u9?)6XZi;QeJQF~ z4^ciPFD^9SHu6W%wqa-v9({ufim;w(RQ@{$8LY0NT)&bY_W!h_kM5!|PM)Fg$+6Sh zXqaWW@rz&lDWmpx{{+sa*bLo&ETbotv_SKcu`mlVk~@sDs$Y+$l#Z!9KJ{2no1so& zSPI)bH2-9V^-3sueA-c8STz0~nyyl1JlQ9oNw<>J7Zr_GPN{-d6rVAxsaS9)>KT;JSr>=|(7Nrq%J#a7Otc(gm4$PK|X-P&Kg!bc9 z^z}8b+POIC>rQPtUkr*4u(Z}K;4*ND9tmnK)|5~R2AzfIK6B5~gF`q2qB1)jwI6A6x99k z_=ewhNfTYixN#vNNDjX^>>{B+4tbO`m0?~f3$WN^CN9-dH7JVOj=-kJTGBZQ0f|M2 z$SYght&O7E`<-_^elVn3;^IENh<4_v_VpQNM+5^T4*?m zm%A}Ch(6x|$9Svi>WdXymRTMWw_!1MQBsJywrIYc1Q3oHMA*W|NmSNBQ_D>=Zo$ff zXbM(Wuv1vzKxMY&>4{ogY-W5};)YM@_E7OkeH@(vSfVnJ1E8 zli%96S-qCLAHGq`&;Qo$W>r;n;0zhLR9-^3$MJUnZSp6`=28DSl{-ZX`9Ef2c?v8! zadL{tbhWIeFKxRhCDGet5_VJ13N4~m3wU9uBNT-@Zx-$ZMy<=1AcW|@teWj%^Ap%L zEj%+qh&H0mNaVKzk_0S?JsO;3B%?Spk_DkLfI*+|M5duU9LF)UI&25qIhSqW#Ad+I z4IBoNP{tnPmqx@g(U_IjkaaZmyoHN7apGh^x2}(7NdE|%fs|@xdV_{}=Mk4iKRAP@ zp4u{4r{NIvs7%suBMYhB!Xm?aI9Y9~4tUqj!#3>bGNh>5$UY<-n@w)4sheqhT`k-( z2C7{=39nIl`#a^q9wec`#oynz4TivNN&|u#2e9t>^pUYgzCU!%JeH9pdh>N>|3&S# zMG!z7(zw=NtL2lK4i8ce=P@m&0|cXSDuR5%JQ80SnBkke7}|A-K$!DNnd02Ayo_m_46iJ~|A zJyVYpA3(#@XtxQsY3({Qt9uaW&1?E;KNS^emuFHCM5A`+RGQM3ET=jkCvg^^e? z#7D$~SpWkT&AfqJ9%t~dkEp=o`A|)!f?K+>_#=D9SvNVO$0~p$OOx=kn}qSB$_1Ah z#>SFHdh$KHF=Cn{;D9Cj)POli)|W&66zH>0ZrAlRBo|#LUgx8i;&#_$6mKqYww^)W zVPPP+kr#h;r%k{n5;`fJ%)k`whjGOM!FoXheqL>G|L+_GUAf(JQuW*gWe2VkqB4XS zPJ(sss~Bp$QKnNlO?V3y@fFF;RSP-KjO@^`9|5p=_W=Y5%uZ$9L}Kiz<9iU!i3Au- zKY+KJB69F3ULHZpcehptmGeK`lI|7` zEl(C=T)}LUh{F6zy8qz()cbe0@wB2Fa>dc~Aak>Z2nIKehJM|5Q{6c%cCRl~%KtSL zi;Ej^mg7Pi*`X(TnO5II%{)Nbg&6$JDZcv10VW90T?kXL+_FDG9$IE%L#DL96g!DW6sXt6 ziar*fc>W?_6mGz&w*658hco7CQ42MZycRYbF=7Tt(958% z9I4|$Ddf&CH zG(~CuCyeu#W#~Pyi^X7ck)3~Fg?iw~=h|_Dfg>xH!W>ZB9fIwwWFk7Jqdj~%*OX_{ z+5*^OV)|%V=F~}g8as~;oQDq0ybEJRW_gT>DN~I@HFlit3hPNKjQl*EN20Loq5**CVUX(Xl;PTe*kodHjWC*_bo2cKl@9dXU zKcgk_xB5MXpT69PGg_9DG3c}YhhTCuV>f($Y7U$VG8RqJ1-!#b?~Nbjfk`sa(d3~E zpFB=w#lbdqLt*{pwSdK))-~?W&9Af1e!U^Gd0sg=4gUMg&6@=gi{>L$IR!M$QX3dJ zI2O^2ve%czh^wjnatztOc{;M!=Iu-v>dN;S(~+ zH+XR{pYOA)>Ng!+28W(;_(sNvk=@e#Jt>oIXR|Fp4+u_YT+CyvQIO4i4n7k{65uNY(bbBq1I$aHMqSzy^5 zi0n(rxCLOO3@15fC5K^pa|>7-q$WmyVISll>+Het!$QMOaXP)GQgoVP0b2FGq|Sj| zQ#2m4vj=^Ga&i%94*gEM2j>l=XT5d&tV`cJQP^NO1GJmK5)u4}F>MAK@x4%TqwQ=# zQZCy)7?H>=Ns)sXRvc3vWpc39g0QZ`sR<7h$(+A8&dtBwOU*AUgo*F~YKZbrY6`#K zll83hkFwPvenz(8R zT2p)ezv%Jp6ae8YE!>vWlidgO3L^BO#-KzgGEg7gi@7jyOMxL^Jh9``#|<%vZN7^W zZE8bQ#US%WR_T6e>>vgf9ByZsOI18dRdBguvO(4~=fFo_+jnIMSnfj3Ko>r`;}+7K zAPu=N-fDD$NHvfr%CWFF)iE1=VAJtizK`MDg3X4zaf!loy5%B~xsx#Wrq9zevB=F~ z5qcn7-|BOC+K*iSn#|j$xB&u#kZB9mRF?{8gF!}%|GBiw*!Zr9-5`ZKpN%4!{^Xzm zj1||S)sg+kUCDX9!cJC;X-nc?_`i7IV*m0e>aY3C`Cz0OA4684LGXo|jB0QYF(-hU zJMErX{ONyw)|rKuhe(f@<$uc+QpONhu4l8>6k=c!J`TJsBqs|R z!=eRBn{ZexZo5_=sJPszq8;~gJz3$Fg}wnt(Sg?p`wGwh!Zd_w8PI-~2!NK1;Cfjr z{0m9ljD2pd-(2qHt0uevOb61Y<~w7**$7z;>>@hwSfl)&6kEWqxEnryttBssawTUx znlE)3(Bbb@ADEW0%=i{RKbh9aPvO>iGoh8(rA|QRioPoFDUwN4Q7BUSW`8gDIBtE! zFTdj5?jDZ;@^MiRK^`Z>=f;J8wx_NWV*#7k9E7I%6+JR`g+B zb(i^y=h6Zm$fztQkXMpGj!OK!T%NVwV@=Y4e_C}}8ei)Oe*HE`2UcnaGlJj>`i$Dj z(&xd)kn^J_4`3RdgMcf+yrU9--Q-F*G94lNgZ| z8;${|gZCEH?SJBJu!?HaX!|LbooYQvZZ27Ibo@XtGnDh%X9h)8w(6mSR zZH7l;UCRDLY=I=MSGUUcC$*C217AEm;C&6556_278os@=PEvKf}#-=xCydwki z^HnrBg+*Wft<{R1`~LOpS4Hme9QECto^fYeVFT}X_xi0RS)i_8__EBa^Abgc(x`ae zYX8&HuBN~L?(fdg5cE9vSFiBb_1zq!3!nelqA6lo<5nPW6E*hHG^)MrqgmU|%x9Pm z9d1Z(cl`{v-b6Ukk?xyFKRW94CMt}Mesm6T7aV8Se#`uv|1t+aYrasC!rrV!Kc(-E zE_N-Z1d6mFV1qDAd zYxqx}_FTTbqo9R`$1t=j2iUC;~%NA#&6m%K5E0|dM1xxszeZF3XMi~1?5__p+XK&X4l)XTpp%zu)lbFIArD? zOU<)v?lnb!pOuYFav9Yo`#=5~8M5E{+Zy!9@3T&!as81e6^|Y(?C)4Q{HO77iw>24 zSybfUKUJM$sXxO{veG@%o=1MdWB9o2QK&MvPlxdM!kL$L`M`NywYF)jbkg&FhnB?Hu61fBFY9Df z(QA(%HnUh!4vb;GzYE}gb3!$ofx5)zo6pVmMhXlil725rPFdpZ0HX|n8^hgsuhI~b zJ(;IvEmn4OiwCwO=q+R{3f_r%``i5Rgz%4%`tsWXUB99I_8g?-A#q~Px%&_KS?%PJ z0sem}rkTJ;a4A}pFxEZxc4NEVqG!665XjVSv5O<|$_o^7JrwXjHN*oyIx3~fG6ktm zgL*{#y=`c})#eRUZETT7(EE`^-j}2PL5#~rxd)<~#S355y=jN*Lk>R@egqqSyk}Wb z$)ig;(%l5Vq}KL<1X9W>fO5WHEevhBxVmu*IDpg-XTb29k1j_G%_s1O0JTsmGTt8y z>c#=LYT$RcEBQUTD30AI-}vsP(m&;?Q7vKZDnx`wluaYMe>xA6FW}ttHfz+Z^7y{% zo^QxZX#!3TfX^Y*WIbkX(C>8@Ak1JT$v3U*#Sq3w;pyE!Jy};O=&ikXV5Xl|k$IC@ zCp|~QQV>)spE5z9?8LEHiNWm4dGEU?<(dB-1;U&K?vC0}c%D7mCBOUybjveS;&2s% zH{qpkUmF*S#tpt!d3&t&V@Db)#8#zzUPLtJSB+6~XKZz}v^>l67Em=EkLmHKtG$`I>Jd82) zE^FRwBJi<6%wFv`$NM~SwZ&Q7^^olsA7rD;z&q-Ti&1#tjLvsHcCs-TaoL~^C!_lt z7CvfLx|o4-$C9%9imFE*zq1K2AuR<1)`7 z*Q@d=Mz&TCf(b}PTTME?$F@IhUr;@9UW=&&6EjiW0-ef71Tfbe?6gQL{bdI0Y8T+j zu4ZA*qLpN}cAEY`R;H(7Lntxj#=cc(giLV0if-?oh+pXqokGlk`K6Hzx19KaJtJ90nF$ zG@hz(SkyafY1)*jQk#k6eH$J$_4AEk#5=`)S(wI0kP!|%brr3&OMqaD+$#9JM7Rx( zn7{W>nZ^7=MBo)qWYE|nv0{_!mU+a#_Tg_$bdUXEucVc>j( zO|}GI?12G0v2abYQF+jk11Pdw(uP0~HNj+YyUjLX;@KbyY-;i`BRP35P&ZXkF=rs; zv=C-u%t*c2aDR^zNLV42>W>|>B~s6#8%_OMGgWx2Mu8n0xtvwf5$$1U#1|4u~iMO>E@rFLvxAZeJ1&rgp>V zHB!A&wzFqhn<)UpQ|d!TWY61W!Yk%f5+0DxUY{%C_q@hoi!_pSc(~SoKhnX|OJiaM zo#xmGt@V_(R7M1~UQ9ZvzH9DG)7-uoK4Se2`gPf72!TK+?{>lYo!DRbx%&s_i(pVH zDHvdZ7J7&)YuCu6{z-x>pIKz}ER1NslpXet;# z-0#`@>|2`r9+%k1G12j-{jcDu>+Bygtq=>nr+s3fE*XAfX`e z_>spb`U7N$ch zM2a+c*owJQ;)|y{i6-y#@i77%M7YweH7U@tm*36o-)=rmeK1PI5U`H~p^v-3_z2V- zXbM2qo26i^AeEOGDexa0@==?ka@CY!9ymS`Z;Mqr8bwWkbpaj@XwW3>tvq}2F(HcT zEeT-=!T^XRJEG<4p=1|kvGC;s$2)l3MVwGTzSr8G&+oN`I-ZzL;68?6fnZkdbDA_= zGsKe$euyozZ=ADM*$r^3Stan34b>SUdpKsv3OZU50#FHTRKXUeNV*0TPoN@_D_OiP zS}>D)CKMfJ_3cAW5^v09_bXFhUgUBNixEiE;bSWJ>dR~HQ=w*y11tkKXfL3^6kZ8T z)x*U33ho%g6HHSIF37@^NromFOaSW{z^knXBg$7+mRf`Wp`HL*{8#*FPc`Bve6RZ% z1w#gf>BDOl;N46+tX;|dGb?v67TG*7W74f0))LllstrpwgJbqmlJF@xl~G16069{a z0*5G{74*&@mJbQ=OU}pJSV^jvo&{}jtT$bE4D3kSIu=u zVHXjZ#Vgz`NUKe7(NaqSjf(nUs|YqctrV<2uG%rvSSUu$v^AN}RFYQx>T&a8I+j@i zZe0!;^t=FjrM?9)e0z%+>$=gbaM4Nd_d7 zmVq74@sDhr5nf6s3qWRw^@FPGM57V^oq-wjepr3Gn=}xeMp-0@C3uSw-a0nJqGQF$y>coflCY8^2S5v1ogYqO@ zO%3eJfCBJ8L?`l8=np+FQI1QPS*UvW;Yws*JO<8#d-rI(f?mb^FYgNXS>ij&G@}JP zBguv?3NotvpR7K{H1-|;dgDamhdZyIdj;6o+5=(=CH(Dkw>3Jm4`cFJ#l?#CE^dtH zMV|H39~46$P{k3)(^~Eu)j;w~J5BxF0_BE98g2t@MF`*50=F1HpoqvPsGx04d z3H(C3hgW%3znL%Rdp*u4CA|4d)zb|PF4xBCfAgajhHy#3|9Qsj9#DYu$POYw9up*) zan_>80gqB1or!mpE^;c_-?2I~ReZv@{iTpLtp8ihz6;P9)Xy0FAi7H+y$pCTyIj|Q zzR;F4zdTPj>WJxm{p~pUD!r8>U%Ig^clDvmFIdCmaC+=MA^_uApTbS6SQshQ#cSQo zZ0=$Nb3JEm64Yr6Ey043tEJqRUdIkX3_s0>9F+DMPn2jHF5;*QoF9iccX7{NmF1de z_7v*arpE9XQ$JJ3;sq&7@jjp4FHuvKp8`dGa38?PsDpfU=Cb6{^C}dHPfy!*KO8Yh z>lNJBWT_tFuVtB)>TNQ5LZqEIp01BAKmr1-xmecNE}AA#V-F83mYHCmcmfC5v0{;$ z5(>wzeh{FS+rh0gxRYLK)}&#KNZ4beZtQHU>|Sg9+z}Mh(@RK#f3Gaq2;?z*6?-Bq zbM$#j6cw14Gcv`5C~^mKFg0udSicAZ#e>mjvhd`HGAu7i7J!f#F06jd`EWn88tU;k z5Vl2AN-rMP%tp+h4`|7P%5n|`OfZI|T1ySaj+qhWq|p=rO_GVgV_QjhQ3h|evELs6 z{E#NwM8PV);<>Rm2lla!1UV&4{eTA;S;&DrFVg|%P=hvU)u)q>B`S;dsWW@Go&f$42T=48TbDlArzrTAC@fFK`+FlQT#!pyJ`d+*ru@Vd zy%Cd55iiH?Bva-Ol?W=T|Vnve{fdcn>*z(42tLdx3Hp-5sGU^0u!ETN^{+ zKUbcWcoIF!h?fD62!+B9xTy@fW(YxgbsI|6cuW5b?`#;CAb9E;1a!gzlW2z_j$Kkc zLUS%R4H8V;2+xS&n(yU`9oUy`jj>_vKU`KKGNUb4@NO-m@95()Y**U%Si zAr~Z5<(oBc$<-hHul82^z^%t0Z#~5na+C_Y%nN%?7xvvK9H=d<;i$zj;HE+t{_IN$ z^_P<)~nLi zs}o*S$GA(xSx63$fp6^n)EqI{1w4j~0Maz3{Qz^g&P;|zlwkO~?JTo(m_Bf6HxsXU zK33+(d`h99W#YmiUW(?5L-kUG;EsEtp&2CBUX~B-ku@26n;?Y z(SENuo!u}gcXxRBD;2HlM&t-RXQwk#fRi8={$fLZ+CfrHS#|oQU+GJ!3k6a?d@Bs` zcR%P=%juQbl$HJsJ^IGsfmuP>?+v+Xo$3pNM-V=Bn-?lig^?oMA2hhveYjWyKU}?~ zSGIYo{%at~23yAxcK zY10>Ya0@zSSp=QG`ebf0FYrJAdb(gUqGY>cKGd07R3g3|oE7PYN!*+2bZd8c;lVB0CfL62wkM`P99-;YAj6mu|0ar!{;X)Z_b9w-zWVeX05v_EgSz zOV*jwZ_7(nR;Ai#NSTSkSiQOg%j&?4>g-z$RqfRUcOO(GRo@dUOKNWjXsnL7TV2K7 z{#mpRn7? zKyjQRSB8a33EKT-s52WVG2FY}jtzwy2vQumCQwMlDyor!dF;7ecOYi%M;4?cA^e?r zcMd$)d~$_j3ozh;-c(-?UU!CHZe=X8qimmD%#5Lf0115SgIa@^vf6`j4xr$i*|+Mt zf}{sh+;xSPjS3xAe=RzWZ?;WLNz91jn_Ujjoi$q^*($0Pu2XaMC`JX~t+ zvxi#Z#r9|S>5@K@eD6EyscXL#+-aF}xSoip+ZZB>;??aTn6RC zuHG&LOS5h6I?9FlJ1dUi9|%>x<_s8DbgR#KET4;-UBpp$O6 zdfgY@b7pYJ_Hof-bW3e}!+}5KTPov>pmtsHR>zUoce|<=-i}=T{Y*x$_nUr0I9bUH zIs%7op&&3!6WlGcR1|wWFb9rTAf_EJO&Y6; zdVp4Geb5@I$U9M`)RHN4C4$f=bg6DBy31y*tAiZYc+N0?5h$>p{Qi7{%S zRMVNUD2cAK)+5uGtC#+Evwcou+DEo5W_K-AyORsMxBiYTUktR=f4x1^9pW{-E-_Kv z+xo+6!bkNvGhXa|M~xm!q2>v7X;5arD? z!2tMzr4aVa8>%*&#~_hl$#0>&Ro(7St6(H}@}xVwbgUR)54>TNn#%JK6t6s+<56CM zJ%fw}yN_52Nrazzb!>A#ZZy6*wIW(xTiJiZ1{qG7Bo99 z+xE_ar~Q}SIQe(`-S)OP%($X*W3Y{_MPgg?rS`{3&mIhgJsBEtR$Ykum&`y=pU3^l4_@AZ#mj z;zvl?_a|RJ&3^eT*|GYh6)gVpgRgD-nbhWk<&6hlb|lw-Kl{4lRn%FtzHnuo|J_$> z>$%^q8@T7)Qa3k{h8w}k1##{b$J7or8VDTHB)SHPxZf_n#;oHJsEPL8bQJtA({KyiVt)R%%ek;sC-9v1b<382+kXGHqwTiy+3jODx83TtJ@^aB zXKNe>H%>g?_O<<>Xfy zT}}t!O**`M;yaILM9;KO&2+^+>4b4FH+)^@(;rJ#`F-!|Z`Fh2zXKZrk5??J{^Zn& zS2NY0h@SS?fx}5u%9is887a86ACt!Y+}H9xj-o6l(KK4&v$<^8xYBy{N6OLV3+i?C zmcQc!8~+CZle|mcOZ$|~%lMWr$of^3!FoJcRHLe%o<1PH`x+YEX?*>a z94vWwsFUg+8^@D7T97k4aW8j5K>f9vm+L$67q^(6Zwe)*LO%<+X9-Sv6cUA}nmuME zwBJ5jJQ&roqkEzC=g-#8PO%suJ5!-%CE=qPD`9mw#^eq$@_V|6>2$qY@0pVQ?ycc! zUA}915{x0qzy?EHe)E~fUv{}FV9RdC`VueFRZnk{2eXu))*6+uL6D!zq30IBaDpMZ z^mPL$<*#{o1n~Fres;4WtmW_g9v-`23h(FD=%=Fdb>@fG-w=Z{Z?4WiWgKo=mj1Of z%PB5?Y9JQ%{pY*HeIvV1ch5h?=#p4S(WsKMvbWFSVtDm-NY?}`bfx2j+@qx9MFLx- zuS;Cqkxr0K)Rnm*R}dwWsC1`ACP_fpZZZTxsE;9;Vq|1fVkakM@VfKTvdJri(dXtw z0$Vm?$%z4ETII|o1oPT4`@;`S9{|&%2uyJqB$HV|BJ(s-;S9b_HlK?9XvGOLnK?F+ zZ!4CDItrNsNJ0I2K$4rDc2WkZ#a0kUil@!UhnViC6g(m5bac)l_pN>Jj;zmMt!%9J&rS_CR@9opZIhR0)eOav% z2!5+w23UIZ$Xr6n2r3&aiD!(iNKckIQDMvkccqqOfWn4d_G7XTj1S)pk!FL4YQ#nD zk`=_ue;nW_oxuRh&Xx@+D2Y->X=vbZi2_nf*p7qvJnajwYLuZF!US>KG~~>kdWHN5 z7pmW5wBSRkl4uhwv5>WMI`mvbm1Qh~kIrVpi(it&B{ib!Z6lFXHh|h+%VbB0R!nh} z@9`wEhxSvBln-6QQ@`!SXum<^Raks;e$=Xx_L%3F2p|vH&214GKK&E+LTK&d7ROm- zxV}qDccQ-gmAfa$&+7d88aE*v0PFFtBI@(3WDj+MP|Nx;<{7rNc&9mI zI69gE@WzC*KXUU001NQ^6U&7NUUC{$nNXs_f@NN2LSV@GPnCQvclAP5`6r=`kCRGv zY}mEki(s+`Qb_0=0Qb0U9 z0kn-0t1SXCh~c~ZQ+zl}gJSoRd)p>Su-{#&yMfLu6jUa6=mHxacyLcDmk9EXLPZXv zZ9crKkytwyOHE1}*LPmmY)9~eg~_9%`cUxCM91PRXevhy23P~HHyv_H- zzKj7uadYmJ!551<*IB%aDHm}8+5fDnz&tkSd`Hs(gmxRdk&88eSY8J5R$g0Y_?(`> zf*^*g9(pf#(@r3Y{eDy{mcrM0Y`-fA5=PZvT3B#ZQ3&Re(7+80?)^?Z2FeW(msS;+ zLZdE6X@ju6%$axPdWpdp1<^c|Mhu{0xz^PrI_6-SI44^x|C=b+k%$AdiHD2bTBDDl z)Q94U1{K%#O2?l7P29T#sxr<4@~_ zw*(0SZL3rmWe(A|q%M>>MCH#D;yUT31kWwx_=cU5S6MZJV*{usTt@dkWfSFI<8@^B zPA)&sH+VVsbe2zL3|DkwX%sZa1N&&XEB=qBsMCZ-a0$Wb>g?d+;aS9u?}AsRzv(4v zKq$VsLs(a!Qu*`skBmhCzL!MhTI3=Pwju6CdtV`4C|MQ9T9BsMCHS!I4rmw@yHN#; zV!^q<3-|Vs8NiS3<%niyQ1}xaU z^p+$8TG06cn-{0THOxPuU;jjJOnve&(e~SLWj<}SDWSBwv(S2hEQvpkmtmJdhIB-J992Ap7IFw)dR*KZamk zE8I_|395*Ezkk;2El>13t0dy?149zq#$X+Q>w;kJp(6>i)jz=;4Nb*T_D*oN!fph$(x@%e_nei1 z%uoAlOHBy5rF;ag2wj-;eu;FH9&FOnb^dK-VxyP#QdT0)`gds(-NWC zQglHtmc2M(U_)|hN|0q;J0z`ZQ6naqb5FUr($=3aW`-=NQbgI!J?fzmPzI*(+iym6 z57g}b!>%!05DMsa~RXls*E`ZOYkagqh*vD>`jd5{i z{k~BpUy4utJxz*;a92mq_Zlj5E|Lt>g<#xW&K+&XK`eXalZc{~757r)C%OcMhfaO_ zS?AonyTE^p>xaTWxTvfV(H>Vih0gU$cCC7Le3Kt=6*^QWCVSe_Bw?8s{62e2O^6L; zeAi#w>%_90Rldu(v%xR57H_^i`SE1;$a?JlKko&f?(`lt*v$MYy+twXe9tf-H8u`S zKkW(8#UZxR2_-jouH$^;;lk%ygv{oS{bBY0lc>GQwXAZL+EmYbZ&WxdG45QQ(843B z-C-B)mHp6tN4UvfUgIBN%p8t~ljhOT?8?7r+?eLo(_w`pLF~y}MxIDXU8o$#9)@P` zoh|`WZ9e}!frcX{H@<2dPHmTd|YoK(ZPv(b06LJq#f)LW$}l zg#8i(jdjOd(EvPk;Rib657JpV+b2-ODbH*wjB2+kelsRP43V6Oir$@o&z&evOA#MU zlf=K5CQ8fro!}SG{6)h`_oox^2^0j4={ncFBLw^*$V~x&DjSuT$?qC(&|fU|rTfZ# zXeznTCOLo4HJsamI>j*dTw(FAJJ|;V#o^otoF|r;*3Lx}mH?`~@m<$4@g1015<-)i z@&t73Jq>BVfG4^>{<`)m8|F@w9wz{R*-?)YVn+jEG_b-30g;@89fXtL{_R1)oUCR#ZoCSgftOeaKDOZ}AtUR7Au{K{hrO}9 z)nIr9iV3{L4R%_wUx=hh&X##`8Sw`IaP~W%#a;KkRuG!O7X6F|d2IRrWC+%06}htg zvO3jU1-+e2KP_4clCqh}J(yIMi5=^=ZrLkUt2#A+_Xxyc2@dre!-z>%UwHf$6J=2j zQPxy!Vej!Ck2E`&+_j#EM4MpO&gW}Feq1NL>5J|LtWwHoa*(Oo`k+CuBWZ+y)8}A` zY(JMCs?#Dm*+*n)!hkOYevMwZ!N!c1yIGs7fpW1^GdXqW2dKK+H~>Tj9$V-NnL@5Qi_Z{V~<~R)#AKX3_!0-7^Z>9 zk^-UO4hKzkq5~p+W`I zq3H+*(Y(AjUqmWGFjw}+{%TK6>Sc$?lUpYi{oXoa=1jxm)VNrDK`0QI7bF~uIi7)T zdBtn6l3~b_^x8F1T9hIGb5Z2$mXY48wZsS|*r7YBd~GE8w~PhGz$>BzBpFc_0maW> z$E^X^P_$7eNC<&%C}Q1mpWf$0CL-*D*Mt}%v~-kB3QQmbWxpT}2wMSY;0~-}9Bn*E zu^BVlsiN6C!@K8GPE+~o3Erw{p&7*df;<21Q3cFj6|AB(J&!-^;URUHhqysb!m>af zHIj={)N5QkZP|GdQZ^7a(3HOKv*3QM=hB}Av@x~y51i?x9AikkX)-Q&Nb#B>>ysxE zL#*(?-TY)i6B7aJ*y%!kDTorsdx5b*8-2v?(P1hWeBAD@M7Ng`5Gfrm3B43u9IRH} zeX_>wgoR=@p}4xlj?oo5Gcw)1-%j^cr2l4RRn1N zaGoSr;t55n`26QSKHhp`4?&iM}K(|F`m`(r5D9}bWaEN?1A9g z1S)#s7Ldf&wUF?XE#uheh{E?M%3BEVr5>mzGN;$f5hYX6#=ucEHd|vrK1*IWfccWl z8H?XX`^m=-#0xWkpIM!J6*2p7hgTV2;;&_f2$B%BWSRfCq0Utlp;lTK&zi-7Y= zL3k_kqy62*(ddw1bh08Xxb(Vj%58jop7JYbcrGk0zwyw>^{K+jt^%ueMcQhpjPIK9 zRq_GEHF%XRBcW3#~4`+B6s0|F}$6 z)V%&IC)Uq<^3W3V=S@C65LC@SwEptbk!HHtJ8WvWv5B{Zc!OCk9CoeQ{Hc~zU$fP7 zE$fM9>vvi<%UY`=2x7>Vak>ZQoZQ&1yXhnzX{_a=i%D`8bFV{iaf%S+|d&JRj~h7;^_47RTX4nZb_st?~lU{p*eNT}UYR&S?T??-}kx1X@I4zQ>Gg+`2IjdbOx8 z{VJf92ff^$-}BZa1B_S@_L_-q*QrbtC7B}~1&^G4fy{ep38G*|9+7^4!X3gKZ*o!c zD{x&f>YTK1n&gN6JLUgC4^@b-t%`XI9(uJ~#ysh@gVBS_>_1sl^p1%roGk|p;1wM^ zWk47-?urZ6amvqp#Z_p&O|*8m656TuiR<}y{wBz-dux9}>YQ5Xz~tW_;ct1*=~&@Q zAjXG@Svf5oE=7j8g_3aZ_=Rs-s_w)21D{5%u;GhHx}%-^asKreXPzk|x3;@KO688) zoj{YiA4a5=n&0;8lCyF=5+5Y`uZ>YlMgNAwWym>#M-y~0_$_tKp?&RpR8;7D1Bn}efK#O8&BLs8xG{A& z+M?kh-^UNfmGsG}v0vYH@N>Ve`L%VS>hWR!ZD%D_YLF!R^baA$NgMtaKj9JrYHAL@ zdLzvGzZW=e-ajLC`ZsnN7QUyC@z{x~aJ_7r&#{({P1iRSTG(efKr!IM%2j9Q`{u~( z5@va1UjzUTx0=r2*l^~qcO93R(W9WCMis(b&bGdK{q@-TQ=Fgbk9yU+EV(#hz3jW5 zRF*@u0uO}sOg-tz*Y3&&EBGS&*of%=W_Y0ekN-xT%*wPpTN?#|h0Y{tMU~M?-N$tP zn&-Xot+{W3Cfro)g;l~z^02hR8AkM&X^gypH}2)%HNGoSGys=yl7<1TE0%J>OL>*GzPHq z#O(6$WB`5dvW39Foj&U?Cr|a>lhxLu?W(c!ytz>wd5^j)UfBkeug||Wim69)(ia28 zvVKuX$`cbq!02wWOga0ooZe>Qy%XXPw-XzHs2f6Eo0kDlFa;->!PE2a#dBvYSXsxpPB^~DA|pt>8Bj`K5klGV-r zN1xl{^tuS4jV>WjLXuKgjDjG)0>7Y=Zk&lELV{ONL|>7cmm8u9L0rN(FWfmyAW+6l z#CzS57r`akAcckRp@X=sb#sCMA}^-5KTO_~2Yp_NOUGePV=SAh#V?_iGp2yV3t~jL zO`v=X9IGHMq`=)~w8|%HDuuY9`)?P3W5wz9C7f6;L1oYWby6HW*&u42F~@2XjG^w1 z#LgKH#$hCh(d#HaDCV?)g6E!NmQzl{r~(`HG&0st&cL|kn|DkaKNp`9F)Rjpu8q{; zaUFair7fwfoItIbOCcz`5RF~ayi*+!7`?s7%k~+(40ucbNxIGl9*z?CT=MlNlq3UK zUI7ztf^5hUxs)usMPk)MgfM-NyN9eyqAeEDv%Q1GT^ltONpfezpP-Oft@;~XT@(Si z8zjfQUOOmu&~~l0RwM$&Ed#yH+xmP><0w&Cl}a)y(1^sYx!H_bbRXf>-7ycLKW^Lp zG}&}!-ws8_14-=IM-=l~>A9J#AJql1*aCd#aV>pm#`Av?zgm;ksJo<&>pTJp48R)^ zKr*+(;{YzuX_WPX^q^Hk0L7Q)FnAW2i*Ob~=u`Tm2$as_eAp+IBQPwX9umXnJLMBp z%GSXPXiqAYwVA91+Ow!N6UL(Ac1YRerIKNC%EP>U#wIL>1miM|5?jAYqAmiYQciWM zsA=Ph;JzA~##vju51)%R;4-H*22>XcqRkYU@It+4Src?NU3yvqS@_!eEIDLB4B!a| zs3`u562)X^bFH!fj8*-D^^TQO7XNH;_5VS@r)e`UN~my&tiS~%F3Wut#>cbHx`TWg0XN^YfMr$CDwW6c15dt049Nzn)c6SiO@H9p7 z6|V-4((GTIYqyy6v{kh5q!SByK&yn8U;rkSaS1^=od`oYc0clVIDQuVN=)-9tk|X} zNGs`**qREi)WEcwBWZqgzVH$%62qv6cm(^E1PEABUv{_@r3B(ZT}%VeqT#CiiKu&9 zsTX@r0|=z>oDzYzTF4QL!Z-mOgb<%^Fudf&@}~#Wd65|cjvcaOT%1k`6)l46GlO?m zKnTJwb>$v{@D|zn5sBvB2;bM=`i9O+U+=FDp9uajgE>Md3gX|IISBIY%$x@aT>q%B zWvi_dup7>#AG2!a>#=zvyYwgV#@VOVKvcFR8>;k=COM18yt8W|^E=@@^^)!8*wIqg zTno;7z3@K$KI}wDJVIy^k+X=TI8c#P%1$eq3-JZYyXmKz&d*2J7U7WVW!?|G)ksUQs*ssc)G;|3n4Z>bBaOMxPj}x)_wFWBhjno_ zfqj`{-Y00({5}P}%JiIu3S|+^0a{z%&HCgesi_}kH=@(H%KYGdfu}9qXB%_$)R*=>=sZKJT(A1=O`d?MfJys@yoB)?t$K4Y2s7lVE#<5KVFd|E*w za7bz6p;pP#s%hwIf5T?egRy2+52Ih>)AOVUbZ6@-QmjRah0vq>5kX8TZnrU_%cBXx z9kt!1M5lX(_aC%B3<@G~J1&|o$~b?n)hsXYsQr?iFXa`?%)T&Ft4Fwt`MfIMrEKld7SY<6S<+AFL~Vct039Iw+l{Q#$#%Xn%hsQ% zQc1@&O*M-AMwm(JgYU=vTJp{Z-#@SR)@J46_r~16)rV3(@uPApYVS**b(8*Nd+xhV zF-k(i; zhnx#{$CASSYjR#X?Ja-lZNCgp>@52w`hYFlu ze3*Q#c|54(MMcgH zOrhVdezsA?x3t#_=)XT4`Mo_gwzw5abNBK$JJ#M*b?>3akeg$!_Vm;ho!Ft^kbnnS zb=iDs#<|uX|IFq;X+o+OpHeyPy}z--hIhx-x79kta66|1;m>($z8?JDp)|%oxAW@9 zp-+j8IWC{SboE5dvYxBY4WCq0S5Y?&-rQX5i*97^GuXT-7CrwWS^i1g5syFLr{1k} zt387^jAxWtWuywjYIob8x;$?ipQ3Nq aoIR2nQ6c;4sm=Gsq0w4@Ge0f>IQ%~YCh5xn literal 0 HcmV?d00001 diff --git a/docs/assets/squash.gif b/docs/assets/squash.gif new file mode 100644 index 0000000000000000000000000000000000000000..ddc9b03258dcd7c154cb257696ed2375356c93c5 GIT binary patch literal 407573 zcmd41XH-+&w?4X)5C}a$=p8AcN^gROCSB>hOK*bGYv>@ocL=>Bpmdbpd#@@*ihv4A zQ_)=C_nh;)cZ~D@bnlltMn3GUtUc#kbF7s)pJzSFst+ZltRI03Ko9u&@Q8|$F|~0Gj|xgk^@&SpjRPveppSH!6~uW1 zUC7dW2_3AcC)KGM9@2ZsvRg=VrO7k*sM18CSkp^N{+egS5~0#Ba0u_&uPWO1{0v3mya{hbf|6Dk0~ z!n(Hp2u`Thy^%bb?#5anlWd)e9BUh0Pu_a)&3d_g@N4`U^cCBO@lfHTOCBqKHts52 zJXh_d)?o5-t=>l+;ny1u_-Y8`&nll8Cb)NhIO|aPY&ZG!riV$B^eYw}7Oo!b6A=>| z_u_6;d{S~sDs5s~W>$85MoxY~p+{a(Nokorro5`U#@s8qw((_?Xl3*3*0wvZ+Pk`Y z$UEQk^|$v943AU~jZRGF;LXh9O)V@gEw8Mut#52@y?g&*duMm=hzuiM{$umFUBL8S@P9|5D_F&k?t8H|D7Q7cesE**}i5pmucYsQw1 zCbKBKU{Gx-A5TZ>)|!pCR7_?IKA9*`eN{P~FZujzc(XtunPjwbt6@I-^(4?_Pmmhl35JD!6j;5W#SPEW?sgCBo(Ud#tMH-zg`;$2$F7Ky0Umeb36yljQyIy}< ztk$iwnC@ylS#5eUS)|$B_Ib18`N{j~?)I-A`XUIKAN6#c?~SGNTF&%zem|TmQ!jq> zrt9)#?Ul=inK#|nU$+P2nIFIH`SJZ|vCeY#?VDfMXM2;yk9*(#{&n;H0`RqL+rdg{Zv`tbNs0a6U}p6U7qE9 zTvJ_Hdt6)Jc6?mdG{SRI|9Zvwq@m-W_N1}r>iFbkA1?1{(-4)*Y4aFo-D%6+De;rj zS91?}KfhiwaQWQ2W?%QYZOiB6bNhBQ@0X5`SuS5Xk1FfFbe*=HG-l)fd(NQ7036`M z1#kgp5E%eKu`Db}Y_|XF)Fj^t$7rEgQiJ~I)F25YiKsxhQ3G0O0lm_YoozliSWsRr znniTlky&0S1iG|M-x|Qr2Qvc*6$n8D`nW_vF)mOD0i1voLI~r;NhfuON0X2cc;ez0 zPvxD#eYSd3Wt$tw`Kq}~w zqGZ(qU4SDbQGK9XFa?>UQN%bp>;dN&f6)UXD+XLrVQeWYdK>*v!i@;gLCpnfp$GAB znA(vnzjos(*vDj@ZWERW=e+6QjtHA~CGPOf7bdeSOURStP_##ejXJ<+1UWD)tT@KT z;tg|LksngkqqGofvAci@|D>zhJ!>np9*o7!!o&b=PEgK8mttCC^|ArimZUr~XaFur z-7t14KL}Lw;Wfc|{Cqw0S+#O3WcyYp`oT}csU-n>!pxTWm%by~MAnxP>s+t5uh#nH zAPA(nD9q@dL?$Qe zP$_H2EG!Fa`>iqvqV!a|s)+pek+k^i8qhwpP+^e5yx;***?t_2(*Ro6-GgTpg0JNR zw&`SJ&v>5pv)oQQj}Z^_%H%$Z1k0HAEiGJF!Fk{~Fk31w9o$RDej>KtAeTzj`FTJy z?c)m$H-FDc!9xUY%|1r6UaeZi*Z_J+@eskv5eo13#+9*E&gD>@36V$!i1Vwrt0Hh9FqI&o<^m^BQX8P1qhn zeC3h)i~yab(URH%NieI9gwW+0qKsW@dJZY*!qz-cIB$IKC9M?~Xu$p)Wx%{HS`%>4 z>G70cs>CYxEr)ZtfXZ~^_7)@T!R} zxXTkxRi19~PVBP+-1AozegO>C?vX*}akop)E2-Zm24u33A}YtJ-%y(RYsd%pT8IR^ zE|Pjqvq$l6%9iE2I!1A30oDZ@q`x1xy%KOy#=@%P(z%yae&3UCOorR9OJqKmX7xoc zMv$C-z}OpQ*I1ybNbZ6{@lQ45M#K^@h}2|n$z;PI2tI4)GiDwVmVs;ZKum!o@h7ug z#SE5IY8xTTZ8}ac7LUjp0I8rTr?j9uIHSDyaJ)0&FjAgBY$WG%aceTotn(?HlvMnQ8cb9m{Ha8Y|*vek%>>vaGZPgaH;6V z>`-BERjYH^fWqSmLXN6!E=nfH3r|P7hJZY!=+&*8UxqU{ORu(=eqI!xNCaHwKCxJ| z0+5Q2$v+5ITPl6aQY8&VYiCa^RZ3shh@)!BFlx)SCYQA`nz05DIQXBI4odd7%J4aW zaQ{}EzoiDp3_ix4-;tx`e7gh#fJJN3n6p>X~+{?9V_eEwtn@n7R99XIn%|E?yj zghuBuOi8!A>PuW0%yMy6B)%HHo)Z9*Cb`!`}d00!_y{>Pf;|DFG+QbFBAC)`wI#kHo* ziL;JbCzyB*o!ei(Nrb#d7WELnjI4t=4Nw?ph8Y!QhmpXKt~^Xh{B&Gcu;Wxi7=##(X52i^f>MUW_VxJiRPWN%*p0TKFXGbxy%(2`C z5Js?HKgS^)w4tbioduPr5w@|+wN#>wa7cnxd0s%>EUKoGBZ8P>|Ep(#|Fe9(=2&ijt2yN6mHZVLn6+G5EF}<*8JNtHiX?;Uy1-!Poy`%8%!|uVM z_{XD@Q{m&!XXkgme!IM)y}17Q^T)40=fD5pD^h!4f93rjsP!-20s((v8W^ky`gdOe0P{atq5`DA*8kl??mGOh7V^J=SDMQ3{{win zB;6_Eu=3XAGdH(#N!RML-pI+HVdTq*`~0qvlq;KrXt24RlaAAf;~v>FE^V#$=^)A>g&qd{nh{6y#(t;pEIUwHeIC1!k#lxKp2% zQnqpClf7Whci^N*80XCH9DOy<*PDdLITSPbs%tX4oDcL7xjw;7_p)AOXr!@;vn9#3 zq=4&y((?m+RbV@ZWjm}756VP?fNz&r_lGgz(A>bcUyn&M?x)Lpf#aq zi6a-`=O|+!m(Kd!F)ix2>qM8!HJVcq7P+g7cA&WOR8lDv$FeY3_%#UIHQ=XdeidnSpW@-KTUZhsC3J z#pp5fSz$8tIM3!;qPEbiU|~uI045le~`<$ z3Y7xWcYHk+sD!=UIN~3Am)KCVB~ICcQCnVVcLb5$-9;IEDk%lPME*x>pta`A5)cSB3z{mYYLf?k1j|zyX*2%9eem z^obdi%nQoDgd)HtIzt2aT3*}%U>JOp5J%F3oQS~_k?zl!i=eNCzJ{X$R;SC0=^lHc zS(#f$lrotjJ^T6}2iP_tBB6hzF~u^BY^RSAf6BwckPP~IQXr%eD>mBA8wyxGg#zff zr_C*;abiB4lb}PhPnwp0h}*|$%4J&EcpQfP0HYjjOc+!onx4ADK6>$vL>;Dt$`%aC z3NI}crq5^#sE zCvbg{UZnN+cpk&IHHB2`GXsj%Cl^bZHvRWQKskJKD87V;vnkzU#yGMmXh^D09twPi zbvq19B8jO{q{N?dCW7(7wZRUr1a&p}D!3NvpY@(;*Yl7G&J#TVIev~H#w`Ukal#yV z^#U|f!gRP8_9fa#j=yiqzBN%_gW?D~NHOQcsFLBl_wgCR_WTG*F-_{Eq`>SKA z3{(Z*wQ8LUlozH7TF`e8>Y}vk9pygW^Hg}UPTSk~p!1m|S2AmlBwwNu$=Lv+q4JY` z0a?iIUng%2^t9mA7FkUdP4gO5eKQXKkk)s!mU0I>SJEg|N4TDLi>OwLL`wYL1%Kkx z?a&^orfn@T&e&q307xD~l>)XI%dUGCYEa>aggKH0))kx8+>V+i>xpXObU$h1IHqz^ z&DrFHw^PDx2D?Xc&wNKi8Rhv{Ar;?%b;{N=cP%fIFe#O+GB*CRpw-w&Kb;XUaNmK` z*c5GVRN979t$9D6$COwb)gOZ(0j<;a`RVKqkoKuDvpLb%>XfF^SE6MnZz196HHv4o zR!DAOsk9VT0)DJ4SWwd;%fe7@mq-<%UawDcX3^ye(vv3YZCnxCxs zu^U3&RQkezq?z5W2e{vqyS6J*4d5!UXIML4ym%I80##Bqt;ec4+?e#!(w@hzgK5av z{e&LRH%L#NtoF`_sg1ss`Z_I-SosSdQ_>=1e|lOUk7*adyfZNeEn#ggVc&|Lr_@)R zMM0{qz|xY1k4u2OmF5y~3lGoB(muT_p?T(I^R6Q zrnVn@>biB$O<$5kV-lyYrGVKRF@^ z`(D+AI8&J*B=>ObZ*WBNJz-*86F7L5zt0d&On!@G>o|LJNcK5I>{Zn8N)z2`frj4% zWd9az-~99@PN?ysTo^&|wi^c(jJ3*{e?#+RGfrxtLx8toMum-5s_Cma%Pv3fqc+;h z76W0gQjG%SfVuBci-)3u2I^E^KN6|vVyb{%wJ}12h_(SLCzL_|9SB$p4|MM~HfUzh zpl3M}#9lB9vH%;>4d}BAm1`>K_SKhZIlkBKx3$e%%>}Ic7h1$at531>y9cS-F9$)O{&{z9<(VI4;OAjOJntPszlJACx|)kB2Q=JAsU3@V?^Y{N}0c= zq}MW2tfZMYRQ~jLF#{R+OF2!Of2!_H-&T2)n zg>+uO`P!e|a2svn-z@PKD|^O=EAs);?%Vq#*dw&P`~jV@+z2X(?8!bEw3DN;lVN%lQ131EFGnti&^dAWf=- zBu&c{2rE$0C}@pS2S{i_v6Bkck!w7L;KWYg5c~DLr_uUF0q@1bGeRBvY5`zA)Rm^y zEGc*nZR~`_W0UI#y@yz@=070f?&KllsYu;C{DX_{2Y4~{ZBuUj0dV~ukTMAdQ?c|p z<9P%UHc4PtGr^>N0n#i%4^n`&QUBvnh=ebgs0l#*t%-AhSijg|dzc3WA8;s=bc2CX=76ZTob zJj96S#EJaGaHv8rM#)D$0>6F1NY60hl<02`n$P@PiEi}Gs{uEp0UweXl1eUU1mLCw zkzyhdB|x%KEcFclX#%2*qE<(lBUM6|g~;yvF=qP3@k>$|jFCG%3C0_XB85j_PlRHH z$-?gri66{IMLE7uTjNPY#u1{?ZN6f`Hz10mD2j}rNM!K#P*|$N-_U^;qj1R_KqxE} zVg*PTS_PB=xjJNAps1mx;JQ&P6=0#7{6rg|rwVc&b_skET!$AoH3l$i2jZo~ z*C~_bu|{q-1sn-Q{PaUhNQCs-Cw&KLa7^dwaBCDnK2_6ebzoLY>OopSppR$8!XyRZC1nKTqsnoM>|;ZX)JLOO?#c1>P8%IV`TEDk07xi9#`H7MW~g~s=mhq01aseL zzKu;Iu*%?;BV!OU&|8b@^GS2&jx?JKr0`6x&vtRs4URMlW#Bes$x7!1G}}kT3Aq!d z9l&?jGYI3XR!hQ=m|U9Jm_pjTWmd-YT!7pj=y1|xYZl}3NP9UJxL`=OTEc`k4}eBB zB{RtRjS`H@bNj2oVxD0l))XMC3QS!6${ z;ZDGCm(BZSP-H|Os3d7f2Fw2lg6HLT{3yHkGrz^Kn0hR>CO3yZ4=mpyk*HEKyzO}rg7?V(v&5cVVvXmE9aiN;j z&Ly!lUM`LkC39RH=?nCX6bX5z(eYG&_Qk+|@_f(Dqf>)~jX}9VnwwhO4FlE9pxj)W z6!D8N_MI9erhu3ryhm8N4c2Wzq7dua)uJ?(hSOGlsZ2hT*LaMKbBL%H4l5k- zEf@EL8=DkH4OZ0B(m&p)`$R{N#Y(*9GRb6+3p9I+$;E_e)?u5X8z92cPP7VO?9f4u_WL9@Ve@nx)B@@pj zNMVikNHf8Nm~|ewbChde)yWQ^-O(v>-X7^E-ZdWHVABQ5Z_prYc-;;- z#zu^rzDSddsipPo@$0!5w^HTFV)>DrZJKNKJXoP4K7iNI=|(@PgR#~q_9?vr0kS=? ziuPxd#Es$epXkuLl(NnJz1UqYrmK5_bk@DA`ZdNGy-`mRaS8jLWrA<6U4C#;tVs4D z7-UzB&@Hcdq`CW7kRi-L{UD-#N^%>!rRRmMw1W8@#_*!&x16y0z92Us#`|2 zZbLZcTb{{`MVgI82aUxRjJ@a?OV}Dqx*bbl7*CTK&oCR$y2XLo_tfx&s88Z#Ia=XT zm5FJU2*$?V^T>(IFT>I_k2^1SSa8JEpBj*$BksdSc~ITrN2zsNVELPN<=S>v_>1@W z6C~Rs+?Z~@`AP2ac)!7L$$;^>f>Cl%$(R%qMDV3FnrTE?p_u`)(Gg(y0&>titr=I; z=oHE(H*%61^iCsiw4I3sKdL4sOT#!=RJ7!FzG2yL>Jvd)&3Z%4k4#a~uy+OE&a8?p zsjwE`9*W#D3|G-S!@N=x9KQZ?P%Tzv7VhxEE)PA;j9w{GFbEAN2v z<@$w9|JQ6Z!PtGdDmmVHBVrsxVh}Fb+Zukp0Y4<_nKjnaDXAVKld z{hlFJ?yzn!ZhjM**wu?JZJ{4szuwT7zg#{IF*Of&b|kMc#t2G9(2T4E|D1Ouu4RVj z+~$RrhFialTfdTwqKLJYr==((UUYcgO1K%S>!hi06B;oMG=s<^Kis@LbT>Hch za>R4}*K^z=&T1018kSC^djm@p(o87caTOK5i(kzqf07vHWxVlC^KZw2%hr{-13&xeZ!q>dnd3Ph=E`~paH@|%y^JH$ zZLs{D|GhJGKH%I%I6qWQbID7(ewCX!0>41q;!p@R|Fo0(r?ead9dWxMK0-}lGmfiz z3~@?iq~IjuIQsCz@gp^iN!o>JlO?0)Uht}2Oyr*d^&+L)F(|plr~aONIgK_hxs<)b zMXbv>u8(H)wp^eP;@P?}P+amZDsiYzrTE2XFfMO%{d>=)&vkpFsgIpkeJoA|o8SZ8 zky=M_%H#1{Kf*7RkH8))zK=HZOiP$pV_JC%b&H~_k7B!x<0m%31P1%ezhb_}@9O`l zqHOG_$qQ%V?Wr^if8oYdB`K%RBL z1-|+2eo9_^AjKemuZO4is+Y32Z_SK^H*Zk!Hj}jnOm?bIR$p|d7tAEn&o#@ybjxjN z+?UTV!10)yh3A_z-_XhRH^SQ?-r`q`PZb_B^wVQA1+gv^#TRP57aAWf9$_!Em@lz9 z4=(jAFAYL3jfyW#dN0jBTv}i+t(dQD9$eX4UfG9UITl|z_g=YvxN^r{c`#o;dvNV# zdF>N=?N@vq(0d*9;W`9+jb^?He{d6Nc@rIa6I*=qqW31^?xzIoO_Xv?`9zJkapWI? z9)hu;uL+EQ-hL7|i~SQKPRY8NV)@f*G}yc$Xi&Od1g9if7JiR^fuKKDjD<(EJn0$b zMP=_Vy%ZNc-dqH4vF}m+maLq6!ZD;7z)S=$I!^XrZ_Pb&h&#i@B7X{Q*K6m*Bl}$L zqRBYP3_*US)wPM=34b48abPUK+F{0{+8Ms>dKT4boxod)39;+B)luZw;a?rbgP1VI z-=IMEz((FCnr2EOrd3h&ZPel_^x((sELIJ(;N`w=_$HHgVmrJRiyjYPH&@u``1;RJ zf-6_+;>O*zO(D;ElbCL};&OlP{4?Qm)1}d$zZ2x)xBa1|1U^4%@WhQmX0>6XTVKIr zFZ1am<3umg4kxg;$>Z4k+|Rzhv^qoD@1BS&Dp)A}5G4anoVMkUeEBT)ubuyV z=7WCxY5eu~Ys2c_wu$?48haZ$_V=Ms=fmH@n63XegO>jv-~T_p-X8kv=ij)$7PI~j z6;J&S=WY)qf;|6+bGQ3HaPD`>@&6-s`Wbnr>c7QK_$CVhz1vNGt{=f;N3R2>+wwNg zI)WZKyg9k)39)?h{U5QDSV%;4Ol(k8+~4~M+kfvPG*dHka`U9J^NWfx$ikBHiuAI| zn%ao!y2h8@W=*gDik;d!9H~I+-Q47OcPXge3R95B;f;xm+E7u54-RvJ$a|KC<~jw* zHaN+t1St??lye{iB^jch8~&b?a*X_#a)*mzAF;}_w?BzM4q@6!EZzq4jwu-#v7&pu zSLgT@JHj!Ygnv%Yf32K_OGk5(lrQ@A%R?z~*0CiiaO+$}|E011k1(N!lZ0%Olw}U2 z!;u7(S0ykHAto(HI;yM)hZ0jl{Es+AO|Zv0`ssJUhz!830H=uOeFgf*<(ejikvR_I ziV&s)HoI_sM_Fh%vikwl1aszH80Te~-5@C`ryxiil)q9jI?43D_rNGxJy4$a119rR zsoOe&&MIq+WT9?y*IxQn$xitFR4=Hun<6wnDB-dBawcbmMM{wC9XjIU$hS*YHid7B zKs(@ly9Mr%ZNhoG7fds){`6K+ zI^nWv<$Jy9@b7oW-g+QUq}9Q^?O}t1IaZZ03&&FC`T}RwGJs#wT=iG(*`oj0h1WI| z`!0qNXWq?^E>^8yk(K~42<`~W>45x%5;d(J$H5C%>aOlL)HsL{X0aTA3!4gL${SLn-@h(N!}}Jf>B5kTXR1x( zQXWru(e*!IGxV`_97*Gyf!aO!4blc_Av*OQ>$ov1c+yXyP(F%&N zyqC?&Y~}ex$su0yQywWu*g6xO)=TKA5##|~PrNPp8rjv3d*m?Npt?XfV=tQ5jaNNy z1})F_)iYlD%6d%3tSSSm2UX%*(;g+D-Ayc79z<*ArXLDtJnU3a`D{$= ztc6UMct7XHqf{WwKO8o5mPKJie*d+Tx`V;@?6Dy`QQ}=ij{r!{Ml42r)Q4D?W07lN zNLg@LO0!!fy>MTVUZ{q4I??P^@j_1(KkuD6SDn3VvYkV5s)@O1>5CG0VznZd+WZTX zi&C0tq$25GqD&Br28e(s3b<(nm-nh%UTB;N$zDl#^@GRA(1}s^$QsO&U^VkXa>1jIaCn z4Bhe*1GvAF1HawC0gD1K=o}}63W$X8wE>>`C8W+(A`mBXArrg$1EKyPlWZNaAz6zr(NBrY zD1o(sjW=IB%bb^}US>9N3Zfz4Cevz@j!9f(A3%^|yiS5{7!V74*1dst>B|DaRL-H! zC*e5XS2|qHogf>YCcl|{LA}9fGh(a7<6{IDNwr78UHCyJr}zvz6f0WxI35iix&Zv6 zd1>BFF1+LPsnDN(rb%n%RDj5y)~I+^Kj2eH!Q4nqXHK_1&!tR;^jASmdcyzL0{PC_gMZ}=_<#%35XS$A2{7XZ7zAEomJns z%Hly*7aQDxFFl*`Z;a8j&+x>`_g zQf;-cG^;Bn*biW6TK-%MlzWRGJ0^C!XOW<)Ar^cucLKI#zyDew!H7R*G({&@uH!V1 zjGOS&8(nj5wp~{qKct)7${`P^8+fNOnR?H>U`Ph)YTzb_O_SY$5H9a49g2A~H!K6_ zz>l1dL1vr%&RNcv1KGh%tE$}}vcBZ@E&=WM;_+aMI})P^!@5=cc#o_D`tn3`f05My zJz|$%0Mb58S1i!5O1$usK_N+d(YTqdJ*FYEblxs?s{VJ z4V+c(zvoOhf93-OdJ3<*xEgdEMM+tI=YJR2etpV6?O#oMvPRt=ahRgqf1<>@n!;dy zv&+fnnE%!q5)?Q*{P@PuNx-y$JfL+^?91o&uAN;b@476dFuNF+bILP`bt#V8W&R$Y znaaZI{6uk`I@;x(5xSk*hA#u@r{CwUWIxzZnkrHHV7bnGmRI%dvvrkXoWjLDo0b93 zQI|YlAl(5hqwhZ|9!d=avtX=A?akmQ~)it3H;L z;n^^AaFc$9ijk>9R4_*@hp&5W4ERH|9hTqFxULLG!lNTsw24qw za*p7aG{FXrkvHNY>K%IgD*-Z;!A042X}BH&lwLA+*2a>SF)JasEVei!QIaKYkHmu4 zC8IwXM%POQK#}_2mGnNWL>E^Bo_H}H9o8$jx+%kU5@J!Wk9?(vbxW&(QwhCvN1KRC zYxykn;J!t@H?c5Hh**VLpd z9M%jgHd2*g^{bh=er7iu`nnmtgA=KUMmckfoD6V*+$Lo6j5uz_=1b(jZ~-0cYrBNZxo6V{I4(;1>(%&-h9Qz+W}ZrJ`~aF>$&1_se_209 zv(Gf0(kO$)mN5_fjT<7RgrV~;Mdp?Abd~aLlnVST6`Uv)vr8vB083FCqt47po64GE z%lHA0%Y(AJXtU-@bFm4qYZk!GVS@EfvQsJ#X#*3E5cZChd9Pcztp_$K0hgJEc9(84 z1VO@D=90LcBrlxqUs==9X+v26&npXYlQ4}lQyf8Hxdu|o;hL`k!J|#P9WiKji07@; zwAv_VO$A6ia?&qdIGyzZoMU>uF$bdt9}I*240Z5~<5GBBIe98JFWsC~Oc#|6GydMx#j^>3g0<5UrB>g>& zM+0788Y4MEO4W#sD{Geajc$P`t=1~LZy}FqoV92G8xuE$iIBG@&u&@J#@@Qd%i0qw zF{Bl>&RNMGSMm3`?5oEeqc}}g=PIe4AyutLp4YKLcE-IGJ|@)8RhhB%3=EgS zWyn-q`eZ&-u!my=)F_^6&4$D|teHxG>HL|AWc`StPMMMp;-o<{%lxiY-lke%8yA{a z*%`EAG!>osnFm-sd!|t3Pn?}pj~|!NW+prLlI!dx^I?PQa0w5UvlpdP{rK})Bdhb9 z_>>9&TXF@MRmzlx+IH2_sMnlUuRlluvAo_;b=xPn9hk9!AuP2W4{*&62_RXm`el^%dnTM8@;tr`fULqPvx)=pE}ifLqQw%c`Bvk-Gx-Y z%8aQiA?OU8lx|Y^TW45tNJS5qRhz(>O*u`>8gF;seliJtO)b4zz=k(2wdb5~7K9fSRnwsY=O-aFcf&IF3SxbnSD{Q_5my_&*d8OV5n z-6YX;U44c;UD-;|^jg96M$h!t*7W<^>1~FYU74AWW-|vtGe-q8$2~KrTQgs7XU-UA zzsbyAn9W`V&E6Eu{_L5(-J1Q&O#zH^VA(kw^Euq$xpdS7%v^gJ+VxQ1QaUQ_eQWP$ zsNU(Q^@I|HblTD51U%$#UEMub3;0QJ#Qt%K;+W10GSfp{>PM~Q3#at9T=wvv&5Ixx zI<5TN4fTcH48*5<1^7z@WziYG3d_8mjm5LJGs{)YESl&nT%s2nSxm6T*^M7wBn!f$ z@2HM&2?EpkNlKb=-QJjiVbl5vgGF&C5|s1VflC_CawQr7ql}4I+zpiG`QXuxzb%%X zqsP>2Yp!IDpLQHXQxrr)4v{FAnEwE93xn^DSJwK%i9nD@u=tUq2fkA*1lJ!j(C%J2wy9Z2lM z6X`59uSuX6cUnL}Y-ZtBM*dFQhS)~6$M`FyFL0P4Y;5X$gDyQHe4hs_&a}T(7cOdW znyUZg+Wh7%8~r4!oM&78)XbiZuj?f=?>K1hZsXp~)SlFry+fjp@_+Uum_8~!no%tJ zc-iw&Zy>Oz@@0RM!NGF`uopP^%J5$jcmVj9 zwj25XA8khtlA@rHAgAl~pup33+sREnGJcnAkduO3go=`XnG!L@iJ+uhq+Z(wtx@$* ze|*19v5XK}?xiC8PDVLG@$Li9PjbqO^NZv2w7KbU!qAH?ef1Jnt0>FgEyN0vv+oFk z-cOa}V7`L8UAd4Cn`pGlM%@w7Sh5V2Jyhp&8g8@#SwBk@n(A)pmcc?~kN=9GrsSc> z(Y-QmuwD~7(J?ajTh4u1?qwUf%KP8@w_!Wf_bT7{+1E0=S7>+W5UW+wQodVjqHeL?#4WSbr#N<(7n!GS?kV7w zoVYkh7us>waHx$_2{Y_UT7TPFp_PakcD7@LG@$g99uT~H(Xs{WHxh##vzbC=sm;G2 z`pfopHM{GKBR~`O*vv*B>n`M0G<(7$v!j;kSCgx8{I zynh)eF+XmV-2|Z0-*Cjpb*zWxD>>wv9rZ!eo-nLSFcZv!3{3zvZ4AicjsK^4BgP`z z%HV7Madiot)YySML)s-R(Z>~122SG*o(K3q7c|W3OAZpGoGQ2XA&;-azXBhma)(r# zTApCqJiyw36!q-O&8GJrz?HZ(=RAf4JC8cJNquJv=~EvNbSm&_ z?xA4^J~>f=jcq$*xI05q=f1^-!}7}KzK5q-Jk{>cY^PcaLPisYh++b$M9*&NH-;WE zdK^TZ9#7Hw3*2q9xc|qCrY5+e((^M^rAw+NAN<7Rtp9ESEkg^oaF53!AL`*W3-2;7 z>lZ2HEy`bf2*|m|3+=U-szrBKw%}W0wzCOb{HxUPLVp~seA~E_ED@N#D$;ECFpwo& zsc@p8>ie*NICfIZGg8QZXrO1lf^;~)%$V*4XdPrjTfQx73E7hF7fJmb-lAr#aHAPI zGv(7pNf()5g|L^XIQHRvpkwWsTJ7`beB{L7%2~FtB=gvRh-hL5qx_}wc`*JP*(1MU zb0e2TMNyr`h=8i(ACL4ejyB@Z^a$y{O0l-YL*z6)c%Y!WG5v-ny zN5%O#pYOe&vxL_`E9F=fnNcUO9h&`QPh0ML)a<2o7S#&0_T2Wq3;7XQe)3n``{4~? zY=f2k80<&IIO`~5>)Ifiw+!x{?ibcM`ZSf~QdG%LPUfQonaaa*Wxi7DZbWSkQ73g6 zS<#n13)>88?lbBneiY3&Ct%P{>%P0y0g?vq-(Q(Ce}F2J>EH)eZ9mc>St zy`$w1=zaOduW?ic5o%r+yLn zVkQG1?c*jy1fb(NcR(_UATSfl0$64nge5`}FbV4Bynj{A=N7IIw*43(Xaz#e9Ycvw z^EmJfVqtGU1Tm)%eVtwkRzZQKYqc_YSn3e1C-_IT==MAepsgtfMqtnYA5|3$S^rn8 z2xNZyj)~_$D-voMfd%&f{B2ioQmtWoo9^qteJF@r5RJ=@!Q=!tJk&)4t5n;Sgo1q_ z_Q0g&wyRc7!9HiKv=NRZqNIJ97cwI^kIU`@y_!Nkp@c8w5*T)36+vI{6(@myCaVtf zH)wl>pN4J6tsqHalrQz<;~9ZIP=KtP)nHwC{+G<=5m=2zL>>Bigqo36^RqQRy-6T$ zRuR;rP7#9sK**j`oNDfRZ@JDe?Tb&xtZcux@;$5d7ulr}`oKL}LDNC0zGm}xb@-C? zi=flReiALTLgw)XQ2Ye+a``kdbR&FK`nq|S1LeycV07b?2_pSVyArnp$EDfSc<7h* zgtfYRHkaTLJ3z;wRu>O$RTZ5s%mgynr?Wt@!D2$MJ{-{SKl>N7j=>+cIE#Ap!J z9GW%z1_$%5&Yp4078i33F4Sx(rboXA7u+eIGpKz*{B9Y;xdgf;a?0y>vk4IId~2=W z?;_`VBHH^L_}qY0qwd}vV7|*f?*b?O6vh^OfX?YcY+hN%4jZGHYc6nW!PcJe5{2vRiR;*XM0#?cZ zp|{n9>ugCO#ux`m&O z6H~nl4-H0+V(VQTY7Ke6+iWse)M>T<0xZ}irxo7-Mf;B|X?xmE68Y;bpxgf!U1#|h zbsKg2A2S0CLw9#bOP7EH(%m^AAV@1nhcH79-CaX>N((x4OGuZ1(xBWbf}+mxe9n3K zJnydmVDD?~wZ3b9P1J$;Zce6C-RASKY*PDiU9)E%i%&KawmEdL1Li?&^H}IN=wFV( z(s0k~sn_npokcjvC;1|IO?I5=Wm__PtD#3)>4fmZp%Tu}CGXJALd9m)QYf=38u6E$>PQT6w)To|@x+`$w+ESw#kc zEMMmOyFd$m2dkhX z58u2OWJidOTC>7x6CoMXx^Yjd8Xu;0e;fXYr?j!s`O#YF;2w4FH&4xv;m^A*08s+L zt8P2B>pl!&80zEXI$cvfoi7~!4##Ec#)12(!BTXFV;a!y242Y~L$`YyyhsnkpzJ+i?qN4Ng4!e8-!b1w$W>nI*# zFMRl7RL+S0haGGWI9V}&C}0Iv_crVG*urLo^2RL5#p1eZ{0z0dLGl@WV_EDQR&Rro zD72ps-ej-_q@vYr`MV-QY7CjA-AS%I?WEner1ScNjK6#WIRskyhZvHGy@bM@hPq67 zTn&SX1ZS$lNEY%@ryhQNxfqH3opInM2KcYaa6P6#^e{hPz92UwZ}ljX_s9fIYcJ%M zDJbUd{?xn7y--2jw45>U!J%)wOTiXL!O&s;D{J4Z+@h2a>$|EVg|4TW;{`2KMXB_~ zu?YZEwV}XX(PQZp%&^s~!)Nd69dqjq34yq)Y>}5CdG8bQp@GHC@1VL<+Hfudl6c)CYgnpI7=V+35Xh<6!fDQS2-18BN%?EX?m|Pfe-#bHxCK3Y1EbprVIAP z!&d7~BnXKWF}stHsnf*K0tmOTxLkXub^XN61bk`xtrG|6o|8i$S|-p6bX6wfQ_qBt z!V`-}7J3M$yNf;bE-<(1)U@Mas*XXoq)k%(~eQ{QA9JgZmFC{L-P@r_8zqr{H68K^#E;2dJ%IDUwc%@bx zXt~GHa`mIpGrygDhFGP#l5 zeYfgAX8E{V+lV8s@vTkJO@kIrVjLl1VQnGbZu0=qeN+3ht{vn<_FOugXb-r5=p9Yl zwj5aV5ZS?p>F8gvStiJ&K1AZQ03GWdLLsoMZDV>+_xHjChr9sYv2^vKw)@ByG+%Bw zV@^b%tqF0H=xe)Y0p>F9C13nQ=&@Z2*TIZq<`1O(^%S~!dl50cNRwZQx$?Ep3Qb1kZWHetRxHkYYj+&mWSMB|_!oPa=dHvfP+NAKC_}d~z zc%47chR+fPQ;ju=#~KM)_H9+wec-kH7-|Fd@$l>QNg6j`>aKdQ*Z*f6@MEa(2#Ddj z4&Lnzaj&uI-Y^sOd4)T6017Q$35lW11?y%V5VPr>T3uB=jZcQuAL!@V)9_-`ULyu` zY%L~C2y|&6VJ&=w<|G7RJ6mx-ADFUVr|d0Pg`XQsocdisy;}}#JXf2Q_xiQEto-;h zV1X@R%sFg7Gw`7yC|P4tF+gtIkX^7!@GAK0jhARh!p~o5Dsx-ISrZ&AoLxJ1%_d8< z0W?aSW8O0=8!{><<2Y}FjkQQe5QTryj5T!c@f{f0^LO7MD*UyaJI>qiSmq_q3iw=8 zu;fR6wn83BfNkyub4k51da8^-)+O!KB&Pvm&XoF>pg2_9a9G;qUSeT(AJ+8gpcVb} zK&?j$!BmY}K28RiUr#^HQ#83LKSi%F(}|er4xi~Qp6MT$8Q7kA^Lqx%GCQm|JBpYc z51*YZo}C_;o!y>&_j`7MWo}7vZW%H6Kg)L8kh>yKCV^SMwMlbm=4J7_2%`Dtx($cI z;EWYp(?IVO>F}1G@+n&T*y=c$z0#A}zLc8^$AU@;jQy5F#+-?wU3&FZVO;^$x5W96ad$0L-!6y0iLQ(C;EtjGWg~8fhFx|^V8J`z3 zR%k-}_p@gpxqE|T8uNU#ZeR^NSbTPQo$nuYb0+~CCIPEdbwk|0^i%1fE$ujp&Jr1m z|CprS#J#pr5r2i*!?%!kr7>q!&ij4kjWuGd+8zd0v2)B82YTe1IP;bRnIeBLTQ}u6 zcJZ062BIa$iGMM-M*gz2`{DA5q2|;Dk#lX{Om88oZ!JMrah>C=t^Pdb%*C@A4Be|5 z)-)ePIm7f((GAY2%weMNPE0;hi|_S6d64FMsdK_G{*Rm3pPrU(H=^@}UfWQB(T36% zdpTi_8jlrP?<^Am{~1g8sPp*YrHA!S41dWR@x2H?cTbIGlz`G}`JU96o1Q-JBoT%b zp7oFxg7-}#t11)S>;eNr6Xu*QZ9_ZT(&+)j3_XhKSe&+P(7Xpy^2);!KXUCnuumO% z0_^^q;4@8t#U!R+9p1%_YM(rj{Cg03X8(hrpUQGSIm=L{O(z{%I zb~&yH%$Htn8qBW$yB@OfSZQm6=gHGddDk@E~@@lRP24PwOg~b z^ImK5=6Pkl#&T6qrc_s0aph%M=f9#|Y<$n-!@iMC$n_3ww^aLqve1>izI(l4pP#9} zOVIUnf$rD3aH0RS7NDKYQQgpX6kTVR!j@yi9fc$WvC{-Q7{QV8Y4k#&6b|s+=rl4K z91oFyG%6|+pGJ)1>gxy|2tu-8hBY0Mr^Z7F!Z{A$hWHGT>qDoc~6Yt{c z8P!e-Fvn4AO8g2wy={(amaPt4fS{?4L3ww~Cal8)-oi?KswscWrkmS+y=Q-K(sNpOC_i<^T}+`|}Nn zJ#U0rYgj=C8JVgmlhueZ7~fD|JIHBJLcLJNT8 z7S)gpJlks25gO`fc6J#DBMX4V;cAG0}op|2YI zPQKpl$>@V@9-einFILBTT*vwyx)7RNs$|z4#y~yD@#Ikf2ULKYQ6WN`dqYRfOGBL} z%iDVR=@)3{n3Tqzs*dY3M;WxUhMO2nQ4P|zon;mJY9liTqq$BvD9ihaZ9}Y_OJH-MZ@V+gW=z|<^Mlqeiz+N+pXO~4 zrOT`pxZT0UhVt|dEJ_qgxuy>3(=aj(G-wV^UjZMdgc$o6lYa<+amFL72kH!j2?aw7 znXxk*w)9U$oKM`{<|qzBwVst+*1J+?LjA{{WOQtM@RTyB7 zt?{v1EpnbcY%7SN2(Awe#vs9D`D!K|y9(Ot9QY@$UN}6k#pL@KuF=u=aqOd`SlB+@ zim8E^-r@dG=yLB6JJYBT2rZo&I{{&obNPbbb8i5JetT?ye?|B_>NgBeEp5n(;Wr)u zL_ZhFY)i#%LUiL}F<@gC%hk~SDBvZRW`I)EHWQ>>C54~PRCBW+-!=~9m-&V>Yk&0# z|CC&cs|8bxZ*rTo@$!7+!_y=_=W!Ah;S{Es(^=5vJ!z<(qqnUtlcSN}DN*d02~ z8Hvg_O(p=`LF5Hw`Sd@q6byN8MH!ntu}j&SzG}n#7@zQMJh~m6ezSk2cF5N zx@7#V)z}|)Vqaw=* zSok6nDhg%yPGqdd^QF~YXc)0zm;=dYMlS>}fH%>;An@SvYTxGobM1c{1txP}}= zZL@2ncC%#O3^JtrHnovYZK!8Rgs_?2~G) zEko%VB~b>Lr48a4hZ^~nYVVQk+*%U#I479C;yxo(DS1kn;6R%#>{lsHi$Kp}vG0o6lp63Nu35QY7Q2T_biOh^0m0t=b?J_^|YX>{!Uz7ErB-Pq@PKdMUf z0TZ!-0vh*6^>BnS4|S%YCLIzcc!Il!cdx*xodnybqB6{NComk%w1>j0!t!fr8|t0> z+wd>cctcs}yZW!QsbB7eYWJ2B(VZqfz?@__Bvd76ObvWU;T3eq--Uj5nbNA@Z6JEW zLu3>Z^`%*YQh@kKMUZkvmm;yS1@Ym%&O*~?bOH;7mLdGT0>AS!q5|S&-G_9mwQ>E= zqt)QEO{1W%jH7 zuvae})Hy51>e9`F{sy^0tMjU<#8~y|QQY*7a=7}crm!s^yk}XmnLZv8_D4nqyKbsy*7Fy!5^tIxir(&Z>txRr^RzlNIOVqWxV53SoZVZ;UP@-eXxeKVg_pN zWNl7_q7^gd^2{F2P~)AWdU5bniW(g^l}fk+_xGPzKcU)85Qzgoctwtcn5nB$z=7th zDfDzJjaMYiCa@p&Q~ysi9rbYb z2Ejgw4W07kLjC#daKS~4G+y8$|1~5dS=# zm0Awo&n@v)6jLv8IH{Tv@Ki9Fnu(0|H@x+;BKyY(o?ZyYa3qCKX700Jeu?Riv^S}z zA-=K#k~urZxCna?m^C)#K{;#fPbM9z6*R0u^vu$v3eWGS(3))-w((&MUXVb}>g@Yg z#qQ5JDi+VrUHa_nwXbX=Fck~Dbo_`(xaB2Vd}7~bnZmTv|=3@hZP{r<)gSu%WFuT3D2y3+wd7jWYeO>e{aPmg{s zY?U;7(b{}6pU?H2tkl8dFf=BE|NelB(;WR$3MU-RT$g=wmGb3;Bl6y#k>MS33-el5f8UYA8D3?i=V z;r_oZIdSq!;G3^z?}I=XDP*n@lH?Xiu@3%uLvTZnq6KgTc&Lll=> z6pvLDUvLzEew1Kul<<0#=xvl3L$rikw3Jn}OmMVZezbxdSV3Hi`W8$DiaG$0;CvC) zTX}ILhxaFNgQ-2$ZL|WC!(cLo1c?376U|_SdW4R$;0Hm)0K5tiAriq&p`biTXLk$D zd#~}BfkRlFnmnEDPB!Mbczj|O<)I5S1_=n1`;jkb0DuNY6S89xn%5rgsofQ7N}j$m~BL2%w}YC1m$5j-Jf-S&rD znTrKv%~JW(ml{J~U<2~@pw2oCz4 z;I!KlWz_!37L@CD@fS3ay z2eQ6iC7aYIBMLI5R8kYy#KL|%b`z~fG*a^y0A50lvfo1yGR71};P zRxtIa$$YJPjsv&y!UCGiz7!TBKzvaSN}(_(PqXhDZt| z{{)2l&xEIfsk@np{F}fYD3w@8!PgOxg<;eZQwgFFjnS#!+&KQa7ygGXfoQwUXeIUq zrwj(`U-LH{X=64+8VY)ou=x$Rd+8Mt@#SypU0p)~Fu=zxATmb_^dt&LGG@XR>@ix< zZ&ul=oaCzIIsScsZ!f-4Vi`bD+59Mzv7wH~2-t!WZA29=@>khns7LbIjIcT7S();` z@S?2LPkQn1dr(_8xDHyYZ-l1XQMi?uLJ=E)yd&#ef+j&aAf`f1iYyP+NJDIBXl=5e zqG_x=>N@zfP+inq0?=Pj{Qmx222{;A${JXbu)H=1AR@fgf&7|8L~0Y{j8m@(wmYJ! zr9!Gs+-hiEbj-T}$uK)S3eD$3eoH(ZLrV%dNmY(D-P- zDy#F;c+l>gUQzKHV9aP2Gl$hM$NgDnvk>a~d{bW#%-De6m3tf6*wetC5O>4;))o!w z+@!WVtHMbtG&(WA`Q_1lp$)k#?53G)l6Pfoq^@y`Y3iTGBp1*?Va)^_%@OBr+#Fim zd;PI9LX$2Sf8kB)U|n=8Gp|bl||Hl73AlHUkgsx{kPbt$eC(cIBJAJ)1OywSrsQX)EXmV!{y$IdqB(w z+J|)s0$<~A+*QuubV~j1I@yZ`^8`&9YEvvloJBs|eC#m0$r8Tc(%j}% z_eGfc7S>Xr^L95OC44)v#wIDPuD-K3gMF^PTCTzCb?UbwkfJffhDAXDubV3=M_#jg zt^o2OID4VG+Z9M%w;odGm`c4a=}NTz)+Aj3FwF`cp?oT{id2dh{ml*d%qW(Cgq zrJst-F)pXKEuFzu7ETH@6AJ6LTc4bYc>G%WNRqoG@kXkE^9e)VlaS0O`RlLh^B>C2 z6Zp!9&CNHzLgxfl9av?BSW{_GE`O>k=$u**6PSOufmIL5rG6PP1Z^-luP;!@=&efU z_Fftg`S%Fx^UkQ$DJ2&Om;3$T7_*I?62?vl%TR`xXQE#O6MXuwMo1;RUYKlZV*BGv z-v%ksgPHBon{}BXZe-Bz5o>t&P1wF-_=O$pn;lJy?9Qrpcu;mnYVr=wJe%#I@lF)c zUiGEh(7pJge?ES@@V&xaGo$b_N7pSUR?6(%VSV}CM~;KR=D zF{XenwCA}3^fN28Qg)}CH7Wig!-jv!ax3Ca_w4}@EPickW@SR!KI1IEL=LSfnG`FM zEveK&vn#<8XgaI2G*HS$*XG=KLpsYX~gA|~kqEOi%KAP^s!3^Mj0Wk z(2uLW2ABGABQ;6t^XabgS5}q%Nv53ndi((Gc@LO7E^K0D6x{n2$07$(u_B*X1|!R1a~d<kYE-_Jh(>#c=5ok5dbxg~3KtOL|!!2flG%xW+w9u&|+0q+h6; z{iuO!m?Hjy5J)j^b;w zph(3Bb*j!H>f+eLIN8k1$KNvDzZDQ@1f~SF>owx=0nqGlI{8-tSX;#9ukH`~G$LPV zhVCu<{-$DzH!pXD3zM{iKJMuh(?Q@#|G886vjZSX3?B{Tq~b;(yXXF@!gY=yRZQ$R zYOy|xAx=GSay0%)6@+u8$$?U&I9Y%pUn)HL=WWyV^`W`R&wB5p=09FGsiA?XfhKXe z!|C=>HN)f6@0M0KcMp!vK7Tc#gNEZdlsix=S`7(es^t2go^{AE3@iXA3@jvKNxmJo zG$dNeUTZv|rQNgNAq)>G7O)_q=1E^Aex*nbo=&X<5Q~*_Vr&r@n%v2+y7yjQDTBgA zUaaLA}s_Ff^d`a4hPPOR#nRGCiv9vXIxutNISFBGJA|j z77NaJ{rMLwG}3mOX@NShkbZO&Z3eS~%DKj+Yl2HJeTtH<-(=<4N^v$i&>idR! z0U=~^2{iMZ?1ZfNBqxhmSUc~6xb~dvy_r&Sf;P9Q(>82UZf3aIZ1huyQh*?v0z3X$ z7@2T?wqDIaKQT3y{kE?uG~oJSzU10p)48WI{CbMkIInA%z?a=OkzhmUEHVl|iUn=Y zAWi#X!~!klu>I>tQxjLL2%?ThB`OQwpg55lgHF`6USApVJ)$swj+g&TT>Fh#-P}Dq z`!^YtV+`ShqRbrl2oi^AxSvq33T})P$|9y04J*!Ghx>h^Nan=j`c_J$z%gkZCaaXY zphH^63w3e|m;lI1IAh{Hr8hLP4C+3u8l~Jnm8nebIxN^kppU<^u5_y(sH zo!iICiT>s)G+dr?+98%5#f;*?6!(ybeWE=qh+=c$p9aZ;fjl5a7uXV$k|5WVijpPO z6q&~4;ULd#G1ADmf1E!&BL7pec;G5JSB@-Q8?a}ouO$MA6&q5dr}?clK_SB*M`=3k zmd415#T(RIL&$uh@QA|409soG`B=Tgp~;W>ZF3A3Y3xUG=Z5l&Iv>(UsBDFebKL`J zMTC9_nUE-G)d^eqSs&ph_^95*>w5|Rt`%`E9OND8F7o%Z5*+SFUbvJEtkGEsyZP#~ zipnigBmM*Rwg~@Jwk7O44_r~TY-uvN8pMHao4VH$J-HvP9)${d^<2gYjghu@Y#FEw zYOQpRN3nEFa>tXhWolyt4y`t5YBJ&tDWUVoC6(m8cZ_J`SEuy{^) zOhvcBYn}#YQ{C*hrq4K`yjk%v)ob6bdoP-POm^oH6B+5dzFX2B!KUiLn}b@O&oy2j zH6q0K7+f#p_RFGEakSo{k5m6MmZ(_z;W_Xk^>b6n$aa4#%bku`N=wO|jk1#KGx4+f zJJ$ZIksDTqLxq*IU!rwEsX^Z+!gl|QZ-4kKbXW9=ecRVY-SFO>6+@PbgVC(}1CmuA zHwTSp&vTj1C!gPUPgw>h-FdCNB{SQT;QgYs8H9UgSs=uYH*-X?fxI8#Rb5a`A-aR{WUL$Zca}XZ&EbQh>+^h0nsdUMgGi;Sc&H^{3KpwcrOUhvcqt%uE*+ z4DfSbBfkYOXC3I#Fgsa`Ww)&zS?t%I#YKt;o!|L{-;S_spwAk=wpeH``BVhoiAwJ^ zGJpR;IC|fs*}vEi_x=+7V)Ul(OQM_J6Y^#T?j++0O%F2?=C$4t8+dAB6E`=*-APxr zcRXooJ_VDw!_2BtjXCsQPk9s2Iav5)C|8rX!J;Vg!WwN3anV zV$pu7*?{dwc}vB7>madY7PlxzH6i7UUTXIFObcn2&RJC9UBM4$-4DpvDxNaI#r`*%$oRlhWyP^uB zp=izQhU1ZH4eKO%m_4z6CD)_3BFh8Ik^H$#NN=ZV|ANzK61XN2R}!>*{i`Tpa)=OK z&Cg3_WT2xb&1yb2W?q)FA}6=f_@YGUKASHnWE`jqqfmQLroQMYhL-b3t5_*Dm_9hU zuG%RVdcy2UoL#Y33Xgp+_NtKXH3^bHebNm~)xT20*9j%1fP*V4Goou&+8fM8WSp+b zczNQA&JeDG&RcUC_BHwVV)ruIq_?%T+|UK^{pB|9BGs5g9l5Kv8olI`Ob{OtYF?zs^2npU}fg3gB|X37oE$3{9N zFM_k}!q2rHFzm`=ABI2YavG>n_GTL$6g|<=;G78NAgp2C!Wu1^oWRPBx$MJkU7se@rZmb0f+H1$he{;v7Q6H z?dPv*yy^LXkNli5nJwhgHi*ayLq}Z)hX|%0fsK&!G?NmLi|r^F(i#-r0u$|EAg;5Euf+VBH7KulZ9BZ9WY~@Vxk#=OrW<9?YFZ$1+#Qg^ZXQk@he$COk@1x#l z??uK@MkH|H>xcTRnFPH46FAcH2evW7aK{`1RRSWvP*uY#!Ex{X5MB zvag3&+*EX5Zr1$mqE?7FhSxHkwLO*X2P*|twkphN8^-?29|c)cZxP-r3n=x|cQ=i+ zC5$kX-6P*|*NEIZvvLhS(o8*9pV?T+CXhT4NDIhp+*+Z?58PmH4Sq&#d!dX$E?hU? zjPktvYH&O@H**0gie?5S{*YX+|KqJ$8?tQoylW@31A;lM-pglpL;WINF#o!l#Yk6=UbP~7?(oa zustbd3+j)%jcvYP7C`iX7T>LQ_wBAjCeo$7Yw_Y^+P$lN)$=j<+CP*&w1@z-Q#w~z zp#NcVG3w5H=k|~pY5*^%EM@&v)M;$%bYc(eF;J{Djun{QK(sp-mlGKz(z*q|LwwF#_rOUz<6MQn*B^TxI$3if3vHf+H5 z@D3|O$L2qm$HWl3^qCz=Q<r~1< zpzL>6_Nx1sy3C!batg{+o~)8q(S6!dE@i14oajzD5L4h0n%?*`G+h0Yo9}>2c=#jv zS*gg^ts#+*Q=E?XciE{A4yXfpK4uhGst{BDvI7>jB%bYGlUA;C9>}FKEezWK<>fT2 za4d;suTudaZhM=HRpb{R_DepV7=y4oJfi*FL$sWP&m0IfaJ4Li%nK zFN%3R!&yk;$q5m+h|kCKkYYBE@}0%4DQ)EKKO))@J7U-le{=6GYHMnTTWYv~b#()e zAJHxUv{C<)9$z)xk@XW9K19`)E1QFc%u~1i4L353qK*UGyONz#CS`@farvWPDTPae zwl*eDA;oGWzYvE4?0<-cU#xOaW*&&YJ5sBtC=#$M71*BpQ^eWLbR}O|H&^+1bGWv> zuu&cHZ=BpOKzYZ@$k zvtPS~=UmuqL%X!uRqyJAIQDvXqE>!TtIlfj7qQ*G*-d)5(q~te!yM{iZKbR`oKQ(J zuoc|Rlp}~39%RgZ2g)^A&e>$l-7IiF`gH4inx#sR9Z~O)S)Z@$PFmxhTYp;EpIcsV zl+!IR>@HB?p;8i6Tx#GvyhEito>ZixRlwzv`>ZgF%cb~$J4fd_u8iU?8(wzrM*#v+|#+fLv?V+As61t2F6W^@JUedsQ!*#g&=p)Be@r2cA z7gKi`vSn{QL)=aHy@Z@Oun(nK>&EdsCRJs42$;UF42;hUP$V|`Xm`H=F=5A;_B{rN zqtvD-iMO#17!@k9B&|9WkOD!_13pPAm;+5!sz-{dKze^_xAa9cEFJvYRyd}8s zhO9AuH*+la@OQ(9p$PzfI4KRGJ1Gv#G&_`Gq=LtT1$ogZabU+i@W4Q}cMQrf11$j= z0y}tzCj+WY+8fV?RFb31Afw8+BP_yLP+k1$QL{T_=wDSz!aB+*C7}#5rouAS%{PQe zCbIeMz1J`{_@bGS9{|%3#%Q%5A?DyF^kl!s>lcJ{T0@L9@kdJ|wa%BTsVbyU3vll= zo!9w_{rKw9WvSQrCob>2Yzxx$=JRe%zEKp7Pdt?vaID(YCT-%>F z*+%pA%s3dvHI%++6pZD!wXi@wJHCA6+uK6w)!dsplwW*K+36X`hy-I|vU!k=9ElCc zN3x`Na>y1~IB;VyN(aHUtS6d1gbR<;fh7Io^sny&G(aK86pKGfWuT}Sund?n!rH7{pH`!S>At_z(LPs1#L0?E zM&an^3SG@C{_$@k2pu?JXUdaOts!A1w>MUqbmXY=NCl4QC7L8^5 zD@H9J2DRM8I?vbkE%ZifJXqh~-2jajYBVzjOfsaLOvr2dNV~jUJf+!S5@qDw2!mjU zzm7?H(ar4QymT*6AkFtAikD0nR5rgDEDaxMg7(ce7_H2FVN;`IAu?7OQ~K}y`?Kij zhEdKK6(|0yCaM<-{?CrLg%a{(K+Grxw`Kv!SFqyGITYsF@a6X}z{~GO;zv;>3=pj; zp(HFe81wCIYLqlFiUIA*@H`3{JUnLrqBjHgrN3ahny^AHJ7y#GRa(i5CG!yD&`Ip= z@G$9iCiDbehL!XchCIm2eWgGjWeq4u2!RjfRUqETg%dLSl`LF}+^2 zdpH^mR6O?t5?TcZUnEGLt9yw7l?>O?Nka`RC?ij4-O6W<*)evUXqzhdI8Cd6lg`Gj z#C!*wq6&MG2~w1lCSLHPCAm~97gzJcWA)M1^GjgFwliFe(7}f;6VDwNu3fgj(>h2+ zPR8n$YiM6}Jwc1D>Ev1Z5H>Eu9f=@XNuAoBm%)l~v0kXoJiQHgR04y_HCrJJI#)iF zA_l|n3?3QYsL0<*OAK7Ywk49}RJ78EO~T?$iC>9;msRSBsBN!{^2g28UQ_|^f}uGq zKBk4q0fihI&QpQ?Oo7_Y?k|J2pX982IaPzc+uo7D92u${W&D zkZ2`-741nHIPSgmKr43dDvVgdr3|nhib~Xi&cnVA9>|)ez3_z(WeTp5wgH?;gmTCU zLK0*>f13ySI7zQg+4tx+0&|G~1Er=O4gc6mmM8^^7>c?WiIXcqaL-iDD}6(}?gl(1 z?iw zLQc<7Kbo7yn4BijXHG@3?2yq!?kpxRAshc6wealUh06mFJQrN!qsQ7(L|IN?sh9<6 zkWUsz%u3kvDJ2Nn@Gy(SCmO8=`Er0Q1cqa4u_|ibBYc)c-9c zX^QqBO%Ewr+g$*h`RIuGoQOsv_hVr zqrE&3uBa~St?Jn>N;AJ|`u*h~&l^w1uHCyqHaE!!4$b!_R|j|^$XMd^7gvL?EIv%k zSl7SYJfGqfj(8B>2qkh);M^pmiTJ2B3QL}(dr&XuNz{MQQui)6Kg;-2_FhyM3l>^*F z=0*A*#kE`g6CR-3493pMdBD+u@k=8K5CW%V)IOYaOQQ^WDfb2n$vn_8=d2w*W;1+H@~O+0}b z^LYXGY)e`TZp)0Ngqn(oo&+@{q1HRE{`EyY;%s7t6vfJe1w3}3TjDld8ks0cD*w(C zDlNDf@$}D2mG`Y)*5Y0tTmHn0w(bswzbD$;NXCl-sq68HUt!(AalsTZ1;k`}SQI;) ziG@R_u=MfMw=ASqd+=y0kq_pAaEG|<7BZw;43ue)6YdiWJHMH;`)zT$bZQ;LwP zn6srJk$=VTBP!#GY+J>;(S6#6f76z)(B&h(|HqeHF(9-Z7sNWi`G(7}NC`?2w%u)cUV2oz^|(x!g97=$ zfwXYc^yh!zXY5(>3eo%(4ju>5lYJbl-n>3R%@x(@XHQ-^2Rk-Uf;&y_JYU4`e8Ze8kZdeLvrI%j(iiaxUN30sN zfVGLhR$S@2M*U0Ynt*^rw>XMEv2kJf;v|E4`vHIY#|NRa|5jubg7#rfmVX|8qM{3Z zf3#Jo@uy^!lK4;N0i`(J9V)#76O4)=iTjU4l|6|^raS#pHQjC0JFQEBqOAUzcRQ#4 zU*624G)d4NQc*#6&h2pZr&2PVy~KucMih#v96Cxw@$#Bx_{Q}QUysAr6MT*4%_VC+OX%nb*mcd7{#;VWL?AB2n&Qaz!vFcW6>rM3wd zF3;u{#=zic)v=1vc81?bS@Ek6zXLTpLk?3#n85oHCy?6tjn6^CUjK@W#h^Kea!4Gd zAMcp(aVu)0{3zV9nFuQH0dCxRF#HbUNAwgEB;6@NB7CoD@W#fS7KJaMLu{wlwSXP zZkL;9>UO>^QOGgdR*Dg#w4$UK0iDv)$s~&_c9z%b&C{_lVc}W+G|bjFWt8|LJ5P~P z0Ov5RSMxR759`deg4D8@ev#YRR4wv6VFq#bB5&ZL`hFzQtOLw1e^j_eJS}0?StzypmMJloqvL+JKfwccTMQ1I!Oj}_hDJ4hm{FQdYFwX|*$*MiKjW}`MmJyJyZPb)(;2nF z_L0yM9^8{3D&^nk;)b_a;To_@9S1+U!#ZJaVa=3{hFeI0#v(&V?BJV6-um z>UAt#3N&wkvw3X@%*Oz4frjGo%&PC*2}I5>N@=UI@E;Du@n7Tu1jL z3CgWmcUFbc)S2bf!eo=SY`ADJov+Rxc>h|OuKCg3;p^t!tYB;2_r81Mm$O4wXI#}A zgK7+Jt~r47r_C;vM0@#;dykH%VL$99=VOVtu;y!#fL}7PA5ESIgw*V+t$yqO-rV}~ zOVWqmXF+d&U$#c#i1z^EU@Szm4Mm>32N4P$3R&WeW+&M<8s^qaM~=m&j=~tsVu=g) z^!{^bW0SVZr-K@UM=8ukppC)*R3=>-3n@Mt#|664as-hZRW=FfI4SoA!&ANA6;#Ff zV``#b?O~F+$gw8KkoKcY@?LzZDpwMvxKg(&m7x?~RVPU~6|ez)8(tNLlEytfV?Oyv zo(Duj!YnZW^C2rW_ZNZ%Tvrb&) z1QNi5RO6FfVI*ZPK4Gk!$iqyrE15?O8~!jG>A4kk{GufV zBaA%TEt~aM8sqLT>O9&< zvug0D?Ps!B4Z|I*szKXCL+lf`de}~i^8CL5Imk(ik<+R}oD?w&a%KhCO|lTJ?W-_h zvqNqn{#lhb!$44SEY;+HZSHCg$;0ho2NcRW|0(i+p_FoPy06v#iT_8`(`$|p+um;s zY#EC0!eiv*JXK%@;?crVSfj})kWuksbU|40f#O4-PA#J(UYgHI-EBcHzT)J%?$-oQ z+`E^oZ32;`=D^U&zvv&Xd&HpXYv`|z{l0%Qf7Y?j8iacuF7GL`^U=yxnUQ*63aBD*2b{|=B zExEmKAScV(><~>S)?a1gZlc|?PIc3r$49D~I~UUeGM!mOD%*0sR`U!FpGrgH zXxeVe^GAmMm%nA0E`NTx^6;lZUxEbD5Ir;zlM#*MJs(LBEdowc3L;n3rzPC~ED53s zu%`i{VZ=t%Y0;df(Y}r#dL)d?8bpT-q(gw{9j_i?Fd|FUFG89+`q3R>T@CCkUcH#*%kq)vQ6HNRXfh=q?(krUw!g#PF4Z)bcRoVK_eG zXp0@}&0462HAu=DOlyke(8KTovDC;|dyiNSJeCfKw%3dE3jid;xHR-YT6$n=M2xRs z>|MpU7)TsXD^933+IT+NiWqY%4+=7kL)`X~Tyq{y7sAH05ZHl5^;-+949Ws`eI67koI}w(`=aFo+ z6C*1Lx_ZFkA_f8Z*iGw%6egg}8cZb=CtC~R^aR!FL0rR#a>lWjOqeH&I6HKPk6^+r z6ow}}R)q;O<8hZ+A54n?Md4%b>tO@+z+Bc@eb!m5yV3LnG?i!8%GK+5c&5mB+-03! zf>wCEkY_afI8zXuaNM0mLW1Q?i|HDV&QQ#J)}0#Mn#_Q_vLDG3?#A{2AUS5x2Rz1f zH(E76T0;=@(;DpCgT18(VmZdK_9S=hWK!)UloMz8lO%O8rSl%=`7LGe1mI|wq8~D+ zja+09EoHCdVdTS;IhZq9Nb;jRvaTzpVt1lBeiX1@T=hk#I5PqM`RQ;%Rt<9!r+ywh zJokfMQZsX*;ZmGmT`c8z;h;69cmhmhlgrmyz~Y%|DkHRH#H+s@0CZik8 zub-cWk5v^+uwP0qv`#d=NFQO&WZ%tE*eQ_GFIl+A_^x;DgEg3fC5?#*^B0dZ7fOlF z%Q>veW)n{C3Bak=rhf5^kq-eViAz27vMrcmRD0r&JxW@3<5oPP`F3+Eda!mErL9Yk z&1+M)NJ?(%6@LjYCKJSXUVskjvIKVXxCP^7@wm&Lvh}502bPqDuf@6r8O#@X4x~v8 zrZENKCF1zhwx#qtOciOw<=TP?s=HMxb-9D%`FnXeEc(}U!?=VNYtDZZd*qSz)s^e! z@p$NAoA4D2_!9aw9_i)GOoLQG=`z8IiX_rvnIARr`jE;iIPG3c-;WBzdf1d_=@;gz ztJ>gQ&noKzn6y_i&(8uz^Xf;7(OtVqthHOxk^SjW$b4I zp`OmIr-J-ylpH4h1xZO7aeaDEg4SNcpXF?DAr`C*>Jn@wdQiYuoA<>4S5TV{7k7q3BpLqOQqDVloJV+~Jf4s0 zN4wa58>|6lZ-BMK*WL=tH79M`t4n9}?0Cph{rzo?ok4YIIPmHxHikCw$}>z9h0*KA zME{C)WQxIFt8@1#jV3Lz+`;*HMOPBz-d(vK|A|RSu(UCU& zh`fQ72D*>Wn&qn#j#*2|^2oZD`rnD9zY)P+PBlD#S2*zHcF7{sfczr<#AZPLB9}Ig z&~+{8U4pg_4h zm$H)!r-+fS{$h>9lt|jP8Csb%Vz8s&oeeU1B!8a@OMRel7lJN!S{e%Gt%xQ$KAvXW zAUjS&%$Yh(w7H5qk#-@$bH}c-<*pM&G9;3KOfIR0B#XF|?E^H6Ai*6S3box$!3B0~j=0*;UbB#)rv9334Xp9c74vRyky9L&v849V$#O5~n*&7IMpy)SZrX)S-fPI^f3c%Y4qI(T!w>G$FvV;eM$i3x_Z;*To@rq9(K!qP@9XRmU* zV*EF7UG&eyB^_8;1>+t?A)8h-*NP^IS$*>+7=l9~hXdbP3{m!erB3-eOzEywzfDxA zLN(e-9JoepM3A~!D(c=x^{Q={D4J=gOu@-fOTUeVDMiDa6(E(xO;T{IaqI3U)MqIQ~IRBT7vRM7(1WBU8)x)Vug`>RaH|BS4+*$9@)(8+MBRaz2-7%^&fZM5mp&Ay^OoLr8Qmk!{d_dmEiR{UadeF-7SeB`8Xi|i*Z&Ww-^04-@%h#90L8k9UbUA1=jd^HxmE@MZAn|wvyKg)ujl?P&?cCGfSYu(on zZM4#EGRa}99Ov=fk8PAt&W%hdu->D)>ShcyO*|Cl&d_!4g;OuxHu{GMwg#Csx)Y)a z6L8UB!0!MG!DzBn#H6SC2?}|l+fGHPOOlc zJS}&)Z+~Ixk%A~a+SY8L6N)01!4e@V4jDIDQ`DC&-Yc?j9o~=_Q4>GMh|?HqDBXHJ z-WB_RBw?m(!LQ&#wbd_YU)5`VELHdRq9m==n5Um8oyDxh-BEx10Teyd z2nMG~-J}!rn6FQ`8c~m9^1g;D_cdRW`90r&#q{mem7yYg;t6dpISweJb zX#TQVxEkdPs5TcU2AV4r>h}Idi(J6%YdQn04vXeF*K!nYbP1tzxDFQE-RGNqf5{)M z9%uAhR{c^4n1F7x|5Eku@1NgKzkIn=LxV^akC;%E~TH`O|`045FA?d@UN5nlL>I1q&{QFxnFtFGEM_3WG) zz=Q%=xSX~P_%4}f%|OF#vB$kBBSb$=Bn}man?n^i8gjL~D+iTe_KLWxRnB z0G2c64yV{BH<@_i168fjS2kvWX$jp@C;$ZMGv!GNSCXOvQ6(|o20SF4G(l9y*ECT! z8AumaG8KePtd*kad@^s}&Q(WJF*g|PVK(v_Neset1{7e3LfxV~Ed`uT4 zN(4zYilIYSd~OcVa;MU((WG}vf0{svU`;w!k%LNNQ(34$(C%kHgBzb^5q#cKN)}F8 z@;MN5NxTmDw&?U)Mnt(oeL4XUDtX|*G6N!eIJ3)j&m`x`^te^K?=8-mUx$DT`S)6x zG20|?|GIDhPoM&#)qPRLpDP}`rm5qX!7k6IGm<}2p50V!wCYeI@M!73df{&2^ zG>%4rwA_T(aoPrUCseGy-vp;2;}DA`R@Nw(!Rb_3-z@TYjwWa%zJBIHG4YljP&DIL z8R135&t@7Dnee9IU%YZ|yN;}NL2i;wKuD#452n?orNGfxbV=!k-~@7&TciCs_tw|jv4qS|8exNmex^9=$kJ&F+Pp)4ajA7M^_%3Cn+c%Fkg=Mu5!@3ee`TEW zcq{Kb5&BNWMSQdEB?b@*{2~y!L%!9m4iqyX%PnV;_#N&w?p-s2UK;EK%UX#i+P1@< z{k$o0^rH#3SP63&sryL-;9u`H+#fmRVM?zZ^kb3BmfBx8OWZTAeM|W`sGL?%RiTw5 zK*Idn`Yvx}?Bu3Ra`b9Y9rN-m%yf~0Y1R@-Mw|5t36@TZzG1P$CM-nRj_R^rZ5;s}`=Eov-Z541diP zFc&!`v4&Bx?5|VPDA#ru@MqU;XRTe++*w$AKV6?}S1r>YkcnB{5KIT*9xS``8Hk<= zz5g|)Jtuwpvd$w$)Lb29G*Go%wK^?u1A;U4ux{#;gnrYV2YHMJ4I!+szWnR9cPUsVckthl)urZ^JdB^7@|^ds|p_o)1UR7!B;NA}b1 zG1b3PX;{%MF4~@P9o2SUbDxcU(F`uhkP}?)%4R)y?(Rd`$}H`NPU_XQsiY|Z@#&&& zq}O|f-cYmnWED*;y4iHOp0~d_?%E8Tq#Ry6J2BUMwkmbnqiXUrNM5^W>)?&?%ST7P z`3=9_qqQz=1{rdW8vO`afPX( z;Efe!W!(K0rTJ{@H)i(js;3Y4R$ehL47_?p4zgb{e-~Rv_gAD&V#`yzA%6)Yo&gVh zx#7CfJDFE~j`1~oBYD}oGDv-&=y{)yU`fGX$AB0=$}}he0r_^uMn6f~U(avg z_7u7f4q;W*_<_Dc!eyX1hRc{Z(pzbB1+_Ki-jf6tyB^}Ifx z-m$)vD!C-iy;i{3{5fM^B_zD~ytc>ww5}!pa}v~je%|L?nzp}F^OpS7v#7(8`PNTc zBQgsVuf}Tb_P?`yq`dl3{Cm%Q|53x)&U8 z5xGwD_1ZMmy2YLCE$`zT|1=Jc(Hg$B+VU-g7b;@`*nRlM6>e6WzP?Un8{+_@jd__L zrWH5g{r1V7Mfx;`7*=_^f$tPvwhSyWBz(m_2-Z)uf=ne12}`9^55bn|E{;D9J|&(5 zpo|lGa6DVx6zAypEzs(dKlH{QzHdt)gk*1L?(m79WYFS+!+<}}Bz<9GjtkTbZKlYV znSQ={m)cCN!?^);5{p`3%i(2ULgJG{mS|FVscz^rX4`JU&-w#k^8N=~nzXFCHZ|zW zV@_fgJkveSk9{Se923;Y2Y82TCKer$6f{k^{b2USLAu!;nc3l3M~kW4hhSL5fV z5CqY_aTTXhwc66!M$Mqc*QmzVJJ(+_zoc~*On-9mLF5AXUTav97LA~B`tAwZ-&i1X znLYS13^#F78=<&e`(6#2GU~`#DQo;AhKqlDjCS>o895NXMXe$`v9Hfnu~V#$PvHo- z-1xIG$hnLX0F7|e4gT@HfwY2*9_R@0l{0xRAm2L>eS-$+h#{+REqj@*E_R7j#IMZ{ks_BMJPH~uiAY3inh2c3)pIgtmOKh8E0 z-P0FbA5pD_Qg5TTOe>_fKZaOu3g@N1-6H1Ixns%lE2@Erd$Jx=&wMj0~ zcSWrXXn9|$tm2N!?rzn7oWFO6duW)OBa;O&#;3&*(TAp5_^38Wbh=w%=%=fdIKs|dwncT9!YWgf6o3G?*kGAdG!=fPmnk9AH=J?$H7b~yj z+#kzXPG~5{f3qXDb~zw1j5AE=IrxFuhaYgT!GiI@+MfP-^1gdRcxI*4>S+9}3Rkii3ITM~_7fY{x8a}fzY>VDWqW94JMCi1{bv?i zeH%w(*zV~Ov&SDX;64a)&y8nW%x4?qs*P6i=vUvV{M25`Oo`pGaMersoFLS+NNV{Vy+bxRo9jL6)J_*mr@FsF$jCf=a=#C4RF<$ z%L~^^32Pdm{fW?(uo0-s2Ms%~NYg#v0AaMp9?G=78Y6sgvtGmJk^rL{fjXjLvUBxr zDD`^)Y=opzTnn1F2fwQ4w-qL`L_>cQpl9_&-2|v5x{YPO`7f5p6sQK{uD%lS?yIXb zoS0pmA8EDz^!oNgxYHaU`dv@qvftVa?}+toF-O-Kpt}r!HY-An5t=A#KO)TAOjM+I z8P#Xf&}HBSob9zah-eVJiB`SUts8nYyn~=9A}th=o^aP_EX)$nbOhjZIG7#K$f5;z zY4q_xHM|{z+M(cdP?#M-y-~Qz8E9>fe* zutmP@|z`@`>p1rpUYYL-?7fI$hIqCGt6Lu~lwd zo4EHd!@<*x!v*mE{9nR5aoiB5LBja@7oGKSP+jHQfg`w zzSGxx3@+@BRz)tBw&24&dZ| zJ*wV){Kvn*U!HQHW`>5JSk+$~d7qcJkfHrQx&{E&{=LnIPrXpkMKtM(R*UpOKTKk9 zd+&l0cLw%``=DT=F|bxIm=CJqnTZ_5^soYnEZR(T!t z8Q0M0>__$SXez1^R=T&neGDz#dkGt1gnXsKOEC+4oeDZ&g0xLWp%JugjVXcfzHz9B zU$X;x&-nmudZ6Y**k48;nxJ9b;u@94_rrf_p>C5YcCJN&H_9&E8g*4K7|7%dtd-203IfqXR+6e>x-Qr>bw}RaX-mtL- zs9r#`^ud>6-!=Xx8!(yfpAt}1!;)9ihPl_wQP8`qU09LeT$qcXoo8!AmCr5hwg z?=-CF?1K$avnfx=&Q3NaW!}P|ADZ_L9SDt+Uk@oy7;gDcyPq8{kL>=G(s|abJ*>$A z57Z7uect{$Re!H}Kpd8IP#+}45{lM!_u21l-k}-c%9J&6@7Qi{8)wiqp*m+M6y zZ9(mIK{5kMttg!Du&S@%7cr4b0>#yV`c)%`Y~venN3(cSpI`9&@;==9Fk;7Rz|H^L z_MW4m7s$|OezU&yy|BXmo;{gl|B<)+{5g!#7o|yP5q|=FX_^Q7JXaEXlJlg}suNrz z^VGaW&9Ko!p!0X^iK8KEp5qMsB=~$tuGKAgwK_!dyIg-x!w^f$N%cPICESXZ)4l(cd=gydQd{+3F4|Cbvz^ zI{LSdhHYz%v?95*EC=GEo()AXpJ|A1csjIKKN8gxaC-QrT*P1Ds7<`FPCP(dM$}v8 zi8__cFw6ejw%kWd(DQ1sbFg=EO*;hst!-QGt$gPyzl`Ipkmn)o!{CEcrKXgc_9QiL zgUtd&&U<~6D?;*z_mv?%Fgg)I#s;6ML;ly^0?I}` z-$LfWJ+qX}gPYf6rk<`0X%anb9Q-SPKeP#HxVPuraTE~>EQVGGcT}Z*zhBZd?ba9p zxuVA%y}x;6>HhQ34_z11Y*F%4_o}e2x8vbduNuy0+Tmb)FCvsjF8U6k%Hf=f?d0L@ zdz8PVUWB0A4eO0Fn>j5mTJBy%9D_@y;k#^u68!{F3{gBhI$j`nx%z&0OW5saip@ic zsJ@=nlvCjbn8N3i=D!z1!avlAzt32C^>FlLJwu(=D__60Hzyu2ARJ-`{|@qL^$wQK zG4z2R$ez3TljvUkt%{XpUMrmPo;CL(0x-nb2LZPpc5~lbc6+!=+NkCz*-d$rNA)ZC zl5m+LsSqqyK8!896TCJg?n4yQV=WR!v*UrHWQ ze&u?7uK^QEzjCDg?kC4sNBc+F8n|fFv)<6cGt;|g&8gRMJc_Ni<*3JGTTcRO;{HS` zht+F5m@rWG^i_OX5hoqcXX!`|(4BVS(LwG338@c;bDnJtblbskE2_b3#ocx{y82** z-~5NKhaOgK{`$CrDz!L$_9Q$z_jpvGceJZ1(?YD^Sv%S4`6*1^>Dhiz=78}?i;I6$ z_I!YvLPT>f0hD)0P-=PhEyS6#dF*3UUzGo|oNsgTEyoHDnl-+sA0J+elQ?6Mxqc}O zquSq6{-QeEbT|04Tr>20*PcD3TWjk4MZ3}qSik=3j^+Y|Pf@Z}w%@Bxe$KgH{xrYi zLzUV43riRYv?w*E9gS>^h>BMBw0YKkVW}UM>d%-Qi*5VimTT-8G*~IlzDRox%28x} zE16~(C7WA78bgXuerzVU=(!12z9Ug8h#-ztQg>1mq=R!3!Ql_!KmdILEkHw^Krc!S zqNC%?q!&yN%tmC>iqcYN+XacHQI~SkCDbX>Gh8oEYg445uWD@U=6sS6osy6v`iwqj zFwHJ4VKNO-`XXPHA$4@5NhQ9ibgEA&EoZrzv$novc&d4vx^3XVmYzCQMX^z|>nokz zLetaP((%~HRzw|K&3hovtlkR5q)E|V-ybY6W0vY?CpaseeU~N?K#5;E&eCirCy;SJ zCa13BA12OWCul99HVut4m?eFfqzC!ZsReZcj)#T>;COLunwwl#Q#&Hqv`baIpkrxe z-0sylb0U#+602#KAEg#KjFQf#<(3)?272iTHH})=gcNp_`-tC5kR2>|0ICWwi4J0L zgZ<x&fT;Q3w9$bgvZ7P{vh6AEBHV++GuUHHj;yPT+GB=_dv%AUw1847h+ymhsd4xQ*(!LtA-)9_`}AtAHfS|K#UyUg64He zS%^uKi1#km$=MgW?Vx;7Xf%k39D_o_*lYl>9WovTvLkn^%GP$P`;=oBvim95F;-|h z&n3rmJKqB%6peDOZUw>Ye3=PRo++|Oyaa1XV}D|5W|>T0WPYifYny}gFkQQ`f@j<;U> zP2Jn|`^|md_V!zX_FxCCBMjaLZFp7Yy8I9(00OxrN21|ma~#M+_E4y^K_(Q59?o1- zVkMOr&+$I$*{Oau*6zAJh(?mK)e)XWArQcwN5_NIj*45q;yN4F^N$EnMxWODkOMhi zfCwdW1fP|k2A)+*nvQYW9n8A0OQNg@cMcfRbR9*RfP4p{MUA6D(XNA&DK!7@cO49$ z3s2w|-}q`?@?8?0_&-vydw1u$-Y-T1C<$^pXQXg=Hb9zX{sqMIq!peGH}y$y__j%S z_tGl(H#HY9jFP34yRnL$yU}&{Z3WUYH8l1&u;#IcZAWuul!zcC~aRi>VbsfD`4SV&o zVDrnJ3C^bWw_|Y5sZyE`GX=dUHIex4H>{k;Up zkp<;(!iwk{Xkw#MuqRPIkNApf^ZrdyHY4E`=$KtHY&}9#xW}Cwdp+D6ckM z$@K^?VQ>*56-ST|4+`wIsL-pfuO#=3YiIE@77(TKfh`VGkhKD3*QXw+>Y+}3!iM>e z@1gqBU;n7b+-~Fs4K3<0c%&!Y@7}z_FawBt1X49Ot^viLv8sRhQYA~BxFB3ja4oVt z3hS!wgS^U{Q4Lo+8$(ysdfKR;1khI=UUr%Qv;ch@K(*+s0xGDq{dtmN`ta5aW)aZX zqE3wvt=5X~%I)VZ&AHT_#M1pa0nmeU5^wsd#>r>U7cb|FODhF3l$IycRoC6Lpw{nM zOtqd|LnbWOS3y{i;^mJsc)qHjAocosAna9jQ!SP4lv3K4 zUb~ps8_XOifQ)#b@v+T|DBrBy-g_u(M#oVxzV#TV`zVeD;kOqrEGgk=VToU;(U&eM zPAY#0H@d!|nX7|Kc9p%1)zDKp(x}>qVLq}5<~Myvbd9N9gUi!Nn~}&20|$D-AIc@{ zjm?8{528%v2ghcdt2ZfcCkao~+RCH1K`BL*jc2_vLEq{$5FUfxDrbz4^KDv*;f_Wc4eXdUZFQy>dMZ>OImufv6@@OF_=-M_0!~xUQB zQ8k4g`f2teCQ_u|uWtjO%Sij}(wcFHp{GYq0>37#o%smcl=$k9((xH1)u(k$u;jW^ zD9HH(G!V|7(evzNxSegrj?pvUCf7q8$3Cs{`Q>7nlY$72fByF;zF$3I%=U*RaAOY# zA!H-9SV#FKu3%;#G>XW}M z)MD@Q!5u#n_1Qk%;X~2w)6RPr?p8Ty0Q8Wwck84>#nSwl;w6#%>N2FMo=WCawFT90 z-%zG%+RJt5S9!s+z%SR3QDFPX0j;x*)1S2@X+t$8g{v| z0-o|FDeYBz<>EIu&r@)q|=mQ`zvtlWjBTLB^`wN6yb3yhA)r4FT!A z7jCP`mjRx$8QzXqhIh4AT_O)8s3`Eo7?i5L5n!u(q=OU3W!{RR1O8d|4(FBj`I zKDwjR+dPu`H!h3OIft+P?K{KjKygVFe#wC586P_UCq8#Gu&j!_D)#aC)SVe+S%qq1 zv-i-kTt2sPg^XV4C-R3U^h1M~t1d=wcE0IOQG3#PcSj~bfqaxapkm@<&r=m{RJ`_C znd;-D2!CdqQu&aFGvALb3jI4(gD$yI_QcLhrw+-Of$JQD{nKhbOqDZiBWqlfU!R}d zJR-yAJdqCHsrR0Zi7URFTFChDQ1c>nA>o)$jI*lXWmB2@!$0Z21+{D*StLaEeN?=r z0mof#syz;&V~T@m$1#Y3p1Nm;*i_Qrd|Bber|J7a8dwQ6gCV2mqyUA?u+jx5MdVG@ zCuWuw0K*Raxx1|lRBCC#AllJx6>9T5U3rSg&p%E1`J0HA8jZwkjWQ`~Yd!}VI+n6d zI|A%s*gf5Au{KFsH`c4n*cGp7Wl4X3bLR^U(8lLn*m>*jJa4qC{mUHXn}YCFERHih z^0X7EdTLf319&dP97E-&V`b2QagdhobuGE3Eaf`OJHi53l#Yq@$aQTk*>uahH$Qu* z&l}hw!p!K^=sn!22t;?cG~bSb+qL3EO3 z2oTHC-+#@AW$Uuq-iAES*1bn8l{5y%zA#)qu}DxUV?J-3X}3!@bvK3h;elhtvi zPr|tArpSh$%R0^N*p@Foa=tHf91F?SHXfR1;QCBU?b4lZ;Xw0YB=$nb{#E<@TSU1I78S-E;^lJTi z4uxl|&G=gvv2gO>q%hPCu5XU@ve!EJh$XXb{lLZ2$va@)n%Co#V`XD`Kl)u(ohSbl zO!1&i14hATFsH(=cX*$sWSmA|;E)hU9S1rGZ7uW8(A1CPw!b_AEQRnbpLJu#J-RGO zkIwYLZsz6ZVwp_w^m%ug^x|(bi1BfIKtt`ly6*?MTBW6%=$Kn*{z$)$(n4FtYrIMD zf*RgKBx(oSYYB-f>7{rrTp34aX7O1LD&qJyHT`eGRr0-f>6z+1t)lX!ujfd&eZug1 z%6IJ~De_=LEJ-86v@`DpSIwku=*8aZVP?{Vv&P_;J-o?}ANb>4`95izYS^1=84Sk9 zIrAp?e;1S9iF-A7_Z)oV9Gvz-&$A3_|3+W0`~t*a6+rH3990=T8)JT%_Bp^EP8^`6 zhrMRwl699kC#>tUM%qFi`;M-9upSJtlTyc#0<;d{VoH+-$l^6+_ttK?qbvmFMF-4)ekX02@hG?Vhr?@2tC&J z7_FGs&=L)iaezujR#rIXhp1-hhJ&25J<3kv3UBjA3bdZ@dqe?X|=maF=i z4~1q?#1j>X@6`DEU&l`S{<&~kek{kPnaTMJ?o7P3rm_uMx%mqKwh+3^y*cj0$B z-9tA-(?|3|S!&GrrD=E1WP1@BhLi7)gmwHM1dY5%#2Cw?PL@`Y{Ope#=7@wC-(~C?mr}GBU3SZxIKLWmd z$Jxb_S9UZ3on5K1)7$cUx#jQA7J#)CtlSE{-3q&{{{W=nyDiveCFk6(a>2sy90wWd z2n~9tzU zJ9$yCXiIE4C(D7RLV<)|K)&=AZ+QGRq)*mznOk#bjN10B`O@Lc=s?H3sN+89XLFFG z=8Zz>_X|dr-A|%2CDd6%8NONI7LsKWT%wrDq?MmYSUgF)D&}UDm-jZ$*J7(=;HRp2 zsw*3~<}4MX_`o)cT2{9$zax}=qEhaD+E&%Cyic3)D6t ziNCv|>C$ar`SG!2nxB;oCstE0WW4F7^_|F8ZT~aZ{L|jc8??`|Zz`W(pbNhmT7C@u z($%iL8U2vUu{uxsi9(4GJs;DeIj*%gsYY(bTaHo^_%vmk^1u*MdQG~cpSOZAeP+j$ zS)ThW(%YKbx?B4lT_qD=;?iESo2#zTHJb)uLnkerbFD{%wa*QQpTpi7NLYp~Zx!8p z3NYmHH}Lx1JGEsAm#3_~ZpDy?tFd{{olU5*=esYFBBFf9C#jh=iw|pX$%wirK*h|o^8&5wQy3?mPHqQR zw~aUN^Li53C#FAY8#MU7j*zTuySHCbDw3(1IOIs%aCu|gcY5jh+xu+N49Nx0;0z3n zH*9(p?WEP-SWfGYVWy=6_BClPZW=j{%+2OnWg2g}b^A{7;V0l3RTJq49gI(JkXsZp zXSz^yyjbk+uCeZ4&X9AnFg6KM%Se2LCAzk;pTPXpekCaNxr@td`e6s!QCTA~)^EjU z`qFiUmhUlAk6)%W_~PW(a6$J z?Dw6*%S>v1Rt5^1Z*FS_ zJiXue+PtnkQ)1Zi9vx4R410qQ!__#=I_Kef*Wgf)=-agJh4-H%LPQ#!DH=Rq@-?h~>@$&AqHTL(e;P;#M z?;SPXZ$EyoB`Chr^#15^`O+WI;h*jO;U1<8SoBZML;d_8%eb;_P z#&~`Es#l!r2b2f^pnxb0hy_sr)K{M+O(*Km{Z>0-;DOccNK|cd$x?b%yfZ6TJ9esG z!XxvdX+q$RVD3^?lH^v5v=fN?KS9*4%F6x|#0zv4gy<59OaWYqJ?fi%_6Gt)U*Gfn z5Z3(VVF_bGsi_$<{o#0J(1edt$&4T=SFJ4(&I~D;5%Ii_5X?$DHEz`5?;Z7YP;T#O z0TCT6ksB?^i(=9wg`+fy(C{lmw~;_?!g z7bYpWDv&@*gEXH(ejIin!zdm@)r&D~i4m9AkMTyZrh5q7r!@I###VaiyL3zpnl|E0 zux6M43~P%gpebaLoY0ZjOADms-Tf>Gt~D_g!Z+bRu1bKs+x{4zA>$n>TfyCSkL| zS#-w;aJc9QN|XG!^2%=^J{@5w_^^w_ikU@^#Bo>!VGicXSwLBK0~)WR?hy^ry6HSd z-O`5T?aWAuglt(m)8D^0gLhs~aOE@ZoFVoVJPy5SSZQaYpCGvRjW#?dUxUlCoY(cO z&7o{-&Mp6Y*4yA>X!#v|Fm&+^0D&r%fiGm|&D5QVXJ;57_eKQoGdPUjk`UlSnuKlk z@yCMFtgZ7wzQ$Gr26?Ma{shC(j>`Cg8R4{Wd1nR}nJiTHIM~u5q$*&cFV94(q_S4S zz{J6ADD?K?y=}!;erD{KI9GYV78tOibt9X@Yu1+Kkw~JP1n(uHA$PAOHj%9O-1H92 zSnVjLKV+A(X+(GwV(f5hpb`7{uRR zTtAUpPoCl!WMUfRGu!!ZOx%A9 z={XO$R|_!4U5bu|Rt4k{n8lNPM*YO+c6?+#zqH9gJ=j?Itvl_dN+^2Lx&X8&MHDSl zoWL_>d5C#iqtPW!Zg{`ZsCe`~qOxrm$I>y#2`8?p`H3gFMKb2mjlZGuyEVY#LIuA{Xal(Az z`i)&{A#ridGd-3JD3h}O{)epDFXKYEoh66=#D1lr6%&%|Y=47>(g@CNP}qQ(%`qkz zm8UkDJidc8y29Vfg<++F+@NHx_n$sm6N#)k(cPjN8@Z zlp%J?ueb)qFtQ}_T7wfus18dSHy7`^^tla_eo`0amev-N^+Pi;+!M(lW>ZxWW*TmT zwro>teIkYEhfZ3sRlb7pgB@ptT)e{T&PychU>npGbu56Q*V1-ugRx{Bt`t2ujl|6AnFc^1tI>EudEQ` zLh-%brpvg?5&8R983wQ^E+;jJyNQVN21n2B+;Ac*)1X4{{L#?XciIqH%Rsx%GqW*7 zLWE7mhP?uzU`)6r2N1Uk8ipR59s4G_N91|E?&MW!ROYvBIa9x1zQ^@H1 zCaYdP>{LajP5w4Hvk}+78eC1314MEEjdw-Nl?F^^|33}j)FLt71}`$~aAHL<@F}<* zolX^NX-F0#FxgG`$1rm`W)Q`!?0BozGvjG^Im>OyNlXvjzr=9-Lrl_tiMerg!T2j; z{$c(fViH`!{e%SRNu7Wuo9pk)7l!W2;!()a2XwI1N=7)e(cHn>Yl1l00-_{Pm*!P20{G-13s zu$`|Izlpq-*1*NZi)mtXj?U_lMZ3zpK? z@ZxQvZ}pZ-G{#V;wbwS)$%-u9z#=wef zow8ssjE>BBT_8i0gz8S4z7uf`599{5>o)0=Uo8;-0Mz~$!18~@ zF8Kf4a3(PtOrD-rsTrcN4;T|Q)@P6xP_B{n!Fx=;dcY?2I!ZGp4u&9RyMZezAG{bM zh9)yV^|LpVWA(l}XovWhF8Y6drLF&^OX-TPyemQSahI?Dujsqq@hI=1_T%zTNmN(1 zu-9KZYS&R;yEYh@WjC6kV0D_CXfB#>_^-Zn|Ag!MZ@B+Q-~WV@9L45x)gwph401)T$c>UyjRTvK)~R@S`(W6k z|3lYbN5vI1d${n-Fv#HU?j9^ikPHOZ!QI{6ZE$x9?iwJ$J;^|jpuyc;6FdYGxV-OK z=X~Eh4|D)!n;x_4C83p02(1!a_pzVK8Ky&c>>Kfa_0 z0%3*YE+g$#wdYtIka?-GG3g9Arw`NL42A#iyy5~t0GxCG*ManZJJ6f1{r__}8!M>c z&ybWJt2}^G#hyHAOltb*4<#@HEZO-7g#(>y^m5)t=Y%yu+~@7odnV7g|5@!w|6{b( z|7-YV!a|b&C$RtPcd>JG5PWY;Kz9x(_z7XMVhlLXN&1H+njl2#&CEO0} zT^OW~oeBj%DL2BsfmUm>`ACp7-e7&|%~SjT`XA#z{@?ju?|{Yrrz5&IN%tis|G&`t z@3I<#2^p_>!WPd0+yf6HI}DDQuXs~FR`ZL9OR{B;BjW-8YjYd`GJx~={~DC|e}@{o zok3m$`p_r0_~v0ySS9Jl#adtB0ZNC;Br!#1YU?7lu9C-x>2&Y@7aq@lo238G9(MVE z#`w=lD&H;qA2)vc?;d7e3p~gLp=lJcni=saVPL!QyeB&+nwt*5Pna6i_eNo19P7fM z@UfR!kLsHg)_KcGdh_OF)B?@o#|HSditeirp$ zACE~XC@EuG`om?0#esqp0SfyT<3yeNa;1w^Zwsv`28xPFS*sgI*~E=4BX;j~Od5_1 z!3G<^^hhr5mF1>49 zlZj{ku^nOIUihP0+<(E}{HJ|g))lv)C#lasI0(DbPrd{N3gU%OqxoIH5$~e0C)p*Z zXg}p>fXR>kyn{>f2RcIT6}sipH}S&IQg3jfk|sR#q+)JvN+Uqj z2JQQwMWgwy-h27*Q7NQ$Y5}u-$r->Rz3M3uFMn7>YBg2O2B9y2+ky&5fGn5%=G&K1 zHoR)GwM@`5O5$Q6+R&9g64sFiabI3b4k$RZ`1S(tHbZnX>s8KK4_6TCRh&EAdfE9Z z%@PxFNBSI`uRa@NzBX4iXmgE4x=MDWe+M;eB&4NQk)(=I3fU~I#FVp`z|)p6zx2o= z;@rrXSF}vio=b|#w4T$p_BTTy0UNXA4PDjmaF`Sbd0wu|F$180U4^oBP)FO#oK~dfY3d9lqQ%;J`NZW7QMn$UvFc5$$0Fl9P>PXmuKOqR!ZhKho z<~0;G$J`j5B2(AX;bx+J>~$(Rlbp5|aY`%Ic(m zaX5Bj4S8N@(~uR=XsYYjBoi#FQ0T=TW(x@W%Y;E{)<1j*a#m;7Z_L<%B!&eP^Bx@iN+kNs%A} zqI2Bq;GQ?R_#X5EVQCX3C>7dD^-Y49?rZh_t$mK!hk+^x&iI(Ls&yCVKf7;mwv6&O)jaVrem zg!_n(a6%*-%M2kzJV=^W`o6QhFn4slS*o(!T$y#o<-PGy>@4xH)O!KsH0~ zW4`(UY{o1HDM|@3CzOvx81)pHN-X7g?(6v~m!-dODL>fG~XT)R$EQpdkQEfN6HSx5?-lKPb zX@)yO?o1qY(|690W1)i9CL?5%)c+>dajthXSE|n)En|a!whGf~yv7-<^TR5h{h^UC zlXQzBxG|xfvvoq7F~Q|?E*%dD@XA1Y2uE29Aeajl^&z%)JZl_%yL*|wwvJ?;^}0mU z>ToG+`WmdI62*&DO1Wnp!QJP!TXJH_#@X7ArT0O7-lz!`3lC8Gg6{mzYDpQ*Gss&= zX0PxihP*q6Sukfg(nf=zwD&uXOCRsCa)Mbru;vv0TpRb%v}Uos#|Htb&0mG zNWcbe)K{rlh`l=Dq_MzcO3i@TT&7tFWgq&pl|W`trkm6s4vQg=st!t2?hvx#?I}6& z<%>!B9(hvJNd;l6wX#hrOO8h7Z~{J(Q-gds`t}|tSZM55 zRiBLDMpaTa(ZSK5lI^6V=LVj&sTz$FLLaakT@=)2ExF6dI#pb#8$YfcsdjWPPW<%g zd0f9p?&v*K`4zDGxN$$(@#Xi#uaL*bO~4Od#Kso3AI@JC)N0AT%|bT{sOT2%a7TK` zE0_fBVsjtyTbRWb z6sFWJv7dTaG9zC*^!n+X2wn@#FqlDmHk(({Q;5ex50oSQL3#P%YtM`Nxt&4f``+g4*njFn6&#gSVi1hDb1DF@J6_5)3 z$QR)~m1y-xWC+Gx$(|T;><`!;w$=Amg&j;!C{FRhltF(s zIIk*+VW+Gnhnk=tpb~!M1`D&@F%+Eu@_1_YNpg>fAcd~@buS3Gcd+hGJBm7KO+-A zkeoFM7lr;aPb~i%NOe=p5}*Lrc+LqY4)k@>y5>}%2fW)0u+3&{Is~?KppkkpGtUVn zl`-aMkersWI^nni>WzQdaZRbh+J-@_I;`xb>VmggY2 ziLfo`!Ya8@i0y(3(=8e_C22^D{Uoh#7lw`>ixDOr7}dwBh^(x1NGYivqs4EEdClB} zPwBc3B%Am4SPb`-VZ-SItZWloT!i0jD@pqBsW%eFS}0Y}2kE-WpDZ%JMGKjM2OSHk zd3mVH0Z_uiw1wKlP(diqjx#u$i{9KbHYSmwB`C%Qu~C--4xM&*4>wT&R``j~t7v*M zemPK^HubS?!t)|t6DpwPkikZa`iaR_ksiA5j>5U`zmJ4oju&(77F!Qujv~|~LW(oC z=1g(3Sc-fb@}8d1Thr?*PLm)WDOPg_PK-ng09{a+Hlhb)MD_@9OZGFS&xB5URU{T}N`iac8+4wg|(Ma-OA7B97!o=9T01 zQJ&#g{2if-(0Ep36;?yA*)Svs-RV1Zsu}ks{`R%jXN__mio&u@`^y~ghaRolwvP*zAcEKQ9g1l1tx`r=1l8A%AolRld!kZp% z4d9bUB0{>rW+tS!4yV9uG;AHDps|xWCSc_blWqjR`Oqm9dCS_98u;)YLm7g`YL1Z% zWqB+E+f{~nE zL$@ZFWFyPF`(ZcWuE+ZP7z*3HgVzHy>k|x(5IH}@)Ej*0HE=Yx3$G3MKz!(~MOV;(BKyW53ZftS zs<}YOqpoEZTdOnlvq8h2tmw>2=$}2e44G19KZ1|(A6jP#Oh$nG`6GHugj*yKC!;mc0mo>R2&}2ssa_rZh{91FEn0U!=XRhN3d?N^$?t;2 z)Q3I3Nvd-@d?N#a5S$n|46Hb*3#I*K{HV^@U_8)Tj?&^+*VSs+(;nEv~nwEm7#QIa(FwHMMB;B|L;aH#~i%01x#^ep04 z3}8xG?Ay%WF(!2<0l{H5p{9wW~JW4-UNvWo#B2NkiFp+YF%s^Q}C9V^B# z_A!g~qR|4r&Y__yOLDknX6@4T2>C-Wx%n?G8I%*_{rbhmd#20;7!jqO(IsHFqAj<~ zO$86`5(MK^lET_&amvBY#|T#zkfKu`6E0q-I!HdqNpTIN{6a5YV@6{iv?=IvyuxJ| zANX=*_tv~G49x24FJSBhGEcN-cww-53jFW9>m>odsjvy<@#=V_*J`HDmT5#Kr-3^?FP7SY2IaisR!2)r1bs<$)c8 z0csOQ6MlIOiH=UXs|;T8GW|{yTgt93hTRtr32S0eMI@6ae_f3dnlZ>~uE13}J(D+_ zu0yo4Nm=C)j~~yCemo_*{>H2(B5OM<0h{- zh!@Z)5Dx~FpaQ%QRq>#}MGT+;7l;TphYwi51I!cPE)eqAm{Kjss{je7K*WFr3l)&( z`aCOa!G>x9$ODWtUeuFa3Qq#LQY}aXFS?84x(0*Ys1{t)m*l1vnJ<>&#XEfAT39E?HE=RoO(xjJ#sNFk`p`DM(Ifb;pfq$MIAG;69=eKFvP z$|`jUP@HNd@cd=W3yOuHu!gzJtqp?4)g$BOXKENwykUc$XLD(Nel^M3{onkh3fUnS zXaj5Rv&vTK#0ms^iz{^pXNbHNQ8j=_rv@inc_TT~zCYO!X6 zotH3GSE$_<0Ng79REtEg4Qkw79j;|j2^&=EH9R-4uQPBq7+@y8sWh<=NVQ`_wRc6e zpY~EVVmx>KdCwwyKb~qOb8AyvVl(M{-4@IK^ZDwu_#&I?ngrEG1l9V&=iO`PWtFGR zOXuxi_cY)7 zZ_(QMcwT%D$z*4R>+s<77Tje`nj83h&$VemwIYE9oDyH8Qw8F@I#rXWlbbTHOKZ*Uf*SfwKPPH>FaePX( z8vAoK@ZW;9@p^ONRt@!$f$Dl-@TLa#V#oPyZ7MmR#(e=zec6Q$9)?1afJ{%mo5RS{4)Ri8S5AB#sS91mEDPr60TqIle=?Is~uluVdvb1TWe z8A3rWU)FAtE~`|3GPqq)o9=BJ?^%4lO8<3LWCBt%y>0^>tv0;G%R1**E0?FMc9m5# zwY4GxRDJw_bAXn-KV$OC&1Bj0 z)U%TtPAhpIm=Ib^)&y)Om_R#$j-2~yqZ@-X@Cr7Pdl#jE0gzuR2%Vc zcYda9U9SHTpt_|tdtf&G_ndh#S)1TDK# zCDfdw^7mBbQaGTctw9O}qo6oxT3n3^()=>n1LkQ@am!kzHvIXXK6>&L362g&u3JjK zusCy>vrJYL=x1mBB`sd5C=Un|nk4n;ijp&PY7xpC%@b={1EqF~SoVSP&;ohNh}q~@>dFjq8}*;y zmWOsKa#C@LuzEB8-0UXO(%3-`$SmeZVK?}wfSZ@>q6$M-m@gjyz7j z71uC{09EIi)=GofvJ(xpzUF(=w=sw_5F7rEH6tpTMA%Df&u3&3D?;>&tU}n5u32yO zLn9}%H~ahtolh1G==}YacxF3GewO9<0t8Gt+N6rEaSh%^>dhce1`RO!yWXlS4WM#V z)RBi<)Nra}8d+cubs$O~Ax0h0k2a{?;Lzb6{C=n1PllEE6SdMLd+?jvlZ8)@ z*I0bC*j36dlt?d-UjnUm=`CRHt1VvQq>XZ6g(pbjdCgP{%unMn^8N3_>M+WXwVdiA zw4b*m&k<pAXuWMuJrBiclZZs<$K1)Ah_H zxP4*^6ZvQK%9Dh4a>|oMua?SF{wuls4qv|_O^!adB3+4ZxgtYNimWnIQ$xQpOV=W| zGTYE&x$?bfI9XNB04V~_1pt^)~&6DXj<&&tuZHxE&wcAfNe>P1_n}BGpNxac#9ZeU-27M3kPBCy(oX=y0AO8 zcD3dskT3x}>>qv#eyEiaYE^P9?`VBIMv72eJO7A-TXziVqsDcr28gA)G&l8+aps}) zk~z2SexP&)EvsVk_q515VF3Te2l9_bkxx04V{*b;hKd#IaWRk?Q{f=~U5eXOEW#~l zZCPGWeGLZM6cj1TK@kDcDn|$mN0h~V?8_-b0E+O3w*;!Nj>c?|D63bGD=+~G-`YHU z;}PraRmb&PnL|DB9g;vq>rU2+IAW>Bq#J=WT-57`1OW#)PeC}L&rW%wN~#?jD4vty zpgNO~mo4Aol;nuSHsm@k{ORF!d(t;mgp}Q6P|em-l?$Q`i+1h!1K7<84lFS&0ssOw z3cHuF{lr;8C87WL*3<}!0mT;Y6RVbytWqMDn#yT~H!*0T|Ca5szg__AHZT5`h{i}9 z0Yyz+U_S^by4^0jn{r2xZdYlJicmNM6tGND+@Jvb)DAE$CV=?n1q;Xn2Z1NFUOt&; z2~(?(DV3bQQc!??M#fBKox=EAO1>z|!-+q@T#>fcj<$w5w8-EZOucQM7T!X^Nn0ci^+tmKMl{n-|^=WnY zi`4Slrgd~;l#mBdo_y&NagFKc- zr{xy3M@b;g!?~B45<+vzxGT`fV}Kl=Kq3yOd=4HGuxO7@8CuP_|68jr@(&N7wwF}Ls$eK2rZoq za!OK%IgBLu-wHEj0VVv)BtlMs({w9XKfB(f^|!hkfjI=e&b4Z*Pa%=s5EV5-cr~3z zwW!8803}DjuNs`56X5JG?5rI)d`WgvkB-KtcdbH1>gqC>I&Ih&ZP{@urd}BtP?a{3 zU==L`bV_yz#RH5q^OG~`Eu8Lk4x+u(&2%OKXBRh51_caLRi-$;9wl6zD9yCFd}F6n z8C%uOG#)v#L(v?~CKSe}t?l)0k1~nrdG2xg>=Rj}^9zVvr1^tZy<@ z#1+)&fA)-m-P_>QW*j1akdf>Kf9l&(oydIs-qaq#29R~iL%IOJ9`YGA^_|ZY7rld9 zkm2{a<2$tzm%UQizK#&eowMjuRGU_5)XHF5oFzWhR%d%}^_~2uln%O9pE%|>Rsh3C zdNBhD#!JGCG|w-x*FSp+|z-M?u|R)P7e)E6AZ+3$2`{H*-WMcQNC%RAXGK_ zog_^J=Z91L+sIXKyJ<=Fv5UXH%{5iVoUk~6qfy47DXlL43_ z)|dUp7dyAda%=E<$5{Lnwk|sVJvqKgo*H46X-$|o%V=qC?r!CTRhH{E#S^+xpNqw0UJ1DE#jj12>Ugi(WYYC9)8f~#HX(k%EX#@4l3+a| zLH(D8nGRqx+4_^TsX}3g2{h!DE@D>#;P2;rffaiPaC(g%@+-VlWS@PlKN&rWhH|-v z^mlKjsPhnxpTDtbA#;R((z6ry^V#>Dj%D+zHBU^6hAE!E5@%$87hVaKzWe24(PaZG zQ@6cKaYUVispeOp`0Xt5zUic>e^s4y-Q72gRjrjg;nkJ=PzAi`Z8~>7Qj0L^zbdTy zXR+vq#k#)wq}pEmk}n<1yI&abaomp=D2KR0Z@{r$KSUNi0)L*!$UR;u9*=k+OZR?} zLb>8eQ~%C-RB{>;gT%2z>by~}gDSHiyqe9GLze$}>Tl7blVtgiDvzS91h zA9|;AMp4-Fqp$Au<~{OriO_aD{$M&$RHBs^tQ39UiJ8cy0E9DLRPkJB8_!kpM)o7lsgBw(8) z+?+Iin>5{=EN`3a32kNyZB#gpTy?NV51FEUd%!A+qO6ouRdKX1_7!4VJ9TYJ9B$+u zPnkYDWe&r>0s$0=Hd`li=cX+NwRng|wI}p$G0ic1RcW!w^8q_!#&j5%N~6H-M<ttM@R`FJ*^6SxUc?-USJR~UgFp+_CAE;nkr;=xeOx(O?eTsR1 z5sNH+NG1oLYno+4YN=o*F>a#h9HWy*oh>d&3?Gwfhn|KUi|%TN_`%}e`PyA0jBjW3 z4ov?s2oIpMIHIgKAFEuyPnxWa-oB6@eiA}1+(~Jlkgf-m{$uScME#)-18F%19muhhnMihQ$s^e27 z7OCI_^FCuvreeTU82jCR4zsaxWFiqPFMeuls&R$%&bhzl(8B~?4U~CxkM=^ zGIyG}p&ehje5;)5z2A~$qnlT$LvQK!W!w#g(O~7fG?zATT zDrU#lc^6DVZslxD8Sll3QG;P@#*{^|YtTsrt2Ue=e`sx6#cS+YCS-!ndzty!-CtXtEp8GtZ!^hF_Jqkw~%uPZX84K{!8l1UQX>+;i4+5wCD1mSqDNo8=!)y*p zy57UZmd;5}N~jWpBMHM<+Ba6_nKRlmGbR!VEA!R5F~?Jn;vW)4`bKl8s}soCYA_Q^ zhj0@?aRwXEzh`SfpR@UxD`S1Cp&={)%z{87yo+2GR);d>KDZb8M5V;>4W(r;`twBo zmt%nN>Okx46c#{eGUwi}*x6q33eJ}5ZJkTMf3yXUaL}ZUufdN~yq(WzBsE!?N_6)p zS^J}C7RL(J;7I8o_1O5}9E~|VJhVsD6%*0@R1Xq-v8BvRnwX$E~zoH1w@)NUe?2FPsEuw ziyWv|9Mduyj9%%>nw% zSGZJyI@!_xATv}^Cif+mJd7ft!I6{+Un7@EcPCT{!|SS+vQ(ADuxOkIkEiCdP%bNK zI@uk~(+K@fL7gX4BKRNboomDq^)4%4?>HMelLECA_Xd?J2HA>ou=4j(`k+Q#B{dUI zHj~7*?I;Z%(Wet%YJOmIrHXA?;BCF-OSj^b_9Bd@KwsWl4nK8EU3SMEx<`5P-?|wu z`YD*Kwf{qf&#GFC_KVNVhKci-UzI5>4YC9?YWD80X?^Sma&VP`{xsvQ`@2SBn$jJn ziy}0n>G~G*8M?9v@xt9mXquK84iI`8_Ln?~Q+^oZt!I zcqVW3`uhn$zym6_&iU(#q;>#Qx&UjsAsb#GTe_h*UZ8lop$1-{M!2DcDga_?644sC zn0e4m>&PVajk?iwrM*0p9+hY@JP^4H@3}_wS3IwjIWSwykwUp=26%uh zmm{FV%Yaj?3@)5gHx$$pT%lvA#$bySw~1iUSwHzwSK{CYx8mRCi;HDd zGC8hZw~xfOswB^6gsyHApmV$*mlW6zyUnbgqpuR8FC(`vNIQ6lSw2uaHytut;s!cQ z8u6a2G@!nKx}>jWnEix7bB%%O8B1PG+0sauXzvOxj|0yDinqIQ+e!6Eq9KpYuh;RL!k2h*a-Y|??7=gDs#jQGnw+6@Sl<_?c(OaNvifR4x^`)j zS1@l~NHG`OlIv_lGrx`OWO+~|h0Z;bw`u0nQi9q!S!HXb?hor-c?0gm*PLg!`j2fS zCd_*C1W8;fro#ZkWl~Hr7w?TWROE?8C+wjDJRs&8&{$_rS#|ku(%UC*+m0-!?XZ@b z@<2UnF533}C3H+osOy5RzP<`x!^{`X$XP-Isg2a(pFb<@i>=pFodWM%BPd@Wtn++u zcDiAQ1CU`C7LOvz@V1orw)YgKQF7Q+l@tTMJs}3Xe5r9J6ZG46S7~<6%oLmMEnE8* zot!;^%`0e!r5Z6hA|+AbxCA$8wj|X@i6}W<$Ts5HH`Eu>4crg<2A%3dth7y;8#)I` zpK!PSc6?_!vPOW7xfUlw=9sJ7_{TRoMZr+cl1G8L+k9Zye875fn;j6MvPjB} zp)`ry@TU>YG-!dcR<;l#SHr(Vu|KjSOfsg(rzr8-Xca=~)lrjXAaXggq2b7CGls~goD0Qa? zc^r20D_~f?Qd_E03#h)9-@QAlWbvx{?MeUbxv9E5AX$&M?dKIkaRBg`H^>MT{w&@8D&SMMCH@Q&Hcs{Gfau0=+ ztGHK;D_UNcnl z{0Q$+etgpy5QAH6snW)BV~C;d!tG$e5kqN^BO+Q9CH(Kp&=_#1D#S?nH#KJoM;ygrGd!OaMhh z3yu;g;TIW#)BEz61b1W5QN*{MA0fv|U}U`z145aj7|_DE7g>-ek-vYfGbPAdpMG^j zB9jiP!2mzMe*5`b=2hm?-Z1buUqbQ>dSxmbo%&^kKXRWV9HTX4sOL%MZ|??61OzZh zRPiLT)yt-b@@upI&sT}9{O@4sGeW8pfCklO@+aE&*UD-qrjXWD`gGo^qcf#JEKq)| z!MDTNXpqH7MBN~RZ(tCnD|1~yX!=HK6f!k^C9J51TSc_9Swg||4UN8fuGs)w3+qCxjp zH*6Xu1g(n7xW92N(7l&3pJ8x-f)zT`3Om&mkIFC_U-Zs#L==DZWT9B#!{3)G#~3e7 zT3Si>IB8~rhsiSz8C*(w1EN|nuLmarL4r#gC(m|m2bw5WK`hZl28${`w>744kulh4 z&7HD$f-8O{TfZj^bKNF7u?PnGq>LQkS|DRskB$X6pe`lY z7{Wjbgi}(?2stDI)H%Yw)Z$iQ4Ot-O^?N!W@jDO=D*hcIz0QH8G#7Yd{u@g!La;B% zE0H)oi|z?xR}!8Vz6_i%X_SXBU$s}qt2BK#Wet6Sa-j!_%SC7sM;%P#A@m4=#6RO+ zM-s2um0&5V>K(_>Jh#TjWziW!q6N~9GSq^4yy^F`=BV&utAS+>5ab}Z2swS16<-vY zQfG!uwhZ{Wga&rgv%_E?SvgqGYPhbne;}eG;^UZ04-K_k3E6RFV~uMP zpLY_PhJ$%A>qUR+Ml~uXL@n)g)l{mdST8P4Ub?=e?E4b7@(nDzBnLLJ?J8aZ>5F?7 z%J*gSav7$VUWwl=*+_y=s!;V}sXW-fF6IspMtK``$_E({kEQ6}I|bX}QsUqs3JP2T z@c5Q>2RRrQbj7wMs3bdonk0EIi0$i-Cs`vZP`fS55>T4R?KptM<%|N#xDYPhtYuUA zMwCSh0nHzJL3XlExJESZkk@@6A-{z}5BY5P?QG3`#9c`&?|DpcLQGc%-T;M(j4VG2 zvu3b7&-u~-mmB`U!fbdC1>fM}b!!2D zgfEA1a$Eo^k0q*a%@~RRJkI{iOvt}+80f|yPM{1GMtzV1yJ?o|t=^Um1E5{YOdARWlyMf8s0Ej`)YA>_Y0k%? zg@(8lKZx0DM#RrHrS}8gsT8XcnL!&Ih(ZM-neVW-;DC~h;B_(EhZA7{@G zJqmNH%0LC^LD9&deT1APDMnt0)VCsPpn*^2+?8h*$A17*KIwEgjW)uEF?#ypcOhss zY^>ri&3mq`rc5HUr%3Xzz8(uvZ0hdoRQ zo`+3RcV<~dM54UTGpU}0GKtKRM7~N$Thc6>WeOL~=|@T>XfY$=RXs=R%cK7NO;P}E zOEx8m6b-64T02KN#()vvBY@q~u7;@gdWXPiq-SMGRdJ|RILIS5^ZWw~73|#rHg0$c z4?BuV++Jw3ThTvj1OO9lVUyfHJBYCi^(&fU_?wbUJFm+?|AZAflk||Ix?0pG^tBw9 zx$;w_GF(llA~8KY4#0ybuq0D^AS>y(Hrjj!Vh3B_Y0u}GxghxLm%d5yleaV#Fu94N zuPLD*J|B89$Eqj{09#T*eX^Ulwe^N2Mfu~Nz}vvT%l+yx?y>!h+oFpd!^AF2pSnIW z0MW3VWXb0Nbc_Ht8a?8K?!lH%PCN(5~@cf1lnhclCGMV|;e+ zA`W7wLhky490=Wr8EEGGiuD5^tOZO%K5e=dT)XYNu9Vc`XRb6-7>pPA@yiGRT|_;u z@VJhYuwnoRpljTO=M^ElN|v+*+W^3Cvhi(-Whpk~oh$3S?1DE!NJzl6sp&W^q?H=hp9i zAIAuFB+98H_d43v-Jzzhf3RU9zNeh|pyM0GaeQKY7+rqbXcBN>%fx5jVstAPk`|Zp zJ7J;3de&@qYh6S-WwFoXQi4pgtHx?wS;M7Z~{rzFXQp)dgty zPtn}4w#@3v+J;-K3gn2k_Pijs4~V4r+I=U!Da2-40)+ulhd|RIt5_20SZ`ShX3P1- z(1#8g@p-9CR0l9E0Ey9>E7e$OPVP8Lp_U|oQQFHcGx+${tx(&ew%XM`TY)r2EhyU| zRM6Em+k_z2i&0K_@6dux4(91&eQss4MCh55t))BQBrWWAnxQ5g={2hDEEyw9n`L*# zpp3#3@#p%5goF!xZ{i3xkeUl_sc__AR7O|@rQWFaw|g@GHWWpPHb<2kk2dck(Z!pK zCXjZ<{_PUt?I@NL>_zBscPkI5QC|RCA?Jvx+u4*7t2+Ct>a+!2XZXG8fayy6RSOuT z9XPh!yyyaGZxia%AL&19Rl?|qbc>i=V{bj=7ZfyQtZ=5{eSMuGP^FZR9r6i1HnUaR zqI{$U3?{vt3cu?o?MwI%%Sqqc@mVWzFk1Apc#3EG5(r60_!t(TY5yr#S7Ne6>QKez zamO2QDxX((nNQfsOk8~}T|aMjMC8qdtaf0ha^I>|f27Y_X?`&zp|G#NDJ*^dTP9mp zRkjj}UJ3j2&FPCBK&=yoacJRd8+qAf>%pn*AUIIOKm ze<$s|r$c@aed;BrFz3lo0R_(+{>>0N`Ubddw1e1_uqhy4MaW(dK*dE=eXNWnM^t;; z6t6T5fC`zy3UbeKWN!=RO;z^NLv=Fj_R{I~BQXS$xEU?H_M)bd393@D4IfPY;$*ACuctVvDSdZf3oeTzxg!he4$6exp z4f=1nye=|v6Csyfg5>t)Ji5WWt#a4xy|?y%a%$WhYijc+jthQ33a8l$XB7(P?F$#< z3YTjOS4Rk%Q_YR~|n0NK*{z3t6$N-a#g z6xj$>2Kk==)M-Ay7NK;~g{~J_c4{wyObaO4cl%&gBno^Bez^mu)UbtbQH6{~V)o&%)Z+kc44e6Z3AXGOobQIr1-4P?!AtWuUcb{c0x zA3spjBKFeJ(!wRQEWJ}Bwkyl*DsyFzcONZlVguym#B1LgplWFkXTEQ}k-dZ{W!`$^ z>d2xW`r zPO&YrUOm#bL!ewe&LMYC+WUl?d(=;B!F*D%z_`P+DP9c(Q=EynzLl-{N#N(X2N(Bo zijs3gmeZfUQzbD7gG8GooY-McNhN-W3pET`-^$ zzY^*@WX3QT)RZZEun%rO)Fq^FOXv=(u#?(~jQ~JH#unex-Pmmi)a9W!?8Vo8@2(%* zYo70}|KwFHTcPk;sd|B(VhP<8hs05o1iiZgigsbG5v%fQpt*uJs$^K=;f-8tz^CV` zVUH~bK-z0NMg6)zRWfpf;Wq=)4|ieD@!?U#nh~;aEkcFP=ti!&I#MewsS4R;Nb`G+?g4PyX~svf3w8X#)K`98TyAaB zHp%F1W|yxU?ylQzaaEt*bXjwF+XY7MwyHTBe%K4JrV=n7WooHJR&^ngLceis?QS|8 zkRJ_5m45(~zqYuKXlKxBp3;Ff?|GtINe!WY$yPF%e3KnUo<)GsoeB2mkNWy-{McPS zMo>A1M3fhwJ6}%!-Tt8`nD0SG6$3C7_%^f^p%~~}U={o()`}qUow02%SX)jR!--Dl z3&v}}yzN`jDi{*f3UUe-4zzZ4^sV2MCLgq!ACOWT{cJQBkvWiYz4G=mc{D=0xnRLV;O^-OVbYjw@;zIxJO8hPF8KQhEI)eLmY{rE#hlz_6?OKnk;9WG^ zbQ80sXJ)#9v4X5UiT%J-bYzB{zH-7)R(X&$@=c}OvUE4Q>F2D1XEUq!xT8_ZT&4&a z6$B9!t=!zf=<`7REb@Sq1{IXA-A>-v4pA9FK-Kd5ah^PKaN8BI zm$>3T7xom7O%H30Q50;boyn?-CH+4DcR+~0>&-^)J>9qrzOfzMw44+F@x9&3ZOi>F z-dRN7q>bB8T-=q5*91=3uT0;r?Z5V$(ZOBcEPCJ84c)7po?V;KRUFH%J-^Kj-f^wr zD%#t8?co5f-|dat%?-0{J>mAPv`T!@raj^mP2e3a-6hW3PE1VPJ>NVIpKhI_wcX&z zJJ-MyCk{?>-B;DzqvD%$7&j*hE@{ki0A#k0NQ zi9Xzj{=c0*t>wMxwQlRTj_bLu>$}eDz3%J34(!1$?88p%#onA{IYkgk16%16$PNm1 zmNz|DUU=yf(r)b6o)c8$1V;5wXcbQh#gNLLPQ(Vx`j-ks3kt7C14uUt5dFujFhO~f zsWhO^8;WFa7ExmQ3WJC3{q71&rTxCt?asHHGtbmbEpza>0@5TqE zst}FqB(XFQxDd~p7vB^7?(ZT0J|4yIvHG^<^|zz&b}6s%6F-L16;RJ*K+Duk?4Ir? z)JY=sQ-y_OWTjU`=3HDwQiDgs??lZVBvuAgSGMW#G5??cpzxZ`MOJ%6^&)TeslZ1P z+k7mqaHg>G=jA^GV39-HL!h=q{-n-++)ex@roU%K(RTI;TC07RUy%z~b>sl?1CGQP zqtP{7k4bap-t;%NNAG@%&c;kS_fJfE^^H#o2p|CFzGI$u$bCx+Z^!LcT0-Wox)IcN zZ3tT*<*x-$z*l;F;PgSiHTT8~V)SnF;&s6qmhsxxbv7Teb+mDE_oSpGUD>xpobUL- zUqYjG9}7qWKBw^yuX(^v`Y!MKV+T+$|843H!K!>c(5@sBY7rEdxw_5dL8Jj+2efmbX>@fcp< z6dqmMN>UREYRKLL;W_*~3W9A|^dK4s55q}oK5YqP?eLh*N`~E**Al$(M5`s&OLmHDyfa6=h8p?rzBLO0f0>hhv0u50?%gkkE z5Yqrx8%~YNsFWlEY9<>^Oez(W6q9a^S51H^jmgVF0ZNL(OIX8L8FPX{Knty4we>Z2 zw)Qr6w|Ab&F?8Z$!$SjlgYuV&!+B`>1MPsP+rnlSlEOkl0wMvw1WUlIsiLav(v2ehRAp5NNogS(Fq36Mk#y@+HieGH23clBxiL zV+__IAmGzyl`lPiRw}@NK`I(Z-2`9|6ekP>2n^(;Vbp-rH&B0-NfkiqfvhQ#e(E`c zYLBWO(AF60)2rE9JrO)5PzCB#s#cLAAY1b<;J|_h6E1A{Fyh3D7c*|`_%Y(iD0`91tQ_|>+P(;iQrx_0bx)MNJ!+dZE2 z%f0_r&j)_^{Q5yxKVQQ>blXiAARPVwHRs=a2OjrYapax%AcXZv2%v+=0k|KA8D6#^ zcNhLAV08F>Xr6-H1?ZrH$YqCOc_sGN;cgWs_+oV$+IS;nA?i3FdLwFAV2dh7C>w}E zwwT+FBdWOMhbQ`IAB#ugI3<;hF*o6f5++E;kp78ioRa1(NneZ%#@60|>6s{Pvd3UpK!Lw=SWp24Ez==E1u@$|fdz7a zOo0SC+aR*f6aYLD1UyGDasZ`UP&y12xK4q_Z%hzD1VX>D`pi|| zfwS!%v|TXGxVvFDB*CAKuo z3a(F&b{s0dOm`e@&(5>TB-dU8@pr^M@*Nor{&5C2gP{QjKBtGh_3j%1=wILV_lC+L z>;ew+Tg4d1feB`=0~fe};WluA3I5F+KU136XofG70gY&`qubWN$37UyZEn`nUe!Vd zJ`_YRW-_FK1dN718kkIXSEE@_a%eIXP%m}(Q=15B@G_nqK2*-HZTZYaZ) z%Q>tTqs&0VU{JY;aSW6jXc+@1Ah-=^KyWZXCFbxY%UpWklN`8!dnpqZN%YGL#MuwkxhMWy)A@4sbol*+-ORgRpFXjLJxiSh+MB&ShIDmvGpHkL zP{ca^7Y<*|z-u*FO<)a+i1o2A`4X)?yT_Ol})XAw*rs zy0(!Kb}Ne=dhI(P@Em$swz9( zdlzgq6VffC)%)#4L(IaF-7tEs?p_(M27y~^AgB^J0ETCn;UrjFZjMOm>6Ciy>^i$= zDHCwig3Lq1wQag@s4qF|Rplxt;mNJV7?!F0S-&YR;jKh$muZ=0By;e~5`5*En|Q&F zLs_tNh-}GG@Z_bvmdi`tbK_`mPT-cZmuPMv=9COt4s@Uc2;41_E0$>EhAqi(GPsNOoOy%w^^!p=jUhIgei z3-B_MD!KJ;By2+aYCDO#Pb&5R`zi+5%406PT9_ka1PmiOHY7xoL{V1Qm>mR@y?qG4Yt0g%6N zUpVzvy8&17O_>Ji7GKo@AO3>Tou8y719Pn(U6jE3)f;(GS|B%-Qsj?wAevRL{z6pbNo~FZPVNtqiM0+dt6^v9%h$SmeFS5@u!jb-|kn&t3`EXb; zR-g;5ATWmGHEJFe(vLDOoBGI(dO_1K@!ahJ+Alqpl=&6kWI)I9oEqw$BTC#TY1{-r zF)!a9^-1vc? z^vPVKY0w23VixcdQ*LeSOq%54C0gd@`Dv{0{l2$HFF1i)mc;&`iO+sB#=(y!T)q>aIkJto_(r}em?h9M`i&|y@THfWf zr6pR*Wm#TQSVqksdF5LgW@1j>R+^8;pc7%PWxEw7V{YYQa#i$kB~;?&F(@Y7xXxO- zm1ee%WnPU9w3}CQ(_kK4LgnFFqM;3BK+h>3%4C2qAxv%dT)TAu>~+#jE=~UwPDb7) zIss=!;-=N;k1pwt26&PJpguC}%gNU3PAg!tG|e zArQh0r_ViSA9WrWOhqVs#S>5k3sl8@8pFKoXJF+25Wieb%Y}_>*2M2v!^M!3?#+R9 zYD0nYr-N$7n>kMLZBu@T87n;~hGwXSZYYO#sE2+ih=wSfEJ{l(OiPH!m)Jpya>I&p z35fHoC|8fVC=D3z!9*M(se?R9!S##Ds3d{`Wcu7%qfES zD1NMHrM{`3U`mn%%aBy5p+LxbL6$_ct$OKvsHv%FNwiFhvjj?~605G5>64hNtAwkid}+6COSZTxkC-XH z=Igq0>8oPvwa{y+JV}?nYmkI#uuO`JXsfPH8Wc1awE{rXDQbl%2c&fZ7Xa#^f{Ddm zia`XbZd{sO=tkI`E5FJ}!=fz8F6_#-EVr^O$O`O|t}L{y$gg-RsiJGma%!#i>%Ri6 zslI8@;w!gai<9iE(Aw<6x~i?JEUg&-EVNu~!%7OSDs8vw2A_Vy5l})?Qf#CNAe-ta zQWAi_G~KU)Z2`>2rfF4dWLmLy0t2AHY-kM2>L}IjYoh{U#j;@$+E{kYOuEZ>xj_b0h>#Pon^+pP$NXoZDuJqc!4;qQ*!M{*v9WBa19)=Z7`P* z)a?YU36>+JkOt+<*9s}QcJRzgQ2nk z0}N^?=%=Fm>9Ia5RfK|a0kDwT?SdT27N2jB*stL_uWpQr5SQ?%wC)G9@%_5+>C$n( zTCnKWZ?)919#VYEU#;ztOR}kgQy^65C&a2G^t-sbX)0%3YR<8|D zZ7x@>`@XCi^D-1mZKjIpx)Sm*@2$N`Z@fASvs^N+w6FI>Z{&v zX&nzO9;laKz+0b+sA;UKG~=oa!vsvb=(A`kHq^Aew6sgVw49eI2wNW26 zQYW=iFEvxMUp-&{vH?A{92mg9G__S@h8eW(0Rw^`!^(VQ!~%>$KS0AV{BBi{;-JP% z0VIH5aK$G7X(v>L3ampEckLG>06u8)B3w0JKZXK0fH*rq4g|tUdo^qr04C7D8blWg z9KsREL;q$38w5ZC48UF|!6hiy77Rg1WP}jRHD(t=MCc4&uf+m%LNN5TYA*&3Gyni} z!eIYyD)_|jK7n3i0a~Z+5CCn`UBLm+1}PGNGT?wn!qNQz)}vj0XSDxueN#91Z)?;GjIeFJU|2U0}?#z102Bu zoB%{97j*;wzyS<^$Oel6oVE#IT0bb46!gRc5b09FcaU;KU#tNuG{Nf{fI6i17X-s{ zyB)3c$c$3pUt2fCH$*EC9h}+rVfa06IY0 z6_9}o?05=nL6L8F6PRs?Yr!U5L5@%Nc&h^oAUTW2xl1UeBJ98c2*3j5`Fv->U}r)- zm;pOEK%OrEWG{4a_`{$(0CNeto~MBd?2Bulc`i`3iX*TzNCAZ>!6EcRhvNV}e|eL8 zfD9b}0#<0bT@*nBka(m?g*yCoqPMl2$2v9~w^-kKjmL)jHE=zILlfvsxHhn(>HrTs z$D=QYgL?t6Qa3BOKt}Y#0HnGbNE%Y+z>PD3h9iP4hyn}*0VpUoi4Qh-=en$qyEh!S zjVAyAxc8J&f}h&}c)#`u_`t@}Z3(QcwJbxB(yg|q0NswktxGGJdq6+%Mvha0v|a*| z{)7V*Fc@rF03fyyj5(J}gCa0OB_zZl%s@hH_jj)VWqSfZ%)rC*G`W|&9hf`C4)z0Z zIu?jK40Lvfb3{L^K&12c%)Wd`$5w;^EtI_tlmG)%dkVY- zm=J)yTe6Ub!A|6c`Tc4M4C~v!Qw@x_+}A?hvjeP0MLWSwTnqtH96l>BzE#NmQYgM~ zb47H~_1IrNO!S3%KSZqRH0FQ4aO}frlRS$CEa;y;>ZiWyuRiOyzU#j}Uq3ueKk&15 ze!%VqyJtfsSVJ)!F=bG7Hsn6+YlCYTKmw5Ri+%;uF97M+ffTQWFJLP|(73i%>V;Ga zU4SW?A_G1=f6B+qH~)ba6#gDGLDIK`Ui?AO?{)hBfpR4Q3t0Z}-v`C=gNPXazySD* z3N$|*fI#9P9^ojS2dt@DEJpySYfaa4inqLu&#Om)W#jz-2uC7dcsvZN0)mx#kb2mx zHh?&2zhG>F5H6d0gvR-dFz^_QwYgAe9ny`)RnG7G|A2vlgM@`xD+x(5B{T$e1PKBt zQ!kHRDP#nf9w-C~3@rw3Ek7O!3JL%Tryd2K1W+yu1qyCR3Iw?`2A&KDGY1N;3M8JW z33YC$LnR6Wrvw08C>~xmH3$vJo(l#63S)~5&d@1$xSIqB;{*Zc!~rSCb!7okytxR% z)y4?%DPwr>B38@8G)FBMs6%G}6*w{;^r7?6u)&Fa1NUjkMJ!i5eaKS(RJ=$}qQ4Zh zXzf!83+2NU-xPQ$(7?^GmeW3Yxg&9umID&$4fN$fVMm#CHW^yVw5ijlJpdeV;AM&_ z28c)?ARvGNf*}PQv`A$tg|>eSPT@kEfK;h54eFrcglmOMwsoQXCwXip$86Y@ZUq@di=`WCRZ)4hfK;K^mC=)yib&_{zG)VSqXo z#&CJR?6d>X-^bsBN!R~)}63`R}Yxx6I2W~ z=-`7c^1SJQ2gpd8BDR1G znq?)DhAmtfkD0B^x&n42dV0taV+xUUv%^wC)W@PGqM4=@C8H-ot03%jBen!G8@ z5{8cjw?fyIDg@y40d(XzL)6nklHQC{J(+`GKAE;ijY@}0v6I)Vkd1p}s|)24?d}2Z zB~}3Bu_+3~15UoH`iX}l_MC|ez^BC1^O}buT)+YUh0`UEnj-NQk=8WPL$N`Cir#~u z8$;Cb8;E8-GNPbu0;&OVwV-l71SevUE5eCtlcqNZ04NqD>rQYbtOtVvPzmtWIMUQt z-(Xn(1T>ccBbXyVzq7VN@Bk={IE#rN?~(VKIh2eH! zMIo~OnL{19D%=r4;|A>njV3|Ek!kp2y)JtHktqaw13-WU0OOE=YLFRA97>cmW`rz^ z<{Co{Pyv`zB*1AkFb)J;kd`^!=4w2mpDE^p3J>umW;(Er$5tUQTl`>eQRs#%qURB5 z-3BY1n9qE&cR0a-BZOX>o)*;OugRUMg`W&#c-oQ4;s6IUtEh(%5q zaT|f5s7{w?-db{T0)*&tOlc8VNpvWmQ#2r#l8Q)@5C9D%fde~NVx*>QQyeY9P)#QT z$Ma$qJYfn$SYJ%1It#&z6x4?SqW}Otk`WDgVlpBT*$^zySAs^B;w#vw4;k)Y4XNZ} zLF;44{`45Y!czU}Z(Q!v%G0hyjh;%2)Ql zIZ=M$0DAcfOsk>}#C(bXB;5*`P=Zt2HOdtb<<5I};S->uNg+~*)K>oS6^?RYa}do% zq>L((gXW5rp};9eeWEfIw!j`zy(&C4fxE2`<%ymG>Lp-mOqs?Mo$HLNTpd&jF9PLJ z(3~rS+_M>0(SaA<)GLDmhN6b3#idy&t6&$)*u~bhi_IDrRvX)t76y-1f=LTvDH{mV z_+>$0ZOIv$wOP)Z_Oz%?t!h`x+Sa=EwXlt?Y-dZ`+S>NExXrC@cgx$}zKUx{D?)04 z+Z5swVh5@Xh>QC4n~%?2LstddB3U;(bCtyYoIQ3{Qz9+I`_a`EPx~&le01y zxM;`?)O?}`*EJaLjzZx}8UkyCY~(>n;}DNqcmR&e7UhufN~|_UBp+NfQ46amaWV)A z9OKF83d88jeM4cK&Vd7CA3J1wMDyG9 zQqgl0Ot+)KS!M-K;#?;GT1G8!cJ2mhYqJhW@Gf;Fkp^m*f$^fDFvLma#ltEEa?Q16 zx9p&<5zgBIRn6)Fqx#h5O0tt>t6?dm|AjX&v_~uqvp&?(WK^l}K4ZMBqY4LdZfh0SCpPn*=Ee)qy#t=xu- zTH;-vHEnCH0#*bXcVFle*#@Ab(lLBkeBj=-U{fvZoE$eGlEN(9Q@CnLBiMErQBfQ) zfb-EA0NZB4l?H(Snvb2yfa6xVa9Y%8EmH(`9vT3kSq(2Jn5H-fgUP-12JltyJ5hpZ z6Q=9=0?vmm7IDvK8UHtTM8IXGW`7sAYO0ofY*&30=Y6&%00xl+-XH)rF#v{#2{FP1 zH9%C8H)3+2augvJDTzh;OiH(hnofr)d3 z7ou=$pb#e^X~r-SUSLugCr=kSjRBc0?$l83-v73_RrvYE~4#;E0iUdeQfe%m<3ySC93`ebyy{NBLm? z`8bv8#Cp#4SagMz&6R3e2?kP0m0)RGR=Jg5=@ebrTUdFP*@t&w*_LkkmT(!DaygfD zS(kQsmw1_%dbyW;*_VF#mw<^{V`-2O(t?1XmIrBqUrC6r=61{lj}aDXMj3+DH(iQZ zi0{ajfw`HiGMIGs+$AVGGcf~Ogk zjwqU;d79bQn_O9zjVPSNg@S|`n*T?X$ax6QSb%x=nZJpM#)+F=XPeepn2WfA-1wY_ zd3W0>cX8*Pjrp3InVP#7o=GqsNt<^2wsb)oRoigr7Nf*_z%+M!7qaaUTTQ~IMhdZanIoa(6qOBrfO ziJ-D}dIedd-}$6&8eq9enbTL6XMlnhhowZSZ=?C2rm3B3x_SC0q~Zys3ks-fTB3O> zp03%XZ`!B@dYL%4qTOkv_o76ACl|)LXYbl%mM@gBDnyRY0 zs;t_ouKKF58mqE8tF&6HwtB0$nyb3Ht6=F4x@MifN(e_(1~G63!`c+bDy#<(tai{| zrd6y?5rqdPt>4#krbCisK$CQ&5)OeFsPPSl(Oo>1UG$|9BdHPCnpH9bQ`wahtD+A( zrUkB$696^`cG3fz69`+uYtEVlj}``}6C1{42Jq6afM8ihhABAoUR9M{+FEofK@YOQ zCM4+vqyz}6LpBE62Ozi8&CnM&;t&==HYE9L z&HxHY@QSEVj4vBKHgORnf=YzK7pTA;*#r&%)Jwnt2dDsKtpo`Fm4QkWCI?P}1^>zi zfKd$f1_oFA1yXPZh6c7fVh8|J1}Fs+G8->v7d>v0w(+1H4uJ{Jq$ZxU7R^!|D%%4L znJc+72pwApaOh4BU=KuR82>OEB5@IX@denW0OLos74}-Z;$Bx6Ck=)h^~DKxL3!vU zCyS%G?%<493y51nLC?;OQTh^xHY*wpd2K&wZcOe9`w2HVFKF=6tKYx$O064FcHvL zwEtiQ$BP9C7ioL)B~#J9fkF+D78Z{{NOlq)g;cv@hZrZ|6##J?w-FD~qhmY(C@thH zWH4t9`~VYnuibPU`cP~FQoZgZOR1q2>Or)H!vKZRM9yO!mP1ITsKbx8Q(oIbhvN<{ zoEKeHg|dmAmmhKT@EqU0a0L{G=F6~b}0^7?yGOSE`M9))8jeKAn0@Cuml z#0g6pG6BN&fNaZp30HzU$xve!{1Y$0vH~l@-{-RbkKA51+#fF{vY+b=k861Yz)CRZ zxOb2s-K028n>b=~8;-jw5ws=Aa)ahF0TF2q0|$+A##RCx zA}l(4f(fqsA#p+ih88@=^MUueZlRD@yOA@`RxnD`G9@HUYehD)ltOZohToe(IK&A9 zPO<2H|{$Q(+n@SnAHif)g;i>%L7f9 zb~ka6OJohf;#@B+^h+!o%vTEsn$yGoealh%As?cI#$V9a72IH#GiRbw2Z;^bVvsnF zpuoj}PN1bLnNqQX z1uV3~2>yM&Rx$#iFXbEKxbY9%LjuLhhoK_LMF>GoBds=A(j{GR?d5-%pd}D^1$R(d zNKHUy)@cT@+N+(&lJsc6(r#RC+H?Xs>D_DKn8GN~*B}_M|3ESPUE1bcibu50gCiK0 zQ)4J`(SUNS{=Ef;GOR@1<2BtSo#MbTuH&Fa3lWjqy}Zs{!?&gN*z!SA#-=!JF&E!( zHD{&zy;|&8`hqQ73!>GpAr1o6szfAj5n-&=DaR#quL#fLMb{qaS^;o z5o+;bLMzxB4H1eUWhB`UYEnprvAEUIOZdD7TOlS7yjnD&Pa->!BC8%E8y$PF4iPUi z)XQcyXu>0T5+(JqRV7;g+KRK-%0f!TO5YH<+#S9;0pE~Q4eT%q1jSJP>&QpXNmli` zrp%Ie$W<*F5slK5YmvRil(opyIQcabj-d2Lb=T~$@cFR4dG0tQc}ChR5?g;&N}<1J z#jVmd3}=;96*iITU?(rR2l@3n-eCy`upnSJAH5^6K<=PHDAD={K~)l zd8x{|&HT{sq#LSQ0#N9qPf?nO7^pud{|3qZs73$SFR4$tf(^={foh`yHew9A|Ktyx zr`nDSTAGn6ov=1yk7|`^DWEK9sE>-Ok}8>Iil)_hoFk~96ZfDkda6uno+(IYhKj0& ziKuWI|Ly-p^pE{ms-APo|M6d%kxGI)8l3LGo{Orde)^h$`l7(OsETN+pURGkx~PH5 zos!9XsJfp2`;V#IkD+C{r;4hle)^g}=b|gfosvnKE^H|3&nV{ZDD2C>orLx^QQ@rk5J2EqbK)uc@56rr0-mYD%LJ zihjpgpd_e>3(9vYDybu?aG$E5iRhtfDW%Rgp!s<~U)q`~`g0$;aEeH!Kj(Zf$XfJ| z{RQTHpm;!p`J%73qv*$-A?le7s+fVwe4M(b*td9U8jt9Srv;XXBq*s}YM9*r|0GJ7 z$?2p2HmZml8GS5Dr;2Hiz}b!p`kGmq|4+%5X-S^%|3&lslNp{i3Y>)qp)B~R z$VsP)8K^|Co?I!N2ZWwGnwejUeuT+q5L#d(NT!TP1cu6Ik~*RgN(7yXl!-{D1bRRt znuz~Tq9N*aC&-<3h^HZ%maoa3Mqs1v4}+{l|4Jf^9h5cD*qTNqNqjx zP!^$e_obzYs->2erMakz$(?4(l>L99IGKWnD4n|rnOVA&23BBvH>$z;r$5Jck;cuH zkNvf3{*h^`h`E{Ek6QGP{o1enG0Od@<^0;O{n9U@s1*VLA^8La00062ECv7w073$N z0RRI3l#i(by$_>2&CQE*uDbJnRRBnqm}N?6%C_zc$MPJm%g5L;hbJN((Wxw-p0Lz| z{eX`n$v9F*4WcRYip^@buA~Czm5##HrPkT3I?*8OjQw`A+;9)2%FXNd{OpoAhNo5r zhL;mGXqdxe*eLk;lc?yUHn>=1R_R!$>0o(y7rEJa=_zSO2^m#cy4nc{OL)qN`t>KO z*{8e9i{MyRDr-6lh^PwdimQwqc*`u5n~6!xdO7r34cgi~O*_0SEEk>}`aEvg{kol< z>itdH8!wM#3JhAvU95d)yv)sih}I%oQxvWEvGUAWop~%<)(~Y6U#pnnu3-}iOkfgxh02A=)zDr;ec>3MEO-+s zLyJOn6EpNYS93;2zE1PzHi-b zyRjJWX^kRlN=<6`m|)U~8OQEubY{?ODOpX{EZgtp;l$Ekyi2`4dK4cDdE5 zzRH5$)Opy+p);o~iufJauPQU|)=plucBtvrxh4*u<-wfQ8BoY=e|f~lW4uoPr`=9> z7&RA>FUOsim{j?8cjv;UZoxU`3X*9qQi~^Jah*`8p=BZZu(iJWyH|%j`eqeP=7nW&$YarK zYh8gt+?`2B&`K$x+V*4Kuq34uGsQ&-8)&lFu`F`WCxXV)%_n0D-j6^t<8nSp_vuBg zq?$`4!l$ft=P?fYjCFA$n(ASPb9faRq3!Hzv)55`s8+%N*T?J42)5dW&KiT9b=`K~ zjd$L9@6C7Le*X=4;DQfMc;SX0j(FmVFV1-5jz120Uyga^ns3f|=bnEK zdg!8$PI~F4pN@L!s;|y^>#n~Jd+f5$PJ8XP-;R6ky6?_=@4o*IeDL)NPkf&3U3*lp zC`(&>^IhheXw=HrS&;MAdpYQWsy2-*z_AQ{efS3-PbO;0voXt~;;+vnv1FU?Hu~+3 z%6|RpqQdpnI)L9<{`UVrKGhs+ZNsD5zR(1~1j5HxayuJ!tf38Wh{GJ}aECnXp$~ru z#2^ZBh(s)+5s!$(Br0)k80x^q`t;Y-{gg;ulBwCvst^ zdyj!xsu0OXNU~`nknvv7DtSld@Jxeu2^B}c*v1=L4U@{4n5yO_9glD5^kt^sZ`lJK}?Dzir>O_4H|v?`jp zf@79dLhqT^93`}HLMI$5@|V42jU4BgoMA;Mn6p&d%Y2Ctas>uk5@h8xQALV+Xwzx= zbmlK?^EPQhgIk4U;W)oh&X5eDle76JaNN~P*syc|J-sT%K`j`a3$;_C^SmcEwBo8b z;!+&??3gX}B2EG#G?>}x9zb1{G0fl*I!OCwG)OwfPP*}>i^&v7J-X4CnPZvwq)W$s z($RqqjEA$_<)YRpv3A;~Wj&dy%1V0EtR`$hyDW`Y@yN<_Hr1F{-AY)csg04w>oihr zU=FK^oJZ(pOxPr=a>_C&f(;BeY(-`(X?C=aO>$>`?JJzXDKxj{ag$7aqwzRFSZEuU) zFp)71yOl?8i6#RyUZ#UAGGd!NOM{DvmJ4+MgHVR%@LN!BA-bR4L1znU(8+2~3})-& z!T=RVPNsmmv}7)JnL|t-sc(m`@+bMeb2dr_<7n30W+q=#$9G;UE7{to!r;2!zq*5t zbMkL`JmgC0w9T@$BChEE>E4XMmtq24tX=IJJ%0VeqX#CTqVy`lz_Ki>kx8Fn0c+x8 z>NQ0`ZJ6nFDHy^)f|G^vc?%Wqv5r=XzT5S!Fftk<-d6$6U55E$tL2(Uq#S>@Ou{jxt}tOb&bTr#Hw; z+fM83;ni`B$qv&;7bB~lrP`Be4IK~vQhRJv&Rn*%8lG~Z=bB{%ky)ql?bo79W>PxG zwUxi}7`j-;FuvNC#&&eqmFt??(TFgD%uBOgG7X0XXr-d@L_qYcShd_GUo47BSEw49@fX48AQ%Zc8E?xd`rtouRm3Sy5RG z4&ITP8g*XWy0}RTXL!UNK1qn@cH{ES6VC$#oaHJD;3)XG7Z7yp319!OGIXhg0%V=6A8`ejDZ; zTwtCKcH{@7X1FcPLN4Anx+9o&hPH_Do$foKZ{2v3?G@6eue4eLTJoyTcx4==C(IX& zO_}jh_R`kTdcxI}_*uB(ArI_-ehuA}rQQ0-w>OJ3yCI4i7UkUMg!bc$^qrcz5N0)!^{||rxD1ZYhyIOU;u@b+Ab*H1#&QAAjT5p#rgr+TPVZNo%( zPS`i>mQiB{dP-=7zqDE{h=o|=ZflfhYxXW!mQG;Sg=2^}40L`D)NPLRQDbEqH|U0U z<6M+SVevG7iNaWwl81b=S`amKgVhhx_gN*si=yp$cnA#imwQZw$oc=B#GwbCn7^$5_fLsx6y82D29`_X0fLxSA#`|^n!gTg@R>pHgSw=LsZ3Rd@7b? z(TI2ows!hASb(R3)b~Q8)QtqiMx&*6MWsqgrHCpuB4Kw*Du;R0xJQWSVQ835CZ$dr zfn)9_RA}{jqC}2_|`YgjXHeFQe5SaA;}1hI9(ak~X$v z=;bsQX-b3t#&1}q7!~FpUe=U;HibE9W>V!w-Ue%tCVp5J8;w?Gjdhd|*-|gqRa7}W z-@#?MMrJ6*Xg!65ZK070(`a^?Z+nQBA1NyM7fTDC%%}AvB;g>>7CyRp5ZB;ma&~@SDt%to(05kHjJsmv~t0*pPB<<0)~te#xKrT zkv58cG)OSE`B8b+hr;oc6-F|32uG^0q9~@My%u;$ikmd3Ixxm(H2EwGx}*q26EKxy zRwzNH#*}SGNV#WL7rKvC8hTzCj&x^ETeV&#Nl1$cd-1rNQz}<(N|Q0QJfs(pT_#$* zwt4Firnyw2wl|JM>X?M8cMi3E*f(okd3PvQXT*1Z;MSm)vuAh;mIo&`GAd|fomE&= zUmUKdV1}WFZX9|*z#*lj8>G8SK%}K(=o-39hLo06K+vIE>6QiwK|uVAigG;loQw19 zyM4FUzF6OSe(QT*x1XZIci#J?g#jNRKWc);Ep{DN7Hiw&f~Xt8vdWcj8ZURg@Gu&yF-6S5WSLnqOGo zI@mC;?~!S4vp{R#-R<=tzowbMc6Zq}aU%BF>m2k%RoKw8Ucxj5sTz)&h8!yIaTm|? zU=OE24R*$tdC}Oo<%&J}$Xg``$>%M>=IQw3s|$*N z#x9122!mi&$&A2vQOsJccJJt;xuSeI-Jlb;(18ODYR+sZ5vWE z%yQ5A`+<5-%{|N=AH{}caffTk7tc@q|z(cWkqDDON6rS`8o}8qg zg1?_iyPw9RpAOs4fbHJnw6;d;*}Hah+<(!-TP?Bv?m0tAf~8zyXEzzVT_iE>GUW{l z>$j$5!Q9*ELxq=tl?{oSe>`;o|7T2+Q3zVkJn?uxFgYzY2URV^AM0F1(9QVwQRyVKJ`**84$)sb8hFE@@ zOZ1L_6EyIe#@;`N-IJad#a2EzeH&10f9Gx*VP!Hd)jP&8;@fM$Nt|K|?+P4`TOX|4 zDUx+PdO31em=#Jt!RC>iZZTZYI8w-<;Tcw&_N8L(DlE96I;XL}(_``tcCx#1^6k`Q z-|Z;E_y-OYw?eO=AX*1o8eSf-vV5^wdsOr5grU?wkQ z?%WH-eXn`{)3-xVW`=sTl4jjv^u7jsJ!FZY{Dz;K);jd_6!C2@?UUS6(-L2;QJwOJ z*k=#>bQ{dohOttGbNXs6rd%~{PxYOTQUGr~2{i|-|ML!)a=KWlm*;PcPV7?s)%pp zC5=CxCqDM(rSX)P?QBe41}OFfaw*{2M^?aro zVSFKr9ZyosMqO@)GrdzkpE!ZO)t`ZUs(qgh|H^%K_EJqNgidV2!)$gvXtks;IGiib zBqUurz%WkW>DJNXS4>3|ozC(4BiF)j>L1SAI~mWq%>_1u)gGs@_tu77Klhq&Ns$pP z_){Wsrmdl&)ni_mG}O{Dt)4Q))m@?uFaC|N zKcVV(C!JF%)+L{OU-zktbe5FMR%}hL>KYfA711#|1iflP2+gi5&vcj24yRYIk&(?K znint5-ll&V-ULmOq!lB@T;ZMZp1qnL9wte$>)Q^aHdD3V-K%b&tw0jS_5Nh}Iv0&5 zlt-v<~$DtBJ-wbk#>?bx8z#MI0ww~Q|d|DxfC)#K)CH${o#6)p9H z^Wg>F57X~R*K8u{-vy9C($)Z(C?}7p+7DZ#A>}2g~)z}NS*XArWISaxQf<Z>|DkXc zRC?MLvGI2Cw24_zl;w+@!xr~vGr6DnyCwhbRvpw~Z5MyOV{kg2Jzs48;(KO2@MYq7 z?cLm?#7%$gU3~Ky6U#YY$$3`R$r;(#OTn)kC1<*Uvwc>}SDA~H4CwD3`?<-JKhG_H zYE{&oHc%xG(cL!rr;hTLnEqGrY2KsPf?&{qw7c4u;z{Kk3N`t89Zc7{}iE7yaT)4;_|)3ZnSJJz3hgGYYVs^oCj*0x7fMs|H2*ji?Y@6FA!g);qK@qEh8 zUD{%<&3{jlf6H*R&}Sr|5)Jt z^xK|HPBHFl{yMRK&VZWcLv%~uhM-Q zKG&7ip=cHRb^dt2r>|Tt$^7IrhA-FtQ3GEpP1SF2hjoFT<8o*jm{{(yWuCVc0q$FI zFJmU)e;Nhf$XAakzn8l-yH!6HHtY#nT9MjP0 zyvpU&+g4{EezjxV?ty0f1=E=w2xOZ|(&ey03U1A}qbnXBQ;$K~S2jNk1#D@^LR zndy8v>l>x$QY$o`AdhK_940^G-b= z(8CMo7m(*Xe;aYW`O~7<#xzS#mPZJ)cKP|9SEOzDH`V0UbBU|nV9&a#=%B`je~tFk zJFaIWIVC);+><2!p!Yqpa)eXPT0CDPzobVdZVQNu{dGjEQ1S5V(j7SYeI~|OOwEkE z7ynXu&uwU2f9NUV+f3i-T=B1d?!w}oseP*P zlDS*c;*zD$Kgp1F@Y&+BZ4{}=ihYv6(u!lIj>)QX;j^XHC*`RoYi@N-OKVSCr%l$M zb)PM*d-ao=ZupD~EN}SD>X?3dzWQwWQ{Yaj>1Ob8)ADBM<+SNm_|LOtTcP}>Rtoh8 zeMTSEEyQl_#{sF$9>g!#C&-&)_RrW-P+YHCO^mHc?dZR2ciJUH_UG(RYMYREWgCTh zC+j2SveZ^=j6_DVjkoWYfcRcxH2^^<$_}_}3b^~llz$iW_FROt=^NC8SF>p^ZLBYg6~0^^%dn3Y3hW!)^L>rotqJF7<()NP=FH^@ zJ$LHCejo-;S8;t=F^(jE!9%z4b+@Ui=3;g&_5NRc(SUNqlm4$yq366CA z%A(9rktJ&je>}4k&$}HA6I_WjPvZYw14Sc$pf!&uS9(z`%6?Lcaj{N!4stVCZ-!mV ztvS}N*T`t{h-1qg3M~`v_U>1zsi!YSDiTq>QD?4G#nyScdO{0BGptpN@x{~-GG`-{ zZqWoKfE>nguV%)#nx4(WH)se5ijh(vNhC{<=-MjGWW1_WV0&Eqs6aAVUss6{UZ`IA z_RsiZiUk%0r!mx>^_)tYuy(%G7(Q)*pS{wtv?z~L168i6nrdHGhBNEYve1`m;%7gP z=8-!0AtCj)Jr6Ca9evHAJnG_7Kc4jDO8bpmXZ zD8xp)M1Co!hvPJhI2L!s{v5S22wpQwKMJ6JuIu^YuE=!i#Jq{{*D4i^0PufCgNXk* z8jJ_l0Vx0gf;%KK4dC>`wZ7To^qn{VNB?JALCs&ozTU~xCsFe=rjJ$mj(bRzz1&G; zP$#TMdwdDmSF~KK_@PAxopz!r;xd512xf`il4Ii&xs$O%1kHj$qL9%?z;|I2i12w7@MT$fg&g!y07|m=uM_&^-$AjR zpMaQ<2-el(YjIr{eMa}r8_r%TpV2}O)+7<6&_e?rv%rAoNPGnj0E|Oj7zqt|SC*#4 z!S7If8F~g2SqPGC1v)?WF{2?}|5)?(N}?>dC_&5q{6Bypj~ONA$;&7$n%CPXCo@q@ zG=%&D6N(z=;FO?eh=z!@7D!jsai0kB{l}a9$~##R9s-)%FlLYz6x*TThd0+zhO|}Y zzG|_97gSvQ%G9@sYm(EHdYk`MIU9k3W=S@pW8dv<@3zxWr|?Gn1<17YOcJ1|2q;Ka zz+XLsCxC8n zNtlsQf1>)RD?$H&QsEpL3u`pJ*fxmz`rV!*kM3NyYneX>stv{lLGD>z6G6%EFgyHq zR;=$KhRht-JDJ)1w9Re0{(tN^r;LfZhD}XoQh%(1W(5$*a$D90>*xk_#yV&sr=2}H z!Tc~47$}OzTkwyKv4Kr^0~3M7>PJR0ubT|yJ>^*RHWK(tI`QeDUHLmtW@aBwN{t-Q z8G?k**i|%)vEMk=Zs`9N*a-Q3<0NORFA%_YbuY1@1cD$1Z+b~nSYRB#e6b90{~F64 z+ql3|e#COkDWMwd7!CF;+1{fumHj-GMc>z2RUPH&L=#EM7oh0%VV?*n`&K9p(*V=X zr%v@-vfq7B>+60m6_rSZ2Uc+(P@@QL*yi-KXtx2HzZ!R6Xcl@WpWHQOxhlp zkdSb;&&UBgqW8JfZTyF&^1J9%y`T`pGvfbB^mtyy42Y+Mn#&<8r~%hP+s4f0^KRN+ zl<0pdUTJTQE}S~0r;2^z?|GqJGCdN^CtTJGj+8E>U32r8@^V6Osi>TW}&QaZSL_N%mrl{mgRwuCd2d;rQ+r+-!zB)rem$0N`0}^-#KUf_FttJm34p6 zBw#RhEH**wyUUV1N@eR5HukLsa3QJ`Mg39>x_t6M{J9&L;!X&9lC&wrc-P&!fg_Wm zl$#Y7@fF%EDfvpICfUfVna{U}f{UGk$Fmlz<0^XbHF}c7Di1$d&p`z9dS_?fc(}q1 z9$xSPA(w#*;OC-c(aXB=1!`?WWPi-Rlh#j*{~qB3zhT*^*%g?NEmeDT0bTX-)2JE<6@PM44&X*CWd9n1$^Q|= zWHJ)fzaG4&Iy~g?8f*@ifxB;qR}=%bNG-iseyKXeat zJ7f_VN@!>+J}uKnTq39!j*Wh{O}x8ZEQ{1Z@IAa$F|mUbGc@h;4Qzi<>Pd*(B^iJi z)|2~o%r~A^Uj)PQnX}kTS zg7|s!xH=Ft&At8ADnGTg=VLTQuH&c%lVFGEdZda@ zY1Od)6U#21*A`BPWVx4o=j>UH4ndHo!X%&6I$lcT^Tlw9PLIy)$5C5eI}6{tRIlXk zpQ|*A#|0G-x-Oh#y1K@aCY0Et$20m}Gu()O@F^Y~X06;!nAXP~IRVs}=kz-tGoEjcXQ97DI8gLOp z!)j5@UH_?U>2f6{W4;`~6eLvH zWZ}lW4kUPkvg@jQX@TJkW??5J7JeS+`1Aoeh}hf~lgy$0#L71}g_Dq6Fg`W=fddEB zmsQfMxWt3li-UkHD-~E<2yX$_8&!)ygq>bTx4_!jE1p5?$WVL`Fv?I-N9ch;KwB0> zXPSBoCD<0{2rCff!7-k(ie)u%Yu?^*vj5+$|KAt?Lr(yJ{~iDc zqzwFj3-SNIZUOwSpg;)t0Sp2D3ko=7nj)a|{|^dyoVNsLs`k+T4hl21{+hWAD(cZY z&Pb#)a5z6+FQ~!QbO$SiAbSNElu<@l9Q@#wN8w;D-tu7)Zt)3;k4?FFh~fkY$Q{v8 zaP)l$DVRJEMM4fI;f9ctkwyTC$iSqqNb<&fzCv#xISCOQ)r}zMByB_4zl$Rv;~;rR zL~hwm>PSGG&JQ6jmnDGbLx}icxDW)e3FS;Y{Rm3joOE{nRXRmI`+nxNU~YdT3LBg9D^ahZ58@BF?+lwMBHMvmO#;t%8p`3R;=ow6j zi0hmUCu|(70QTRg6+_fj;HQx%EB#EG9kp_rAMYcq3K z5T-%0Arnsk)MZZ)*m9~kXq<}-g?EXu#RIY&8T;*hI2c}|U(7q>U{2urJ}OzG zX~Ev7iW#-CJO4NeGA$vNaZ9lj18o-OvQr8E#yw>Ph3WkEd%Qm`AS|msW9Po$+>m;* z84lwekIx!vy8jtx2b?4)+s~>sT`-V<3%%B{w|mvqQ#nKH4YyAdi#69u$}FT&*>Z0? zJnZ!JzylV*Ecj>kBcJnH3C!e9C$}-6_X0+7LrR1f2IdwQvMsuI%0!=XRDAfK`_d{t z!{kMWN0wAr*|$r8iWv4>D~&ySb6qRg36k{)DF}r9q*u>@MHPDEKSKmXBMacKsWZ4B(iYbwV0_iE8L>$D3|E2v zi)cF+lhs%%3+s@i>NuI#v=}jxDYiX+3p-W$d+z_K>?Xz1GC~Z964B5U#oXX>KylOe zIc3YvYo1b@2}&N*l5JQe5J)mJUc+}Cx5DLKjj@4i5#h^iOg)teQFotaow=!!gPq}N zzPuU5P@6n+PtU)zsVZ^K!k^vgbww@Zkp}Zm=pfi({kxQSmj(gy#Ci!<$V!%bI><0Y zX%D!#=wL0Dm6fc=*#^;L?y}81Rg}NipUY>vavn89-_ndcC4rkM38LS~#h8k|J*S*2 z#M2`TTl)~}MJl79terNxwPRzFNLqD)K?JPlY#g<`z0MsdtXXo85ScIoGU*ih>bK}7 z$!>}>INFVKOV$TG^3)D=&)h`Mf0Dc`kNNsu5P~yRa8MXB=$0c*R!j*e6-bQ$UOdzu zOpU$Pqg@+N~~@{0DweBRySjZJeEcaddVeoxd%VI8k*I8oElhwL39D$utAIC<%5O45#`Kqv@tjmZ_MG1n28Nd6N^bnjB`T+ z?&X1~hUI?gN1$sRNZ@(*>WwD>B-?4N8+nv*fEqT`1vULZ^b2XO820>YSU0DEfOhaO zhZv@~(*Za7uT&HiseM4ybXlm2t(K87AmGtOj)^wQXYt|~tV06%w`sI#Iad|?Y4fp@ zVTArc5*jHZyJfzm+f23BoQp0|(#AUD7AH64dxGC&O;`2{yl+14MsyZN)LoS@gaOde zynPvZ`=o?+YBZ+9(I*)Wc{Ll8O6;?#Cl`(k(SV`AeN##}3)H&Lp+E8+&FG!>pkNiX zrqMtF2s-0~{wR}R!wt?A*uNVfeZlJbgE?lPT(LstF-NhKcx=!@zSuf_oB%4&#Ie;pTX9cJ8dQTvMi z!h$}LycL`L3(fJlHWy-fpOf4^BQ&NKHVfE@aw~|3R(X0DXdnBqK)kPsL^!u={n|&Y z9-|+yMs-U2F)Q)aVg0nyej zl!<*|zfyx}0$!ziFIYn>nTzb;6@Fl6xF$o3l+8Ep{H6kvW7^RWbJ|9^gGOKJP=w>q zQt81lR6zDbn}&b1`!02!GEQnO-}4%VV;5kQ@;0K2P#k!0QpkTDQbQTLF#x$#rdtoV ztY}c@Ai=M=D&A&EN#440(-%&&qG^a87}eEG>RjmkAiW$vpjTT_OO$xNSncJWd0|2NCQ}ZKz=U44GZ<L%Vi z+Qh<>ZGp*8H0kBwy2ARpbD95sOt?;s0n-}OL6S@`Id5iBb|-&Ynl?MVffCHkkR7X) zQK%^@)2{Bm;w)xJ6zQruz}v8KfOa&WsxfL`*l66Bd$ZRPaj4)8);S^>sF{(0fukv= zk5r|9&1R(CbyV@_9BW$snsaTTB*`d*@kHnm7m$hR*BpTq2JqZ92+i~#NZlRlf6&m& zn%Gs$0(zAzwZn{qiAFMo0I#3+wGOBfV4WykZm=+x2P!kQJE`@FW+Ri-*hlV>?;L>8 zF|t6(A;!mM$+20a`lBppNs3w!_wetF{T*6%517Jt;wqijz1i~Zgj=)gFuqAs_rhnz z^^-I@?v&Nqy$rtiMe z)iVM0CeyF6^)hcUqahy7uL1!&oi4=}uiWi9mGq-r;8roAe<&l!T2>>09nbxO|&nl{QM>MA7z>oZ?IMgku305$N5#pQK8 zzG8pv>IJOjw{NF@Kn#ZyRKNrA0?w}5#^hn7GZyrnpw~^W!X;1>kfNN#J_K>x(o7aO zfuL}WON(l=G{Ripg(VNzP$F>zBfknTJgTVQolAjqf3}NUc!;^9 zedBI*W!J+=+b(YXAVYX+;I>g zbQJ!NoG#Curqlo96V~KPBn&~iED{X~iqUp)Im`>&#m5}UKj|jm>*gXpa`1e1;i;w` zi&Jz_ff+jzdgUWyjoM-ixfs3{@va5&?p^U7 zEAgH;@!s?az7h%kW(fg72|)!3AzcY!D+v)d2^jjsXov5;7hdePN zso$n-2$I}XW7e($Z$Y9m%8|A^_w?ChMut)*cxdb8MJnjQPtngaT1a|bwY}KL#Qu}? z$qbUdkimC3D$QC5qM=#wfKLD;{tRNJ7%z`bS2_8#cY~Z!mqeBbkCi3cWHD27uC9Z=h9pvcEuk zbwDarFpZ|4@uTJY`!6xvL>y!G43hw-C1cxz8Gr_ZTAPsPe0J8|vLPu>-Xe1w9_=8k z5~vmj;5HoRavG(RjfZT-2J-{BuQP1Ew~%0!@>=>ym*kn044Jyo%o0gh_|3f*LYl^$ zC|?b#su9->WhR`Sp)0CC|3sUOnh z6i>t`BHC~a$3am&7a1ctl>0%<^^~${F2wc#-&A?&J6amuCOhTmA(s6NL}VLX5IFqF z6G`RRdtBvOf1F9^h9#VyD2HYOn(f&B^A9*$^%6#kTg=|G`)vBijU|yn8An$*b6pYv zELu{waCOLzKx+xtV~%DxTYgZV{UnX3B;CS<3MdMahu9J!;WYiF$a-QbD3~ZtXm$nU z(ox23CxFpIs>BiBvsWH%BV6=`R>lcw`Blq**9TECrv~uauc3R1cskhsx#-=Ug;n!n zyc44$v?&98i)xM(t)B9FgQDg`Hyz}+!df`4Z!7|t1_kJZ$QyP#dP_kFcU9-+YZom2hF zA(NObU22$Xu~Rjl!_fj~I+Km%*NctF#DE^wJrJg|7O}$fgns})Eu7hKAwp?$mGJ~3 z{?UR%%dnDf?1v#F6~4$NQLz3=Ff6`1<+(1 z!nRRVr2rZtp$EneAQ3JWJ!3uT$}$TA9V#K6`z;X}IrlubrA);Tv~%PvKFkD!&^pJ$ zLrIEs0IbcsPKMp~XAqTjH}r#Z&0RAd85u(t`7I#O2yyv@l{=2^R+ES1lxs(-!PSqJ zp$kD;fByi@tjNSQdgNMuwvDi`D~2ed zp0##bi`I^^{*;M-1{*^-$PUiS#$;KCG3Y0I+vNu;3TbTx2unmQEAb2h-b}?feb>_+ zIPD2+=Ly5~{sWWS+G~%)8CE8hryaB#&5{l3w3dsrW7ZquLfi1*EEcVrVUYrROy7`5 zb=rSvH+wPB2rh$KE@<44W0M1Cq{(HM3Elue9F#g3r|n=`G(^TNgpO2&(N$4fTGU)+vM%(UTT z6818Z-Ele@C6K#W%y1=hJWoUO0uZj0J)zt((R3T12+4XQuMGID^PC}k-4*dz>SOwvB<+Jm21vlyx7!;a)$}x1@Y|P}9>6R#v#19#`zK7N zj*08#-0Z`#a-IglD>kupbG>4txxSFO_>=mPmN`RTij?2?7>*EiTBgDRLFxQqxCV>n zQ>e5-;hk*wM~Xj0A0~+TpQ%;Zk!Bd75qY4K-i4@#<3fP+Z{B)g3JhHBLvELE!1iLy z_L;*0dj5Tb;HWgGzU8n_%MpK;F)S<5vMW!%ls@p5;1Ly#)sd+77}3QywFW45p<72^ zuPFO4^x>8j0D}VfynC@jD0!2-XVl#!qkj}g`=^YA&{P7geFi`Irq_tV#_Pnr85ks3 z^IHLiaBO4@d24vJPB%fJ{M4udlgOpeL}s5w)q1F~vcP;g z+-A`)zX<|q8S!?9EOPtqWFNbgCq2_(I^hol6nvlm1${{FHSZv4_aT(a%-Un)yjv^< zV;JT~HrTtH)=6xbz{aFz8Z`=@>&k*Jy1bdjj7V*sALm;O8`2>JJ;L;KJ039%q$A%% z%MbkO2Gav60{duv8{6ug+xh}HfVCMYg;a6?MzokJ%Uq(n@4gzk* zCn^h;d9T%;o!)%lJoF|k#9&jdpo-wBqZYE+hn_i%S7LS}%y5RzC~d~3f4)x5Ybt$o zU#S#w)|RT22*<$`2S};0yUN&J>|MFu15nKJ&}gP3myjt)A)!t-HUg^uEMnYGAW}%5 z>t>1{d^?0bk?SAoDM$egO?t>gw#H?ln`rN-WZO-t7a095lI~B}ZuZ}8%V!Nk`ntC@ zACg@>8q(?1&l4tx2ef?anXdTujA|jA=*Hkf`*&7;&E^1rnkn06Er2vWhRkbxqI zmPgdL%~+s`Ejgwtr?YBgbHOmu;Qpb061VC5R>-5HkG|`pc83D9x&)4tiW7Y2za`ZH(v+k1Eb5^yE$fe|2G;Wu$yKRO^3SRP9oT z$T@QQj6)gd6;No>BpMT&zRN%bwOkbyu};6cbQpJ{qf-ZZ;M_0J_FW1p9?z*;j$Ymf0SC-s>)|Ck+S-<49`B17pFg8=zYVhh-Ou9e^TbRHujDCm5T>rCr1x0#Z@-%LHVm7St5NK(dZWE!6fIbvoO-q?+1pc=uKP&N|9 zU_!(p=ybtUSa4i;`WOX_2*tX<1fTq;If?DWilyprMvbN-7>j1}5c@EFC6C4Kb^#fkY5BuuL3uqb}4;vB;REVrKZ zqu2Zy3#2~dV#-;#ciBS{*pLHoW%WDP`DcaLY&^h%!a?53rgIxnmhS>zk17>D(x_pj z(L6z9yI4SplEt`3TWBH{UF+tR$L|z@eFl~`uc~_nTxDm2e!>e}tk(gKtF;HZn8QBb zlllR>*(1KofxP|t7J+&i^2+Aqqp~{?R+)uY*j_$Ykc3g!30SKK;3fNk*98(V6ZlXN zGJW|Y<*8+xUo4z-XQKhdZIFQ5%;C`i4!rjD+e-o@Ar_<*g$PYIR*QJ zZ1y4al$|h04xE6W*~DnK4cF*?A^sqv*2p?o&*1$%F+oQ5yV_G1($@K^TGOj3s{4v< zAuTy9EHl7CmcWRk3F|kX;+kGwehU5eDUg^(2szmwL1ojmlfdz!Yd7UbAp?9yn6-O9 z+t8-_Am8yt_hGT$X7}f^7}mR)J zcld>EyKqj1)z@+n#I0wHNR$;-$B%4=xx29|>D`ZCTr|#?_W}iwAAkRoTvl*-{Q8me zy;6p1PRaiM)nCZqjd89)YTqp=RXv(am_y%`E`-kKb%NDwY zOC0(1PA=pmE5@P96l~kilLf&!L&)X>guZ+KI;AuC9lVHmoOsKKgeP+a5sDK;?dBDLq<77#i@v%8~j>DDdjT6$?mP&rZi2Cra&| zsJ@|W7}upCo5co7JOYztp;fHw^+56F`(9Ylbt)3BXggO`YMI|au5?TcGyf>9rll^O zw*z63v!eHyUA%&~LOR5r2RMT%qwviDJNN^MRaLo_qp*0JcH`pMIpD zAk2F;{6sSfzVmHXvh4x_Mype0BQ=<<^ec{FSn|+BHt+wsh}>q-PGp#P-pR^hTDuzP zg9;_CDL-90Msl%1kxb^$tZ_mFFg%VC0aHtt_LFnfyYOK9I;B#mxWuwhD!6-%IZ!N z*n<+oNZuo$4wPQb6wi?b#XWiE+9pqc#-x)A=uhJ?%vFCyaMA+*1QP~&R8R`n+OiC}NN54p#lIkCygk5grs6MhP`OzTr*RDXy6S`N@Y` zW_nKOXf*qNhopLoid#-wa}Dyl2d7@DNTFSFr7K~vXrYGeL!faTZF)V86h`|FtVaiD zYv^8cmpT9Ou*IDa1%EcG%aTmmiGwvB+aIa8`4Z-xKX-{zkDu!WRh%f$wwu>R(c*>g zR(=kgy)UgMyD1Xl7O~W zg}|uLjMqn@-F%(u)gyn2-~zs8m0!qY)n|L%0SB2@+`ncBN>X0QBtO7~D({`%)nSPQ zLm5K~5F&LfP5pH2nQ<@vOK%H*-~y$1-miL`^g=_}fpE}k#mJ8>Mdugp?*Kj5qzigE zxaT2hj9TNK{af<;0?RuR@bR#IbZR$=V||*xLM|b3G|ReVAsN^5?hn~4^B=QoHi|*7 zSsZ476Z z+^Agl*Q!A~>2m+sF{sJ6Gg+a8?`uZ!@7%6m-|@fLqk-6AR_gvlq?vtk;^rVTNrzrrE`mmG;-sge8bd9SKU z+*W%2YnPZ6fEI8u|?xP(g)J5uII;)xSf#+U725Y1D5%r>{*>KU z&aLy$r3H<$9>sdfsFLgcL;Wf;!9XYaak^p3k9qt5TU-QK-(_enEPOLsWJW0Frsg$V z<8)m&6zm-&2?&UT*ldFIvGSSajmp`rsanh?WAxkp9Rn6JwCTvyF0qtXVFgwa%7x4o zY8PxGu>{P518gN5O!I9J>scbuUdGy$;pjN*>QqYKR0(_)z1* zOj$+%wjN}C^wXM>Q^mQT+2MG;Zk0BeA1KDn8I@t8V>Q_@rT*QyoC0NHi5c)7TBRf0 zYsgVCWp*UVp`mPKqv$GRlHxE{rcM(wYDcm$GDZzs z#_1r%I0SB~^j4|pliZTF!7?E#O8A$2FIM7l(KKOqv^+Tg7riKyA_+UJiddd()()od zS3MzT%CCvyl~YP8+3PFy zr7FCC=Ju_w=yrqdwZxR)q!1*P)?>5V)wPIfV>+-=L?dx0M2Jb?MtxQE-&nZBCS3B5 zHX_lS95W%Qv!lFOk(q&SB#Cdry&#UYl@utXI|8&!sn--#P{14=kvz@4=J#ro_1VBg zGe#`(Xo0BbMwzEq5!_z@ue09!+$N6(ojyW8#-(_PaBZpEqb<~N-5-mj0E0V7T|$>@ zD9Wvk7nnG;oqMvD*i5!+RF}+pJSuVgDi~G7=vLfVG^jU~7?ciHcH1c@#A4po5q2b{>!bwS^nurWz^Q91!HaEK=zgOxmHyS zeMXvs0s-1j~P>3<8+J7x?7yF`du_X8|8jsWyGJTefyW}EB8ObOZ81=7To)7-QxNp2#AcD zycdQE!Z#5#1QUN3=U=djH%23(N+ND-z{s<0dJ-Oml2bice;3rW77UhHF%U=yoSwL2 zo3_og+-FZ7W+WLIfhXt8xfH@lg&vd24}P3eA~jQV8Kz>5>u<#YQ5u>@j720Uoe2f# zsUpbtHm`qXu=cPYO^Puts1^Qijz5p`XZX1j1ZY$qM;10pCQnKG zwT+P(kja2q$_^kQ2wA;J!Vx?6FWO`sqvS}dmH2`0j77ZLnO2-ppI?;GVDuEm=07)U zToe@uVF%(<7*Z3PMB%Ub?GO2cDGY%+9Ky zq=RVV%MEDSdBG5s?1z<{*l6Yhcg9Ocq8UL`-04&mXc|x62X_dSsCuvpO2)0b{h=M)wU<2lq=T=B==DXWceXDb2QqT>Lh0WvGo&XiVVhxRy+`!lVYarW< z@j6?q#oq}WN;=t?D|1)TGUAeO)DF&oFR5~+bc=21j&yD@ErSrsxHNf_1#0qwSa`Yc zmuGqg+EZT5R$fav(Z^~siuZ5q_GNooDbSoE9pq|b^I)n0QLT%drgT;($3{!?KA=}XElRkEM2!p4-V>+Z_{{lpPQ&-5MDw(Pt+Y*nB5 zZ16i%;T~(fzGTYx(ub?HNQ?o@3RK<=)hzNpx=d0-F1pkwS^OOeh#H8k1Jm(DYvBEs zy6*I|4^3r>KWRK26YvI+FW_C5c0}JRqZf$e->5Co7qsWVvk3>1HEcOHG+r{R$q8MP z7h{ROpe@sCEUze41{YygqQ+<+{h;MMDeRbl$rB1#oPCywbS%w%_N9aErf?TWvuX2C z=d782t!0g;Lw@f(`=3sTO3c($TZ@oqj|gVqrC&lwgqr;nKRrSnov|?(5rIy`x$j*c zTdWQv_O%?#Fda@`T2KJh6_-!u=^U>L5{{!xJo2pzEUzKnnOq}Z9Qw)3xbE*V+_xAE z;a0I!BfzctdAwCX)B7_3wj6V`tXe)C>$CcU7~Kx#ZAX1+V72s_)-hL`AMJjeCf?0h zqfmZy)WPBbe{)3rucC~T=ZiRR$TTJm)qnWfKYa5yrt@ezCmyxH>el&(agatM*}F)! zQU0y3f8txu&ZZ*fsoGS0x2cJ=VJp4>KS+vWV&_?TXzu z>T-L;ZU#vpb7O-2hxFH*zbH7tf_q%kC`IWJQo>(K-DLlRy|ZqM`rX?8cRCng=*FRk z9y$aaIs_?65$RB3=*FQNX%J9{1_eQ+MAR9&TfhcH1T0D{l!xEm`#$dbiI?!agX^Dl zoNJwHU7s^uf&F(KE?O%x3_$D7tPVf8#$hHCy@m9h7obhtyUIG?q*g4%8`vcFL#h>$ zDqk6|?z^O>#rBJZWvvO`o&s`N+R* z?3ISQVSIu*3G1KV{92I?aEp%>${zcE=~b?7-e6Hi%7+eSN!99-Ia zHQt&{s+spw@?$!T;4z=F`| z>-+bOo)TJ_wX952Fq+XIG5&%vB!^~*KhG3-3}}ud>tUlVIlg94oJ;-5^frjm!tkZ~ zvl4OMn4M*_p`}MFU2}YO%JJ(|O+s>`->QhqlQYPR8qTsbJLW$xGEO~S>V5z@(6*7M ze{wv!5-AOG-r{h0nO_j4TPOTA@lMOrU$>iX(G(|^kHJsGc50rLFnBBQ+X+DuO|lHV zjMW?x(K~h@8Bh(*#yRZ=6N-daZ*`VZG3>vD^hTgo&shRmsB*a%*d3POLJ;<#-|{uu z7G@Xr%~;fV7jhE8Lb}KBZUufM6#Ia8#_ra3l;D<8jc$Tqa%St@tM?vcM6jCYQkq+F z4RPvhDE-rl$f_d6aAQ0!UR<~m<6VvwuN?m7G?<|+5VkF0o1ykIXZqv!82N;x87d)U zsnvYYc4a0vcV+O~jCoP(jCTZy;7s9|*@{d}3R`Kl-)VhL?jYB)q`~Ec1mS8D6qBx!TfKei`$MXBoF5WcmuEJTV(MFqUbSju z4L?`_V81_iM~`8U-YGuY@n ze(7?q{Oq>@JN86uS>o30$S#-+J_TA~R($#N$giqmD!IiYvYTc(yas@sHG2fFE*gy_I2AD!iyb1J86YIqifSM7~5VWBz7n+>|v~! zbnXt(*$2VAzF>H@Sz35r7_2#>G}<^*t2|(%X(!Yd(wGkZ9WDXfsHfy+`_N*(fbkLLYA5Pkl5!@ksk& z#L7oO_Fd6_S^jUueZ)Qt`D2q(AKQ^xHm^Rb|D!P@sh6Y|r&so;{itl+7RFI5jf(4)#*Q`(HCA+!3YvPWl)WpCaHl#6~b zGHGIF8v4xUa(3vVL%?L{lG8Q%uw~clreV)LZ)b%|`9fi0Bcy4Z~9?P&3 zFvRo3t@X_%Lq8sQ3hcIUY?A|c1#C4n?C`1&_Aw#Td0!zb&X)<_9#GMP+CtvSi4h{7l*x}A^xW{X&(f*GaOeg$EEAfs3+C2)X%?UyQQM0;NAHZXEqNIGy(Zr^l^ zHNPJqEDG;#>SAU(dEW~Y&kp8-enaeFeA80TxxcY{1C->NPlfWIfCDduC0ZjBGGw=S_`QR`tjA#x1+~gid~rOF ze8d`ywpQZxq;@o)@(LUhlWudq5AeolfijW$??Yx(avy4ksTcC?D}wb>y{k2CeQSjL zl9`J%1U2n)Eli$72+5@F%BxrLiYf+z+dfgT3chX{Ujz-*!(2%B@e2fp8>y$l@;T2* zx%l3J6(&Zk-g@_T;2$09Oa>NbPY}gYM4Opd8!$gK@^bZnnGPf35d{1s26d= zuUq6g!c`6Ua*!-;%bse9SgGyYc&O~F0XYnSYr?jrcInp0=m_&#fP-%z4M32=Ar->X zs|FEiAR*-&b@V^w5Y?>+q8>B)l=CR|`c{HOiJ~x@qVsWqYJ9;HU|j;DDp|3DQQ~FUIqUB2Zy7ukjH@Yu z{T0I^7{Y2Ty)gT%<1dAfaulB-_xXsdqcHCFZl_#@?aA)034b-v!=x$js$|MbqH6{z(Tmy5}Q7^wBTg-Ex=B zw7>sH;X%b`-^Di0wEW1h_8KJzVUFC#sl{O(O|NgzZ_mG29_!REzj*=;oSq$_X4kHzFLldebXQZ0zik8%p$wi%~} zhdmu;Wc~DY)0!2vN6c=~RmUv$xDPS-2j4jNd2aXosnjbdh9gs^;-+==ihe6cn0SMG zDtK{gnWj-UbnC5DF&8<0RQY8^*-c&@)oH!6TfrvBw`=cfgiq1pllz8vDT{6|>1P>H z5!5nnMK+zQJ*?hCH!GxGQw&R7I9&XbqC;@mfl^Wa{PmdMiU3 zHY{a0i5}6TD&2VPscpm8MOk8Lb6a94xl(>7LU7`Dr^b+LItg1F&Q)I@v!XV~Y2Tl8 zO&AOUA(9X0M3-&-!11eIu0Z17BY=2%@j9?4Ff8}$K`&=bD}x`2*41UNE`=Ha$(rh8 zekk>*m!{E8d8WF%_9m(iI5X6hhDU;PKH2iUIt|78a2AJu#TAwpsJax;=GlBV*mlBh z)>SEu#{L>ObJxmfmv#Q}N=^vAk>78T^0d`oEpk8`9SU#>``;lnH**Mf?VIdZC3Mgy60 zz~T3;QnZao{pdeCZ+O;9G4I_Iu2)o+a~iU#`0of@ZktI_iGXp1hnNgK@m@cEXvLpl z_KuNvpp0jvt-SCwc*x}-%m|k7CF3&H3*5+%o6d>U&Lv&RNjR@DzzKo?L89fWXe~^E z(zP*nEXEn0rExF_pPPnM48b!31jN4A#PQbr(9K#O2l+;1^!DYU|Fpfr?{ckdu5@3O z`nJ4mE%+O~aR#Bm13o_kPmGg0!*G1F6XqBg8DwkZm85U3B5MW`ql*MU z21&!rYD~<<^5&$2Xe;5E7g*@wH()yV>xg55z4%(c%fHlJ4H`C?OPH^XN8ZsVvGZ!Ikcy53wMze4ZiSVP z-*(e~$XCqq4q>uNH{HQ6l zr9fMlv=39E$KKsM#jOTeB=wJ!qSZvG9im>fgsjoIEinT_p+D7mx#-z zB(EZXT|(8U*_>S9ioB3yA)+6TQ?i^&6oClj=Fo~SQp|;{VHq2|bmlfwVHw!$a#x8?H-s<>%gl%BnP)1Y z3`N@{s6^CV*OJp!`XBnug+wx}@i$^-OYDow&Y7dOcg_~h@HsDQ++Vg7BFGnvpo8wq z&gBvg@_DpHI1ZH44?Ov6b<*@&<;Gjt==$9+!ZNNAJrhZgHRe;=gW#V*Qdd=xOYddr z^9jeFML2F75Hvj?q+ruT9Q`KKt+pscev)&(%E0By|i6>8C~ZhQRik{ z=W(UZK*7oDPMx=h-F8=9z_y{nB3zpiPP$Uhf5BHgPt!4~J~m3t?L&RMgP#359lJzB zs)HszPjkrAIc>WktRS@YTSGyVs^#;B!i+|rexiR_nUiaoscqji}PyF$|C(|gN z4##JyyoV(2eCP%4 z^mqpnqpj5Vy{8kalr8%Zc8;9$G%2CQfq9_J38fMsjDU#Gz&g~xuWS&G${B~9u=UPB z)m6Yjuoy}JAe%m`_GkAj)Niu8P%J9E@_4on{aHFq(Vig^MMWDAZw#uHTkQk`+J1gJ z&X0X+LPY8(NtfioO>cpUIdH{W9r?cEE59swvJB|%_;^ma1_aBgmU)6t$hXH2=dY}f zX(Y@Glj?nl)GNs>%_8B5{kq)QF%J*r;vAcfECfeNDVQ@U$8DA*N0c^h=dl$q^R_{q z+4mJAmE6T^-&*4?$MT&ga5jV^MEcA-w)E7^1s4}F`ZT#fZA8AYZDfz;J$!Ci0s2CX zNm6Urv6Z@Ii7vhy{G~cji>pTsSG?%}<`xLFW3q@!!d8x#a4&T+Cgi+@a*SIb+T}7s z&4sf{LgdB03T)2$nFrXu6jtrP2H+r8@p-X^aA9HY?G^AT1&G>^?;R$HHMw<{XmZv{ zOBhEtX!N8vsV(_CNmY)`pCX&hQ_gn->Is;|=sXOCH3X`h6NT#^Gs}^yAGJskfEUz| zlrEUL7NmxBdAiRO+YAU{0UM^R~n`D5u_C`%yAt7eLfa-<*o$- zrC!_|Q8|Jpm76^Iov}TGfqrNm&UG6JVV2UuzZubhe#dNF>@F=g!yn;?6aaBhf7Zvg zV=E~qB3LFF*_LFRR2Jvi{q(>MvrS@=tkFphy2M+AV(BQT**SSh1VNFhx)MoRSEcqO z5EBwOFjQ406pcy)9)*do8fXcm(Wf!f5BJUqbh*(2G$bFJ?mRB#C|J%s?`?xgAos^U z>Ze2Y;uN~Y$o<+sWWN(zM}^W|?++~eM3xCXwv4eo&gcQMt_(iAi)aJ{V@C#om`h*3 zrAiQoI*4@VXZ>23=%3Xf6bSK=>5tfZ9|iYzv#O~^#wesUr1W)l7TAE7$OEz~eJ(sg zzbPD&F}Xn-oZv%nepdOp`(il+)sieQUTDC<&Pb6o@n@}VI=u2UlA|uhHV>VB_E8*b zrKVCEo3xGTx5Giryi`jVs0#zq#>Vt8Wu$UsbZE_#BQ1%{B~3^1XK|zx!)`PtI=k}{ zD`yOLYl0g#$-l`NYd86km3|=&j1Wgzu8$;re5}qp^+gI5(_ZD zZ@+B1ke73Ls(RA_*tz|S9D)KlCPFtdC~Ft_T$$tbe?hOiMc;? zxn+^^Y9{rS3J=%YeTC2DSBrHl2Y>D#PyD&?_Qr*hy_cP}kxz{8!g3U%{8mu+R_LZy z=)bHmGORNBpOCrVGW)NJTD07WSmlvnp3tnH8(bA&NNcR^TmZWdpM3-(B9xQp$Ok`A)o~>>cyH7n%&~S~BZ8=hyZ8*H4aljqa@r1p>4A(lM~L zKVa9o+4X6UHKl}X`ZeVXwc)Qrx#!~*oX;q+p4S`UzURmi@rDUy#c#!qrngim>R6L4 z@Rf|qv%yu-{pMwim!6V2lip_J%${Q8d&_B$vYM^xbFyV<;bTnVx0>GFFkNX;T;m9F zk__YDNa40PHAhOh%I$oCc}R8hoC4pmn^ApQ#irFsI2{?@^q0|VeA~Eg+v#KTNDrb1 zh_hXL(6%bBnah)v(iGs_ewt3tOwzZvU*mlF6=j7_gj~qQz)lKPBUz|Jl}YO7J9m@< zhu4hTtsv8w^#D0q$m|!7?oHw&9FFeyybV^<#j`(@AT@t4PPyQ02NUh%WAqNc*9&Q9 zT^_GPl#ln0+?Nb*N>0+vTVG$$e|~obwk+emZh<(>zI(2fu=Dqd13@+FV@K4*q;o7# zgCoG;lX|ks%FN6oi>((+;TVbX{;9&Yi_guumk=|5v&+i4mzPo*XNhleA<=2ix^!SW z#roG>kzb12I!0OB9tFOkmh`Ae9`~={B(I5xy9Y2y#jEV#3ASf*NZIk^GSR+$eSyFn z(|bZMLxdytJW=qVSj1$cp$snYQD^W2FplqZkHR=3bLv}$x(sG8atFL=tiBq175ZM0>5LiNHGR|an~vq|!yW;CDPFmmT-jS|s?tHhGvSO5 zpst3qi0J6Na-3f7z2cPU<&oT9zX42u0xRdIv#S(#&atjdJC8HLNZR-Gg$T$I44$V7 zv-H^V$(8jYnOR@i;hfz%_DSmg{q!9UNPox437aJ*TQ!|?q2^|#8L{NHN!AT*F=-I7 z41(f~j`HObEKTu52@WWq3zIos7fy?(I18{Q^*L9@h0x~+2=AK9zRB&Uu7of%_gK6= zWkpNS!VM(Q>ly>v5T<^&g2c6(kk<)sA;w_WcClQD`Wu4#II|B` z3%DSpy_>8?G>CP~`#Jw3`xj;R^W&j(+%wF2>tt4TU9e&r+lSjFTq+w);#i|3ZBK3$ zr{I+p8X3WG_gK{(IyO#*z_?2-fSg;JOb`8OLV?ut%vv!=xvJ~B#xhLlHA?w~f}-i{ zLjHTq_Zu0LOvNs5+8a#|T6-!PsLp*bG|Rt8cHU*(9xo$Y6bfJx>h!*WylK({a)7hl z(f1AHAb1~U!BwHvPrj*Af#LXo9~*u01(eoarld~-jupD6`V_QF;{^qmR~~0!`20}WIA{=tJG$u+4(mYAk61Ze8vp}T;;oYqAK zj1mr!g$hYXWJvJgy1~;`y9AtDu@729LsZ4V;Mg>Gp(HlZ)uFRV-f9^|m;1yL!jEFP z#;tDpCd?EuQoBch3QDf1z6!L zmhV>tcBm!03w9lSePZmaPP&Zv$dtnWQ+YzRT+=aY>0!C!S<@+>KO(rHgu2bOCD6f0(Ha9+XBE(D#wtMTnM z{)kC`VE5M_m6h{`Tt6x{nc`H};_fO2uDNlv&Q+onU#Pw>x)`UnapUTfpBv!gZ*Ki^(Xn`kD!eR}rzPd#I@tIda}Am&!`(A|CmZA&SLd}}l*}YXW=B zr9$=y8hub}Vp=8)y!oESl;4^psv&^3YUpEYYfYAk5x}_N`?=;?Q{>0=orkb?&j#2o=T^aKfO-U{OLU`gD@h{IE28%%FOlOa<~ZN?Gur(CJi zLT2`7Mx|Ygm0bZm)fFfSnR9YPzITi>N|sw;K6~5HoyD~g*hh$yPm=`5{d22nOn0>d$ct6ECZ7CM_<5Zl}fAK>X_&JGJ1IyKN&c&bWVTv$J6Uq4!H_z-5o`;va!r`80e;CXvPcWb-#nM zxZejkyCmYc_jERwlLD)dOa#~9d!m|f-pTbz#wsL3c;HCe7&UpjZw6(R>y6drqfDGZ32xYa~5Q+%^V_p8zBthdE8rrr*TuF*asbSCk*2kp-uY5m&i5%$JKAdI+e zU*H8<;cI}7v1B|JOJH4H?xAFxj5zjfZ4xpXDv|{s<*}SH+F`5Q>|@M-fWD$KR1Uo$ z1XoC-x7*p2`F)41Jnol!`5_10YuBW%$Ah>d;@&-LU`)7@Ks5HX5R;Qj%nl4bY$FM^p) za<0myH^IChh7lPK!&0`LjSi;Da+`16N%&*2F14wz$u1nUWmOQ5{Rrq%M&aVMwD22u zg@SnKgWd@?MNFHT2Sv44kA4c8R9OtyhABp z>-y_BrO6xP6qbC@n#9AdTfVOB?CM-xVTB(};NsuZm|I=LF9lW7`m~DAJwEq&aoBLn zLPD0c)%B(4OPCXL8voeuclThCXi7%I6UPkG8nkfw0Vlp-*QGvG+N5zgX4Ay}k_Xf_ zOXY(^!KqHij~t2*kV=21pTgQ+={0>Y&h0LSI49-Wa6JmeeX4$fWantz;<#{)_}ty# z20u@-fGVZWe!TUedCJ3=6&4kE@SX>wPJ8}?S399E`Xx3JSq?nhU=Iv|kV>iYJ;8$E z)rTzko@uKy&2D#f;+NO;JM;%A&LK(JF6?u4Ct;W34AA<@{c5|+2tP9Eh9JP9~SNv3CL!ss8Wbl*yTgMuk zI%eGs*9BB!I5f94muE~UqTlYBRni>){(3(D->>(de1CGRxsxb+w94}I z$I#g`dj%~=?-ic@oVs@Apj!6N77tI!mD8Hv9!&QMVJab9{n3B;Pp1u!?W;by9Bc$&-Vme?sM6qbI$ zI-Y`rl#0U~RFj3z*(C%MKB)CcxHH&`21j8{Az}cO5lfevMt_|wK_^BJh0BhYwA zQRelu%O#>HWptGUjIIb^beGQYIPD#aq>mLtGz6RFVWX-I=`rD6=1};iRs1}L z8w?^Q_Hz)W41-_dT!jv|d1rVE;g(C=1J!deTNgdn5uN%mQ69dvdmk!Mqv#m=}GNaR6_5?o6# zsoF}HiSucwqR{Uy;q)U9#&HEcMJ`}ZIczohbD?&|nP(TOa8PR*W>X_FN_h1=k18xWBH#q!{xZiDw|zgHDbK6+VHzHz zv7(1QtPNaCeR8RKp7gxInOJ`LXwfn%`#$k{Sm<2-&9e-Rjq9MMz8zuqml0fgsiJk> zJhwy+J;y+<0;LVY-i|hxLo#_KN{)SR(^foc5{BO8NLi!91nXGgX?BM2ABPKhxzGq# zQ8yquIlpmi(1Q!=Sn157>yf&Gk}g)`kuIMp(GE7@qO4`={LKV6CvO%z23UNF{uH*w z)Nj;?fPU~oPt4`dQ0Aw+{Y<&s60d_W-USw%r<9mWD)XTMg1N=}1!%#zGoOlZS z!QXCguyM{2d@OF!ha2bTZK6TpGt21IR);m)ee}U2Nw!)}Jmzd=A+w9%mdudGxeI*u zwZcBI$1WVo+(n0S>Zz1xep$&cD#`kmYy*Y-S>^{T%-~$6{fVqQ`P8mPSSuU9a;swE z`$PA8KiN;)HbIiGX^cE_ZJkt_8u&IwP<3WS+z_m%5ah$nJ`%iMETXT z?XgT{&1a*(FV+Xy#xgp79jvXbY!n_Cm~o>zF*MMpF?bNzdRPZbrx1 zv>^#D3#+S6uC(iFSWyyO0IL@XOY$GZFV%qxH~9KY3U!sEV?RX#sd7O<+3^`+A#EER<=Fk5SFR^0nUT9tY(xdXHS)q2}IXhoBF4ESoi zL_{f%;Q3uYe6CQrxg2o-U1CZ0cp=>t@8jVoB!3U8A9GT790xFVp*Dy(kh61|O4fu@B?jFYD z6dXYyGH#n3T%ngk`XzcS$>r~!Om=hanFPx&;_w)P93q!5fxe;@t%s{$x%;)THl0xU3Q zwgl%1Y9oBdd|jdwH(iXNo4Uk>Tz!FpGW@{X;a0|OKpAsCwhm+eZc+cb+K%{E7r%PM{o$*nht|Mq<2k zA_}TF%GZ@g%9u8`h+ISI1PETp^WX#uU6zi;2$RONSt%~XZAPDL&RMs?dY<(BF#1Y1 zhLrI#q!68n#bA33L75~=@Vb@Ta>r?s+sFngNJT@U!Pe?-*S8yqI;w36{ZEgDnOQ%A z@K$jatRwTp7a3lfOm9wNT?aqjj5nBbRbWfjplI{CqntAp%VO+-!nT9+mp{7)KI*z< z_B5^;ET}tqrorMh8Mf9zH4tx?yKqS|WhD6gu?)We>jL+vpSw1AsunnJu#K;-a1}dH zWKp(i^q6;7u&!H{z(^$wh5DvzyF|ZvD0VYeanbwT)%#4Xm;LBp=^HzG-vsPb>L>OJ zt+<&G7j$DAnFT~r4!XcnF!>7(fOQ>qHq=3%6XNl}uCq#hLyO4oOMkHN)?A~`?Oi#k zt(Z+6_M_w7#Yo=VH~fV$4Sk+4VxqCQySu)FV5m&N5(7 zX@d#QNu>|b=qlBO+01(E-Uz5&)tvQM$BBK03@QGhX4IIUPkY}cMtlHvsYwgF0LkkR zOcefka3!tN;Jy$Y^z|LeYD6?;*y3QLHR?|{*=-ZZGW1G zkUp5v+|7D(y;XRS0PJ&-(N?3X#V~t=KJUGZ^;OmA1g^5{y$dJ#P0oA%ToFB<6W+S z%+K{gTmPT$OTF&>+^7iu^78{FRp!_6W=)CzudRmqd%w1uy1xAS_-_eb@;|v@{qS!I z{{5ATko`x31CDk_#9EH_CRD!uBf+wN4i;_6i$JY9(w($837`idwU3rM!&B z9bW7ecTd#HgpZCp{n{&`V(JK~MiZ{w`=ui$b@C;n6Q1h(H)gWx6zh#9uQ=?NEl<=b zca2W^N9~udVCq$eji!Ri_A53`>eZJ&Ha>neU_1aliI& z1N03IcM&=oVD&2TuV;+$;IR~ehli8?;0#?dz#8W_*z zmmSpLO&g7E$L5NA4r&S6jV6KOSwhP+b%kypY{+&>89KiH;ojEyNP&fGTp+jzelTE& z48YRfF?+fK{Ki`3w^Bt4HkaULO}}`4HYC!^O2W+tB@$a5yqZoE@3O~SSyW;TZ9cS+ z)CrMTs#q*Sgx?&v`1*y-W9}yR;MX7Rvw>x5+qVV-O-VC2jyxoT1E5%{8zX_NAxycS z{khLWE|US~Yy*&YK6#yB)mj%jS|mq4I{J`b@U|DDQ>C}BnhGr6=~ZsJ!`H+dba%0D zuDDgC+|Q@k9QI_fy*owk3JUBDOtVwy^4O@sl2^$fVY0(9bVf=XS!WO8BuWo87I}>e z+#A??)+*91K9zOVp{MM-2U)KvX0_aIAI{!M?MR$H8hdlN9sXn+w&V2u;@g#>rKW2z z(lyvcM+n_=%dwtx{8sr2`ukz;@#B`Ok=ssxnuMWe!rWue|44rX$eg|XD9Dk%sbKQrP1G}@zc5G z?rZFwBl=T8SCggI#iF?$J{8%^Z9ZDTUbF63rR=y&pSEj_Qavleq~*>Rx^@~#?ScMk z^3aO5|KqC#C_70f%e{IdYhX8~RV(6MY8$De10vOi{3WfW_y#y#Zs zyT*Mx+frZOd8KP+KU(%g(f*t9HYU0U-p$v>Aph7?QK$yg-HCL9S61_X4DA)kmGOeN zrqnS2+_6s}+{%(<{>hG^(6rqZ1($iAbZi0bdA$C=LYuP_~q252V zd$4(3?03WrB~GzU`m-PM0rwnAS+O`ECV<|6=jCe|$=YPeE#uqI7rm%WaTp3o==jB> zpu;bQQ0EiF4QOxXB+@*E0zqWZRQyz{2lwd$YsIjk&2L>T)01!FmUw8NY}85(Cb#^p z)K?PEbT?T@e>GNl{^Wg_d{yGn9>ecf?|C|UIA(>uoo zwd1bDuA5&8EXx~mx4USoU-qn1xnChmLZ|I>{2HRmR&Y}>&%Ed^hg|IG)G?q9%Kcs* zQLh2Hc=@^&^!tK@T?c&YtakwqNnR4+dX2V zK1BcRviS^;MS92o{vK?}5znR?&t(@JKniB}wxTRSBdudILWLwaBc(T$9(u^jc?T%( zCkVJDRVGl*VGwryQ`>IV6 zeeWFO{mYW=osStbINLv4DeEDu0R=s5B?RxstEk$ZL{3DYk2WP%sPnuaMx3+O%!*)= z@ZC4ZY@BwpjDt}a=SUFn)x9}Rte&;7fXI^+3tLpFyMJ8-p&4+5 zC3;5)+?YXYCb2KCHh_!nVt*Zdd=V`0!c`X>g#d*^sH^E#OXJI)S&aLf71|I+gZ^*qP)mFTD%Ba{OsyMfgSc@ZiO$^`KKW zs;eK$xEHa7)RJz}q%# z{bn7$QU^k}8p+>Yz9Lf~{5X#+{aiWaa`B~Sa%o=Vm_8=ew4s%FGAlC!Vtq#)!*GT^6hjw`b;16V#gOJRVd{t14VVWNMpM0nfmF#8BR=E z2k%ijLL6B*Aq+Okh>=EH$axAEBS1@7GztVrf4_Q9ur@~v@FhSa-bM$#~53B88~@!Evy_U3tA zlb5~Ozo+i6^q-_2fMSUJI2`qzD(r^7^?l|P+T#ir-MlF0dZ)x|Jt6)mRY7_Q>)_Tl z@=NsMnKNa;vU*7)73>R@(TX5oX|JJVaJ%AnW&6n{6y}JOR!;@h3ccb`!WxT2tUF3jY|lw<{~Gb;JLK>l;QOjBaUWpZveJ@M;E8?(;p z{iRdR+-tDZC_<;XNXRYPD)Bl)M|E<_*V=b;{vRkXBE*I;Vt13XL;|@e&<#0 zPr0x6$xkQ#oMAf|7Ml~Nr3gfc>8lvOe}+ZBe(qDzTWJe+UVb6Sf61@>f0u6TKR(Zi z{JH}bc%b=zl z=LYr=_jW;ALz9sV)zZPzVG+q**zA}dwR9jE4x%>)krL5aa!BNm1seLeoIV0}Td7Kc z@oD{Qcxs~w5GzmfVqhd&BN<8I(r}22U4Rz=M@ga=WZ;ZwVj^}3sAq@u%yO0v^Uz5p zI`R&%!>1mp{=x}+6Yfx-Xrv;gy6cI_21q*2;|461Oa&11Lm(6mFukQUqRyD?cMz@Ef+aCHYN8CB-8Ah$)tBh8Dgt9L|7AZ5NQhj8KA>ITCYXUC70XKXP|vC+|y-#A(A_kwTqbJg{`k1O~cDTk-U+{^X5Iry3h^6T??rtjZ<%?}}G z&t=8vjp47qujd!Z!W7@|g1?~h9Yfz(2bLQ~8M>o^e^oTjVv}r(2!zHJAD|HPdois1F78sR$JfQK~Arx-67ua zPrJjoRH?lY;S%4yQG9*V-k4O^r@e8)u+;vE&*Qs4srrx4qw)RI|Mz+RUp~)DYcCs= zl}N>$QiOCA7b!5DWUGj-uT1He_Ayiuk~R08k=7&N6Y716e3lE#8JITD1|e1CqYK)b ztaD{@CRs;u4dVs$3B}R5XhQ`t8OsAAV2gCika(uMc==OhGwCX?Tv==y`dDSl0#n@< zo^$mZ>M`~(Wt)AnLjwQFruvT|{@*>$|7M7ve1vZP#}HS4h2%7cngb&=7?FrHpoE}x zurYme=vlHNCRe*})m+LVmG&G;V!cH-@-E6%1DA!L*dd+afiVVPV_TtEMBOf7)6A(9-%q|X+>G3SkN?RTyo1A+-qtsBO(0Y6BF8F@r!)eJ47C7!qAcf0F z7Mc1&Yyaa5U14fZ=oKZp>cG?LYCIuhHIx2}E;0t3VOT&o!>{%&ukjI4?HK^ ziw`Zex)!l11>xS8Lqad}4qUPdcUSLvSsD5HB7qqSSJb*08cGbJdM9<)aJ+QwZCaSa z16ayU{Dab>Vh~7}K=kXq#6$(BV~2(X`h0XOLop^1hN7Qu*5pW?&jYWWa@_R*8$DB9 zvUPWUuVbZ3VcN8nU~tr}aEPdAW%j321?Eo&O<13Ta*cuFBL}4Z7kBUd)nxmAi9RWW zG$8aMU?@U>0HKIf4TwlpKtMo1nsg9pf}(~hy-DxAgH-7nkX}RY8W52#V#UVrzMtvd2WL+ayYta^Vf2v%1e$ zCxFi>8PEnf1$YKa!rP6I(C3MCcWF|j(+Lbd1TY2mC{Mgmq8S6FXYdn-+dw@;VUR}# zjw`2R0EZk9gE7#&#+bvBL}|WCefKP^iCA~FmJS@HC~whp=cL0j@I31(l{=7d@B7EkG;@10gf*cG3(HDEk;-le$R}$8QXRQrAhf5HmhFmP`#|WTS<8T59-AB4|YR z4-^XS=(DH=_{e<`NTCS^#d0a)xHWlS^vFJ8ZhriC(8|TV1Mg=M=X;FzJfp<}r6^ z4ePI-#j$+34FLJ))**y%-+OQ?+)WFhKTf_gHkI776P{Pjt1*B;L>s-!uq3{K=<)jnBmWE=43-L^|TTsJYi^tJ81Pn0{3+V3@w*%!m+;k zeU7kV7Mx8oa03x4Oa@~7*1!x#^524sLsGwnKB49A4u7KJuey!A%WabrlhJ=Dg3R@N z&{tXgA}ZLowsdEwy6HtbqNZW4a<{hgnId^HduJ?fJKRK=Je}Dh~%O0fvS|RgTz$+VUni!81TxnJt-rcrLm)`#3Ag5V!I;Pk z2yC-jL>wsn)&rIyEk7~USC^_yI3V&w`eZX{GZy7?qCQB+6A2C8f}RWgAMpO71m+a) z@&Dp|{{M^jzSc^#s*}n;9Br+p(t@qgj{-QIiBPbo6hoNdZ38-Ljxaa|Nr3qqX1SinDwY{fKQ=$_ zh+zMA&sqt^7}0s3dQX^KlnH}ml71Dh6R#Xsgbnau$_3wo={vuSpZA`eNfWxCaCH>y zNMatczV!ZrwMXX)sE}9h9)~Ww<6l?_=gpE}q&lDFJ&l`IBpc!lD|x;ijp?v(_1mGt z`h~$F+?CKo9JDT+6lchoTqwFCgW`Fru>&sI3RLSW%Mbq<48HUzlcuP!v{Dp<=4RTd zsOlHptyV%?@76#(wNKywu;}UgN9Mae?`2cEQb$%S6mq-DnN@B%UimY;cJMFPm6Ox5 zFJ`b#sJD9wWG73#DrnI&f8~mk8~iCdI{znBmX5WEbhXJSxaUsFoHYEbFD?Z9%%*ig zt+_}0h+GGgx4v=V>K9u8^!|;J1nO)qT)$rzcP43*0{! zg`zKHK(>=<4-s_Aq`NmIB@e>^Sa0x1_bR_1$5qXcTX&FtD>9@fXGAF}~VH!e4C2@>!q$Y;dL)@cl3joezvpeN{udY<1W!i|)-7 z(NEr2xnaS>M!3jYuEALQ`t|Cye3j{fYrX&!t-bU*IMil~`|_=`4p3;^xvlN{p>V4U zNKNMQPN-_~CtfZgVRBXjhgwfAc#L1lsuEL*BUBjBcDDnb5nI3D+u_R zLLfq$P^dlx1o}Z?jj7K+#vZUGdd~Buav(~^z}E=n>pCz{R~63Ziq}$9!B>hM?4j%s zAiDIdu3bO*U0?UZ<)rJV+gCZ|6cRB8!;_3ywsY3SeJJ17kP|XSlSGI*%ae&10-Fwm z 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

    0#2Q4;qX<0r%nX<;KnQ5EI z6JDJE#?2h$@>-#__==Jh=-_eNrZ$@)HmOiqQH?W{=k`-^HzN%t{Q%*44f}V7@iVpG zUToFpf~4D!&$;icO3R@z$K@6wA=mizTxdeEJh<68c|OKcHb%%w)HB#Xrr!5|e+ma% z{qMOJl`{$wp3WHs%qv+xLFi-|JQKDq)c`(!-8C>t#5)$bSnux4r(Dyl=BAp;R~0E7 z+;N{_qJtFURl}0wwocr5?9oxmWI!wVh^hTP7emrAz}7qU9{TqjMd*70y!$81d-T;X zrokT0R&NRqUp1Y3fq?$GQ=y5J|EL>S%8z{pqbFqMp7&5{UGAVW%y;gl$M)0<$Q$ur zFC(tSZCF16jnc+^o3BA=da!*v`lQYwx50ZhM;Al!@!KljFj0M4YVnrjIi zX-|VkkxU^NMBB4LL7#S4P3OPrSNBH8veQ2}M_-I4sB$|?{yHs|1`VP4F+MtpHRf

    wQN3vbt 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 A-T(jq literal 0 HcmV?d00001 diff --git a/docs/authorization-and-authentication/docs.config.json b/docs/authorization-and-authentication/docs.config.json new file mode 100644 index 000000000000..ec88e86c18d7 --- /dev/null +++ b/docs/authorization-and-authentication/docs.config.json @@ -0,0 +1,8 @@ +{ + "spec":{ + "id":"authorization-and-authentication", + "displayName":"Authorization and Authentication", + "description":"Authorization and authentication in Kyma", + "type":"Components" + } +} diff --git a/docs/authorization-and-authentication/docs/001-overview.md b/docs/authorization-and-authentication/docs/001-overview.md new file mode 100644 index 000000000000..5274b909a130 --- /dev/null +++ b/docs/authorization-and-authentication/docs/001-overview.md @@ -0,0 +1,23 @@ +--- +title: Overview +type: Overview +--- + +The security model in Kyma uses the [Service Mesh](../../service-mesh/docs/001-overview.md) component to enforce authorization through [Kubernetes Role Based Authentication](https://kubernetes.io/docs/reference/access-authn-authz/rbac/) (RBAC) in the cluster. The identity federation is managed through [Dex](https://github.com/coreos/dex), which is an open-source, OpenID Connect identity provider. + +Dex implements a system of connectors that allow you to delegate authentication to external OpenID Connect and SAML2-compliant Identity Providers and use their user stores. See [this](./005-details-add-connector) document to learn how to enable authentication with an external Identity Provider by using a Dex connector. + +Out of the box, Kyma comes with its own static user store used by Dex to authenticate users. This solution is designed for use with local Kyma deployments as it keeps the predefined users' credentials in an easily available ConfigMap file. + +Kyma uses a group-based approach to managing authorizations. +To give users that belong to a group access to resources in Kyma, you must create: +- Role and RoleBinding - for resources in a given Namespace or Environment. +- ClusterRole and ClusterRoleBinding - for resources available in the entire cluster. + +The system creates two default roles in every Environment: +- `kyma-admin-role` - this role gives the user full access to the Environment. +- `kyma-reader-role` - this role gives the user the right to read all resources in the given Environment. + +For more details about Environments, see [this](../../kyma/docs/005-environments.md) document. + +>**NOTE:** The **Global permissions** section in the **Administration** view of the Kyma Console UI allows you to manage user-group bindings. diff --git a/docs/authorization-and-authentication/docs/003-architecture.md b/docs/authorization-and-authentication/docs/003-architecture.md new file mode 100644 index 000000000000..326b7bca3c7d --- /dev/null +++ b/docs/authorization-and-authentication/docs/003-architecture.md @@ -0,0 +1,14 @@ +--- +title: Architecture +type: Architecture +--- + +The following diagram illustrates the authorization and authentication flow in Kyma. The representation assumes the Kyma Console UI as the user's point of entry. + +![authorization-authentication-flow](./assets/001-kyma-authorization.png) + +1. The user opens the Kyma Console UI. If the Console application doesn't find a JWT token in the browser session storage, it redirects the user's browser to the Open ID Connect (OIDC) provider, Dex. +2. Dex lists all defined Identity Provider connectors to the user. The user selects the Identity Provider to authenticate with. After successful authentication, the browser is redirected back to the OIDC provider which issues a JWT token to the user. After obtaining the token, the browser is redirected back to the Console UI. The Console UI stores the token in the Session Storage and uses it for all subsequent requests. +3. The Console UI requests for a list of cluster resources in Environments from the API Server. The API Server is not accessible directly. The request is routed through the API Server Proxy - a simple Nginx reverse proxy exposed through an Istio Ingress. +4. The request arrives at the Kubernetes API Server. The Kubernetes API Server validates the JWT token it received and directs the request accordingly if the validation is successful. +>**NOTE:** The Kubernetes API Server can verify JWT tokens issued by Dex because Dex is registered as a trusted issuer through OIDC parameters during the Kyma installation. diff --git a/docs/authorization-and-authentication/docs/005-details-add-connector.md b/docs/authorization-and-authentication/docs/005-details-add-connector.md new file mode 100644 index 000000000000..a2f0bdb4b364 --- /dev/null +++ b/docs/authorization-and-authentication/docs/005-details-add-connector.md @@ -0,0 +1,61 @@ +--- +title: Add an Identity Provider to Dex +type: Details +--- + +Add external, OpenID Connect compliant authentication providers to Kyma using [Dex connectors](https://github.com/coreos/dex#connectors). Follow the instructions below to add a GitHub connector and use it to authenticate users in Kyma. + +## Prerequisites + +To add a GitHub connector to Dex, [register](https://github.com/settings/applications/new) a new OAuth application in GitHub. Set the authorization callback URL to `https://dex.kyma.local/callback`. +After you complete the registration, [request](https://help.github.com/articles/requesting-organization-approval-for-oauth-apps/) for an organization approval. + +>**NOTE:** To authenticate in Kyma using GitHub, the user must be a member of a GitHub [organization](https://help.github.com/articles/creating-a-new-organization-from-scratch/) that has at least one [team](https://help.github.com/articles/creating-a-team/). + + +## Configure Dex + +Register the GitHub Dex connector by editing [this](../../../resources/dex/templates/dex-config-map.yaml) ConfigMap file. Follow this template: + +``` + connectors: + - type: github + id: github + name: GitHub + config: + clientID: {GITHUB_CLIENT_ID} + clientSecret: {GITHUB_CLIENT_SECRET} + redirectURI: https://dex.kyma.local/callback + orgs: + - name: {GITHUB_ORGANIZATION} +``` + +This table explains the placeholders used in the template: + +|Placeholder | Description | +|---|---| +| GITHUB_CLIENT_ID | Specifies the application's client ID. | +| GITHUB_CLIENT_SECRET | Specifies the application's client Secret. | +| GITHUB_ORGANIZATION | Specifies the name of the GitHub organization. | + +## Configure authorization rules + +To bind two default roles added to every Kyma Environment, add the **bindings** section to [this](../../../resources/core/charts/cluster-users/values.yaml) file. Follow this template: + +``` +bindings: + kymaAdmin: + groups: + - "{GITHUB_ORGANIZATION}:{GITHUB_TEAM_A}" + kymaView: + groups: + - "{GITHUB_ORGANIZATION}:{GITHUB_TEAM_B}" +``` + +This table explains the placeholders used in the template: + +|Placeholder | Description | +|---|---| +| GITHUB_ORGANIZATION | Specifies the name of the GitHub organization. | +| GITHUB_TEAM_A | Specifies the name of GitHub team to bind to the `kyma-admin-role` role. | +| GITHUB_TEAM_B | Specifies the name of GitHub team to bind to the `kyma-reader-role` role. | diff --git a/docs/authorization-and-authentication/docs/assets/001-kyma-authorization.html b/docs/authorization-and-authentication/docs/assets/001-kyma-authorization.html new file mode 100644 index 000000000000..ae772a266095 --- /dev/null +++ b/docs/authorization-and-authentication/docs/assets/001-kyma-authorization.html @@ -0,0 +1,11 @@ + + + + +001-kyma-authorization + + +

    + + + \ No newline at end of file diff --git a/docs/authorization-and-authentication/docs/assets/001-kyma-authorization.png b/docs/authorization-and-authentication/docs/assets/001-kyma-authorization.png new file mode 100644 index 0000000000000000000000000000000000000000..057d24269309421fba1dfb4eb4d8417688cdf027 GIT binary patch literal 55744 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 **NOTE:** The Event Bus is based on the [NATS Streaming](https://github.com/nats-io/nats-streaming-server/releases) open source log-based streaming system for cloud-native applications, which is a brokered messaging middleware. The Event Bus provides **at-least-once** delivery guarantees. diff --git a/docs/event-bus/docs/010-details-concepts.md b/docs/event-bus/docs/010-details-concepts.md new file mode 100644 index 000000000000..1653e7f1b207 --- /dev/null +++ b/docs/event-bus/docs/010-details-concepts.md @@ -0,0 +1,20 @@ +--- +title: Basic concepts +type: Details +--- + +The following resources are involved in Event transfer and validation in Kyma: + +* **EventActivation** is a custom resource controller that the Remote Environment Broker (REB) creates. Its purpose is to define Event availability in a given Environment. + +* **NATS Streaming** is an open source, log-based streaming system that serves as a database allowing the Event Bus to store and transfer the Events on a large scale. + +* **Persistence** is a back-end storage volume for NATS Streaming that stores Events. When the Event flow fails, the Event Bus can resume the process using the Events saved in Persistence. + +* **Publish** is an internal Event Bus service that transfers the enriched Event from a given external solution to NATS Streaming. + +* **Push** is an application responsible for receiving Events from NATS Streaming in the Event Bus. Additionally, it delivers the validated Events to the lambda or the service, following the trigger from the Subscription custom resource. The Events are delivered to the lambda or the service through the Envoy proxy sidecar with mTLS enabled. + +* **Subscription** is a custom resource that the lambda or service creator defines to subscribe a given lambda or a service to particular types of Events. + +* **Sub-validator** is a Kubernetes deployment. It updates the status of the Subscription custom resource with the EventActivation status. Depending on the status, `push` starts or stops delivering Events to the lambda or the service webhook. diff --git a/docs/event-bus/docs/011-details-event-flow-requirements.md b/docs/event-bus/docs/011-details-event-flow-requirements.md new file mode 100644 index 000000000000..d90a9affe8b2 --- /dev/null +++ b/docs/event-bus/docs/011-details-event-flow-requirements.md @@ -0,0 +1,45 @@ +--- +title: Event flow requirements +type: Details +--- + +The Event Bus enables a successful flow of the Events in Kyma when: + +- The [EventActivation](#activate-events) is in place. +- You create a [Subscription](#consume-events) Kubernetes custom resource and register the webhook for the lambda or a service to consume the Events. +- The Events are [published](#event-publishing). + +## Details + +See the following subsections for details on each requirement. + +### Activate Events + +To receive Events, use EventActivation between the Environment and the Remote Environment. + +For example, if you define the lambda in the `test123` Environment and want to receive the `order-created` Event type from the `ec-qa` Remote Environment, you need to enable the EventActivation between the `test123` Environment and the `ec-qa` Remote Environment. Otherwise, the lambda cannot receive the `order-created` Event type. + +![EventActivation.png](assets/event-activation.png) + +### Consume Events + +Enable lambdas and services to consume Events in Kyma between any Environment and the Remote Environment using `push`. Deliver Events to the lambda or the service by registering a webhook for it. Create a Subscription Kubernetes custom resource to register the webhook. + +See the table for the explanation of parameters in the Subscription custom resource. + +| Parameter | Description | +|----------------|------| +| **include_subscription_name_header** | It indicates whether the lambda or the service includes the name of the Subscription when receiving an Event. | +| **include_topic_header** | It indicates whether the service or app includes the name of the topic when receiving an Event. | +| **max_inflight** | It indicates the maximum number of Events which can be delivered concurrently. The final value is the **max_inflight** number multiplied by the number of the `push` applications. | +| **push_request_timeout_ms** | It indicates the time for which the `push` waits for the response when delivering an Event to the lambda or the service. After the specified time passes, the request times out and the Event Bus retries delivering the Event. Setting the **minimum** parameter to `0` applies the default value of 1000ms. | +| **event_type** | The name of the Event type. For example, `order-created`.| +| **event_type_version** | The version of the Event type. For example, `v1`. | +| **source** | Details of the remote environment that the Event originates from. | +| **source_environment** | The environment of the Event source. For example, `ec-qa`. | +| **source_namespace** | The parameter that uniquely identifies the organization publishing the Event. | +| **source_type** | The type of the Event source. For example, `commerce`. | + +### Event publishing + +Make sure that the external solution sends Events to Kyma. diff --git a/docs/event-bus/docs/012-details-troubleshooting.md b/docs/event-bus/docs/012-details-troubleshooting.md new file mode 100644 index 000000000000..1ef6724e8ecb --- /dev/null +++ b/docs/event-bus/docs/012-details-troubleshooting.md @@ -0,0 +1,13 @@ +--- +title: Troubleshooting +type: Details +--- + +* If the lambda or the service does not receive any Events, check the following: + - Confirm that the EventActivation is in place. + - Ensure that the webhook defined for the lambda or the service is up and running. + - Make sure the Events are published. + +* If errors appear while sending Events: + - Check if the `publish` application is up and running. + - Make sure that NATS Streaming is up and running. diff --git a/docs/event-bus/docs/020-architecture-event-bus.md b/docs/event-bus/docs/020-architecture-event-bus.md new file mode 100644 index 000000000000..5481699cbfe8 --- /dev/null +++ b/docs/event-bus/docs/020-architecture-event-bus.md @@ -0,0 +1,50 @@ +--- +title: Architecture +type: Architecture +--- + +See the diagram and steps for an overview of the basic Event Bus flow: + +![Event Bus architecture](assets/event-bus-architecture.png) + +## Event flow + +1. The external solution integrated with Kyma makes a REST API call to the Application Connector to indicate that a new Event is available. + +2. The Application Connector enriches the Event with the details of its source. + +> **NOTE:** There is always one dedicated instance of the Application Connector for every instance of an external solution connected to Kyma. + +3. The Application Connector makes a REST API call to `publish` and sends the enriched Event. + +4. `publish` saves the information in the NATS Streaming database. + +5. NATS Streaming stores the Event details in the Persistence storage volume to ensure the data is not lost if the NATS Streaming crashes. + +6. If the Subscription [validation process](#event-validation) completes successfully, `push` consumes the Event from NATS Streaming. + +7. `push` delivers the Event to the lambda or the service. + +## Event validation + +The Event Bus performs Event validation before it allows Event consumption. + +### Validation details + +When you create a lambda or a service to perform a given business functionality, you also need to define which Events trigger it. Define triggers by creating the Subscription custom resource in which you register with the Event Bus to forward the Events of a particular type, such as `order-created`, to your lambda or a service. Whenever the `order-created` Event comes in, the Event Bus consumes it by saving it in NATS Streaming and Persistence, and sends it to the correct receiver specified in the Subscription definition. + +> **NOTE:** The Event Bus creates a separate Event Trigger for each Subscription. + +Before the Event Bus forwards the Event to the receiver, the sub-validator performs a security check to verify the permissions for this Event in a given Environment. It reads all new Subscription resources and refers to the EventActivation resource to check whether a particular Event type is enabled in a given Environment. If the Event is enabled for an Environment, it updates the Subscription resource with the information. Based on the information, `push` sends the Event to the lambda or the service. + +### Validation flow + +See the diagram and a step-by-step description of the Event verification process. + +![Event validation process](assets/event-validation.png) + +1. Kyma user defines a lambda or a service. +2. Kyma user creates a Subscription custom resource. +3. The sub-validator reads the new Subscription. +4. The sub-validator refers to the EventActivation resource to check if the Event in the Subscription is activated for the given Environment. +5. The sub-validator updates the Subscription resource accordingly. diff --git a/docs/event-bus/docs/030-cli-reference.md b/docs/event-bus/docs/030-cli-reference.md new file mode 100644 index 000000000000..9bfdc6932e13 --- /dev/null +++ b/docs/event-bus/docs/030-cli-reference.md @@ -0,0 +1,69 @@ +--- +title: CLI reference +type: CLI reference +--- + +## Overview + + Management of the Event Bus is based on the custom resources specifically defined for Kyma. Manage all of these resources through [kubectl](https://kubernetes.io/docs/reference/kubectl/overview/). + +## Details + +This section describes the resource names to use in the kubectl command line, the command syntax, and examples of use. + +### Resource types + +Event Bus operations use the following resources: + +| Singular name | Plural name | +| -------------------- |-------------------| +| subscription | subscriptions | + +### Syntax + +Follow the `kubectl` syntax, `kubectl {command} {type} {name} {flags}`, where: + +* {command} is any command, such as `describe`. +* {type} is a resource type, such as `clusterserviceclass`. +* {name} is the name of a given resource type. Use {name} to make the command return the details of a given resource. +* {flags} specifies the scope of the information. For example, use flags to define the Namespace from which to get the information. + +### Examples + +The following examples show how to create new Subscriptions, list them, and obtain detailed information on their statuses. + +* Create a new Subscription directly from the terminal: + +``` + cat < + + + +event-activation.html + + + +
    + + + diff --git a/docs/event-bus/docs/assets/event-activation.png b/docs/event-bus/docs/assets/event-activation.png new file mode 100644 index 0000000000000000000000000000000000000000..b462d6b863fdd56e7bfa0a0f9df26dcd16f3dd02 GIT binary patch literal 62833 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ξ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 Yj*XEbZDa8rGWhSPjv=m4%P#!?0eJHojsO4v literal 0 HcmV?d00001 diff --git a/docs/event-bus/docs/assets/event-bus-architecture.html b/docs/event-bus/docs/assets/event-bus-architecture.html new file mode 100644 index 000000000000..9f1f279ae0d4 --- /dev/null +++ b/docs/event-bus/docs/assets/event-bus-architecture.html @@ -0,0 +1,12 @@ + + + + +event-bus-architecture.html + + + +
    + + + diff --git a/docs/event-bus/docs/assets/event-bus-architecture.png b/docs/event-bus/docs/assets/event-bus-architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..a0367bf145708261252284833c7a210c250333df GIT binary patch literal 76963 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
    + + + diff --git a/docs/event-bus/docs/assets/event-validation.png b/docs/event-bus/docs/assets/event-validation.png new file mode 100755 index 0000000000000000000000000000000000000000..fce7304b014735c57a671d0e03c39b06da2136c0 GIT binary patch literal 29126 zcmeFZWms0**DekSlG30^Ka_-kba$s9-5^LR-Q6HccT0D-q=1A7g0yslbT?-{xcBcp zd%y2FpZ;IYb^X2g0FTdFYtAvo9C43(tk9PVl8;cHqQJnwJd&0YQ-* zd=eTPMgapu1|uydtm2}#ordUwt9teoL;Be#9Jfb)H9x|99N?oM9zG#tMvrkZu`m(D zDtspq#FQ{|w1lECy8Mpmkfip+k=T#XY46rsykhR&^u00VWaVZ3ymxZoYrHqZedfH& zaMrRu!%Gki{sjcWOZoisr~L&y%gbKwuj*{E|NIXhcwAT%ZghBTGT49o*gOFr2XlW| zi-tjt{KroqQO`gAhW3==fMxu3u`(m=6Fbcq|Fvr5Cv+?0x=ob78TBBl z|8*zkz;;oDL)nfG1SWkKs8uKZc&~OlB%r%!--X{=86XcrX?#)HW4v9t?6k+ZL-Dte zY@Ycht$vd(C7LqnTJ$zvEBuMRgYcai{I~BhmP@OYp?!eP4$)MuLO9W8b+~%Wf0Gy` z&iT=Zi7_Xa03k~M|-7tP;-`ddN%$S->d&|<$w{Yw)6Pj4e+rrGt?U>YwC0gG`Y z90E#?klofOt+b3xbU*++txiKlQK!h?5?UYx(w}#!yvLqdKGLyG^S`v$1dyZoiK)b{PE>eUWJQaSP! zd`6vTrP}psXGPleW!Sb+QBgtjDUbd-yF$+}Gb2$En5rY7CV^=HS0=LC?)$)Q;|F=6 zN?~+WmPVyfmHAKVz62(HpVFnBznlk|3)0J8gb1c79H_xmJc5<)jiXbQSsP5F*KKl$ zBIb1}K$;DDM401pu+%^9d3D;ip5e`CXlOXn=Iu?ZQV@B0(-n#{W*RaxR-`KX^#cKg zS+PNfAB)X`5Vz-LjmKdh4c(WsLEgRI!_{wNUZ2nA+!g6m3Noj;ysjHQJY|>p8ctm7 zwL8;T&381wI@0WF|H~$e{4c9sU;^Uw?mOW5W6TloY~<^DhF7OM*f=;b*S{MYxC%8Y zvld`tlUPko~QKgX3kjL-O?A#0M!CqKFYnzL&dvpY)4A0>N# zsgTO`DL^-zh zDLY8!a{!rW(dzzU=ju-Vd_1{mQ14uedt9VF|7w->d@G&F;WwtX*!&DHtup;KMs~|_ zmCY1PJE@+TDwD8*3_<-0N$Zgu=|0b#NYI4MbbbuZmd!agF5Y}W&NP@{An6sc42#RYfk}^Lc?)%U+pSxb{Hr{|Mhf*x&8RHD$nHXi|CSAo z&I7{`zX|ezB-UUN#!L!d&Oh#&ISjZrP2;KikPyJ^J32@4n3R+Wm|Ho6Zj(xsZ^71N zW%ea6#A#YO{+u9+Gu0t9IythFr2b&JhbD~WEMz8~-#tgW{+)&X?2k{Ps|osE(ydGs zcI14_Q;tykLxKQ#39raG-3Q)nkp*xN)M#TvddX*BD5mrEvk~d|#Y%2#`}ck^q~;o> zq@|UMOvag>i~S5_G3|L~D=`BR%&iu_!)rIUwdN3B=8ugv40aphv? zo3vi6rSt_CEZj2ueF9X#LlsVlcJ8AN8AWEUp1{fIE167_-bnIU{;uDMWY`C{-1vWa zdn9V`^^O(!)B80NNx_)MaoH-num9Q_D_-6Gyys+~XvXqa0DupN3DjB8I8ylYs6x!i zT1)jq)V2(dL+Oj9FeXYqzSNTcq!=S)LgYP*!uO8OfYukdzR<9+SB0^Yq&@%T{nCIA zoFz*BeCvOU@pTXoN$8z_Jo#&xf5!{#eFYGEcqzvJ^TFT0XoCn}9mi{i_U~xrLIV%v||K48(9&ljVjs9Q%+j5=3t>y?SAO3rP zlkb5HOJ{^%|F`Axf?Mr<(D48F{;b4-4ApisU;KNy3;(}5{x+w-^z#3yI(#GX?Kt1f zMApx_$&z?q+tPw~$x=!s8{Z4gdai1*8Z?;g;XW#{y*{_SIA4xfy;#pkFzS9!RZsNz zG0k4fSvud@jJwDr0Q3wPAx0}=AekL?t_F(o@@+@K16#$#%?kCa?mgHDW6M)$K_@|i`%i; ziM!aY*WHct1&CI0NbXCaG%FxOVKE<}bOmX`m?I(QJCz->CG|`pSOU&>sSaDC(k#Zk zkw%yscy?xcAO})86LxRAy)0HKR+F!-b^NWiIg%T;Jy|IlIub=8Sv0DY^S1SJt9Ycz z(Q^NxUQ!~ z^Se8_UhUN9x}WXJjMiGu>v^ixTBv3O5{VI5C9{~kXR{oado!3?czQ`J;Gtpk`gN|| zYX9M4FlqnI**w#7PlS5L9r(>|bA*bDO-bNto7EMhE0o^1*G&&m=>L^dPX-J1qBin4 zZuQ1L*N*1*lhWLqYl-m=GV}l8-yZhi11?gVQnrLNSo`Swyz4r#`LDXtnI=bB@veQ% z&rUnh-nW;(%i}7vDvbi!tR`QAglaXz`!-7Aj8q-1_JL9)N2ru8$ed!tiBBiKC$UN*bG>a#yBeDGMkEie(|6m< z#r3ik`~gpDAu_0I)riVR^kW1>s4+cg<~8d)uWFw901;i~%#Nv|&35_gNT#qqu99TS zgTRx2DSKO#Y$cGWYZ627I#Zcwd3kwt&V5gHGg?YqYy}lzGj=j8o?e|X?j84=fkflQ zJmT0TFPrzjhoqdl^gf$>i!Wr8tx;r$U=4^||Y|C56+v=(l>QR&LkI#nC$L%J$LrF!n!A(tZkX_1_BP zGLeP@&+5r#IGuNO9PL&?sX;|eEg?J(F)>Hx%wcZw@&Y-$>p}Nplkdsw(X6HlcrQeY zmY2H2#){E(P?ed$0ty7Q8*DzkKNZ$6YST_)F&Vw(s|ohk952(QmwLqSvY#0Xa(a%p z(>Ytftg5$O;x%i*A~urU*nwO+`o(Jr6z(=sJUyv@1j18t%tI0M!nnzvwaY<{5Gt{? z$nf*ypNhqvZ!y&|9e&lledUKF^8IAG#_Q`MrF=4*^jugl?l&A7g=e2a9>~;ms@53V z9xRD(PF3f-wWX=qZ1k(^d*+^Yl4qSwTb36-P^j_as1#&kV33>hzVjqtGkbYx>e9#^ zKnqV6kn!7>@&8~`_;zmO!o7<&(WdFH5+v^1FIp02)i8o17iu3$4a-SMy`<3=d&6;K5fKk9|b?*T1*8REX&~1uhh19vgL&Cz8Xi< zxR4_ivbCm(&}hAnN7B2!Hesb4x2K86cne~)E)~KfONBp817ajhxp9WrJl7a9E{vYv z{6>8~P6dn2><`Wxfy(T3YW1ccS|TxhK2y){R_(Yf&6VFh*|}D}15M?&M&0#~(_1Uqiuw6J=vF>#yfNZ#>uD%M29 z`z3(~^v&J4?PzYY|1QvX_BGJ_@wrBYiDYx)#HkfYsUe*;y!X2F@eRjO)U>OyLk~n4 zCv7>WeeV+{_G3E_uA%S8Nk<-QVco~&Of?z_0G~nwY2urgH~J}-7LO_K{oVvLUk^m(3j5z!!QlAsIE!t?|m&B$hO51F=V z$lYCvggJs*ZvbTSrVmxI8sj1OIQyf4Mp zs2%9j-wmp9woChtkyM=Ba0_hjWQ>+%52dcN>NDhddw%L}0%_+P06nn!7lp`a*h*Xu zg|nBOD1OF`>DdcsDd8`E`X#*~sx6i6`gD?svnHUYPAWFMc0rKL*RWI0ocQ%gbI5$zKY#c=*LTxT7seO!mX{OWPR-Fkm`{K( zzuL$R_h$r`$^48Mspn*5uO4sFQERarEbN{z(+Rd{G*-ES9Bwr)v$!w2md`r5sf5SC zl{2Dmq9{Bi<@tzM}?O9!QK{QQoF zm>cJysmo_yi;C-zG`p45^|P+VUzID}ysKp!B`gLCB{HffYBu}G=s~s?i#hFu>8KxO z6`p`+aI9+B_zl6ay7?b59$VX7# zy}hz)yVkF`n&&ETB8s2_ay zJA?X^AaDSYXEG8mBRtL$&N{h0S{&Dt8%++gsXST+@K0`%HWp`H+22cEQm&-(9e|UE zFFIByG3n^B&!#mb`n61;dWXz8(eSwZ9_A)P0-0{o!YH{Qae^X#23YNS%36%`^y3%J zvqEf7o{{&2=#Vw{xoZfwm&Hi%4>{d#W3iky^5z)N$wfy#v%CKCoZ#}Ot!B;dX!Tf~ z)PwgScnlDO3DXe>QS);E?jnFlBj+RF{af7CY>B*6JN?q7hVLbR!s5=uEa4XS6`i*S zG>{&CJKufkJwpW8&k9dl(j-u^>dno#9bKXf_wcS}*8UY?%9}6D~-& zX(F(ucawaGX+F9^cDQPglxhu+!oLN3U#HmJ+J(U#YNd!&-5tvp9>$+dM02r^ypzm0 zAYcuLxv?aYtpjm*ffU5vy9U=5i@icZW&6>0wWO3SR1E&TmmRC{`P@8>8&k5&({1yY z@$qiCRht6aU48E6Kdco{Qu&&`*UyG8iw?2!AN83Ps_`lBgkISmAs|?6i*~Q{xhYj0 zbv-I%x2RWlQJB55kj!um{EpIu{G+W&sS*P8B@NXVT-ck+%MYp>$M`g)c1zRFsqfSc zyAsN|f&3&^nnUiV=maFym1$Z6^a}WZB$t!&%&?4hyQeA-SNmB7<_p~pq%Jla zm7_UR?6c(x9h;82M5_#bi)XmHG-Yb<@EwZRF9p?_s*0xKYKYGfoYgL$|EQKkci=>0 zq`^ZmQ47=lcB&U|nnxP-`tiHXheP~!Kzc?n$dd<2Xa;Al^uS>fLXql7oc-1uXgmxJ zJJ8z;S#CmPVXuFSK61; zI{0)Q?AJ}9^!hd_dZbBNVaoY3ujc9Jc^cS!*VKHqpwr2E%e|d*x#>8(9S8{i!N4_e z>%4k%E%A27BgAw?E-W;dfBdL`?{AP%VT01x{0gfB8@*^q5Zd1bO z{T>Gq%IZ#9%AZqdzp!7Q+jm_4d{!h|iYy0&i=>TrsTz%fyFuIhuA-;ut>jl@cbuy; zV=lrfTVHD78y)zS?2M6PbfOkzA{U1EG$~YgOy;(+ls~=1H`k&5j61kGaMt768OG1C z9G`x=34=>yHIgswMKE2IS-(bn!cBt^Zl~V6U}x+>&Ust+egYhRoavbWZ>{VevYTbQLOXrrF8Ur{Gw~jagOm2#tF2i8j|*{UH-A_ zT>txg!MrA2GF;d5%~|Cx?$w5VzRP>3XrX=3;$ZEq=MC3SNbu&P4s+J$C=eg|KJ$w4 zGf(&PJRAI1-#r(gT1DwOk2-=9beH1-Psuf&`kq0w;0n%8ocPGksG9ohYE0};8gd~^YXV= zRs9%cBb$@SUT$x@kt(2UZxOgpn*0?SX?zc-8tvDO2NGYf75-9{0vH$=381e3)bH|eF&0C0-=0r-@W#j)kbORK)fh%0-O#~G~~Z=$&Q zWf68rc)T0A4tRj~+Nf=xvbtYiDb_vk1iCW)WxAhZ_xoaLW6+*33n`@WQ2na6PDo5l zWJBC6(X5XBG2LLBjr~$0;rB$jtb;?%HGrW2jM2iDvNv5@T=YOG^j4+drSM=nKfUYW z%8O|R(dp_dR&yzuDJ06 z`b<33=)i~nkc?ZRRVyRk4>0f7{V2Q!7ftU4ZI{Gom9heA%!a?1j@#PWvb>w&A^?Dp z@n9-<_laygLlozni)cKfE*%st!kGjcDC{dy>Sm0B&Hfs!q@!$!XcV45d@GnFbS{U( zSC+K{dQHjzQ~||eq#N}J-Msf*3(%1$a%oqW*sCrg$XhotqoH*ET(wfI!vWTzXE9jc z!$KKUlV~XDrm`NmSL}=M=f`h(}7llbGq6qLL?Z0op9eQ9**Y=b~S}+Tu9nwsMd}3Y^(XkvRHl~WXgN@pdxFBB&GmdS7>XYh z?TqBgev2d*H3kJd-BR$W{Yo!+w#3J1&>^4+tF>;sQR4wT17NiYpu<#L2Ragj=A#8s zO{80%?3Uw_+B7c|U&KpXtQLp0^w^q#HUTRm}WdVV){p2;h$XVg}+)^L(W5wZtaf&6;b zK=Sn4iMWlZq-~EBS4H&I4=P9W3YDqiSXO1BT~|ikCaL;&vpJxlO3T>Y#mvH@yz^+} z^wb0F5Q;L!#hV!O#1lgvNPNW5KhMu+RLp+#AyTxfK`II&<3nztTmMeC@o12jO275) zuD+e!1WTaXXea;~2OjIK*ir?iAHSmpe3F1?6ZT8@(`}`Ba!fnSHvl12L#4sA23ClA8*BVfR!jmwU3FUt3#Uq3EUEOg)`-m{t(X!cwA_U2juk@Q8S z8y$RW>SY=wHMLY)vTCl@>aVlXC+xKQ)GHNmcQf`wROMD}pxIL1eNSqj}nS|f1z$xl9&^_xdt{6t8 z+ip5X=upKLhC6d1gG#}Vx1c#_rjzYqMi`?hwpQ`p`|{X4PA{mBTA7Lbn3=n4Fw2?dL7?>`vVJQWWpB*U7jzH#aCeA zj)o;k*%{~zM$apj7SYCjnmt&prjQJO7UeI+22p$FqGt-ARWV|GeoTa)5WTW_taE`U z0v-)K`|#!;q{_wE(BM$B(3X&f;JQWtu(LW|s_o}#9RdeQs|KCzEiadJrFDSYZwX8r zp=_y@XeeR2ev6||D6&7_H;l9R=(ldM_$5$AWl8g=GCh{L)sIYH3&f#OV&|Dpkay22 zK6%|-Zt)@vu}+UF%T}KM1dZTLXuEM{0Q=cp@)ROeZ3|W)#0p#vl?`*15?a^MGu))y zc)NX!n0@VtgSK;idjNwmlh0(M#{*;Pj(P%g7$Jr_ zhtZIV*jxcq5R>Vg$Xrz;)cfXSLRAK@totrq?SMQJliWu`jB>ZQyj3~t;nfVYQYR%U zjzmZi>SM-&kYDuW&C#hAt6P5JVfcN&tlQEo9I)J%nPEM*D|<$B9%Ypo_eDby!LhFH zu*cArVf=KhbD?MCx6MHya_cnzu>Fs=L3-((r z;|0*cjdtH_QI}7Cq2vWnOI0z0gQf0%uSryn24`>zRM{S!ab!JV7x+*S_;!N=6Db2U zPNcos6pa6O=;c#g1Y4ngD}LW5Eam1 zhi6&FHq+)k1E5`kR%^SpfkH;nEjD}r_S)!wSCH=|e;RCxJj9EyP?I$^S!E(ib*}U4 znDc>9hWcHb8rk*lV)+t95P-En0QNX`{vP#tEUa}$tN-x;gZHBYuaz3z9So<>t;qt~ zW%K9G*J1&|G^kDec;CK->t5y94YVym_^F-^( z9MX%UcKo8l9xntOfhdt$lW+KqcB}7`6U}jCC-CfQf-`DynI{i`UW$&HCj1KU=Xe99 zlH-F`FS#$Xh;vD~FK6@hRQIb^;@GB?pG| zOycHom|-5V+)5wcb!7LI$hFN&{%9==DtxZ})Lqc=%H_-!$lAiRKtRP;F(1;P9EN`y zKzE5)8M+6b^C8O{Bqb3tM{@GLhKAh}{Ee;Q6+-6|>M0t4S{R?RnR${Ish^%un zwhRF=VL?Sktd;D2g_N%;=@n)Y{9gb=kpo_Va(Y&<3?c#j{e5BM_qy4dHWxF9FDe0YtZ5 zVu+i8=cHM^F*f!Qy}7mTz1Ic^YMCD4@Q77Ze2HjzK1mUL)=O>|s6vNCy8TTQ7sjcv zEg41$KF7j*Q>Dy+-8KaGc})bH6q1w}9wwDfh-d~DyU#`!KeKH&9e==MJ)UjGl^+6$ ziy`(ik9+b&j~hvvV0=T&;2;mAD!bkbLcB3c%`hMsPOs;QPRE5+vgDnGcZOaYjh^qX z&oxs!!uj&FOJUqWJ6@fh`&MKvQ~YUo`tJcLfCnxCx6t$eQgLYtMQ|ZyUCTx~Jh|4n*xfc&;IoM7O?I zSqq*oF+I7uN7$S4N|!`)7TbeHQL^kXl`*Zggp;2I;Gk0q56`jZdxrbJs(6e}t2xQK za+?gdBi`zs=;(lQeK z77WEQLtP!!PMY&%n1xtVWo^qMt2w*(95|&y0U`?P&)RvT-nUlin!f#Ayz>XRtC+*o zij-UE9bQ&$PQ2KQIttR!Hk9ngQ>#5-RmQ&2Ts*CLk3m@RKEgp<3~rV}SwmAY7K8rx zvyeRV{7Kj9`5XHh$CE8p6TyJgtCoF&0CI&HHW`&q)`j9Y?GM}|F_nIx+wa#3rfiK@ico_MOvS4oPmWa-^%T~f_=!h4idlOkz`fgC^rJb0Rbe%)DB4awQw zEi4UgjvAypx|LgQJxTd|q27P!&E`NFx)+!LfOSc$=G?5Frgyu9~R zGM_YX$Tp7$3-lHHwFNUqHGf@Nl-Dsn^WUR4Yay=@;)Dg5cQ#7jdm_?Pd-qIq<|_Rm zb1bC&q!li}il|5in2-X-@eQLIbtzAFb=}EWdQpb@R|iIm|MSIvn9)AOz=>`i`@PcR z&veXx8<%?c{S}qtY53T-kPnU{uS-WOhmFVN5T_G&d#{(VNGC)f=F_6-X*B${oR5e? zzVoz)n^632V+r(`PviQiQLl2mtUjNNyVk+bpe^xBeA3{eMG`Ony~o-U?4{;ZNbo^` z7qoHRhPm|Q{YHS-*_>}{6O)uI&N#@jX%@w%sb6B#!A*Us+3MkX(u9>tLdZb%%PnFD=}Lq4_r>9m}3o&teMXPJjr}tf|@Y@ z=knOx+;exu*&k5lqo*| zkfmbd;qlN|+bJ7_Em~+(_&Itk5c^%7?InOh_E&sMTM$Cb(9-g9u5=8wUxKUkWQ8ya z{`DDiDdykw7_Qd(a z5tM#v2{p78eXlMBZoA$1!Rc{z`efehZPyk*lOVYi4*7@ZM9%;u)JDW@Czd--1_^%5 z3azgW0rOR=SgmxWl))Xf98J!G!I%*@9WCz{Z0&- z-bU2A+0V|h00DTmk}6POE(pbap!VV*xoOC|($(#fR$E%GMkX0R2{2i zlvL#D&M#TeV^{5$2Mu;gP=rI9__YymOQ^Km8mnNt?k3|Unj;`c#+CWw&4Dx*N-D~g zPvL+fYWH1pF&HEC;xCR{qe?i4Vtatgl2i73I6HV{C__KA5m0%*0U~M6uAsxlOIeqV z7IS9#%j46elVh9;I}aj3FCG2}DEu?M9)NrW>M(MDL=3f|=lCyMZ%(yCLqnwR#|;E}WP*N59R>ub00S70Ofs!pW8?{|Z5CMFiMF&Pchf zHN(cjk=x1ECZd_JJiX@|2j@!6iE2~DauK<(UQqNaTp*htsh8;}d38SVHRx%B?Re?e z*VeIsaq0^%5(RZC<&O!Qbq96j?~S^VqLb=0?S1?cKdC1W+esQ-6 z={ygCMxp?nBP_n?2r#2$0VgI`IakJy|A|zO@41K=lk@J3GTdt@;Yo}dXW+m7UP?c9Y)%He0B?tTMWhSxn6~3Wxwg0{dSR=Fs9Z5 z*-OjN z0BjL-`jbSJCj1hZy_v>W@=vg+ggOFjCMyi<=KVXRh1-LRv)`GGowpwav?yta^+izx zg91}Qya$U|w1`Ly>mj^)xKb$(rc&)Z=H-5z8YIX-J-B)b3J+kyjuAC zRKT>>1r#|bEVI>)CU$_?A*%9jr8kOhn7l1~tY95p5L%#LUFsP4EZSjThv1%`< zJ7i0Dmleg%ponfTI&rkcSWzLreIg7bI#|_PPGnoacOj?!a{cQdz6QGhrjlgMz|E*8 z!?b}ny+ARbELKIzzWtO32y9!Ks0^)UQ1*L~JYV*-(W6#+<*+p}<%nY$Bmty{xMC;i z?z`ODq^8z!LjzBGBqK;vH6Q7F-_&ATY5BgioNI2xrBjirP*=dQ+YL@LqfOx3?n2wE zkOxTMODd?>KWer#QsTr|Ea@)Ugd;+J;stcKfQ0(Ov=ep9a4Gvun>6 zq$~z=`5FG_qXr`p-FOTOWXb5{kpNV4EI=0M6i~k=K~UnY@!#MPm_RvQFv3D=>rChV z{J7ZjjkAD4eZ;L}6C#MiTp7?nLaN-m3Eyqug#*s5wF9{=Ih4{P_dig2@?+@0*I$;b zL37?~iO2sJLJ?14`1NW|_Gz!-w=a?L2!Exme{BJztrMK-4F5C5hwc|8tRRw&@&aNF zW>dg&r|V$e|40S;f8%=w$}gGQZuIT8B_~`$Vc`|h1t{WP_dorCoq~i5t8n~4d^-8+ zN87l~x-O_dZ-F|9Bp)D82Eb5nF;GV0!=iYae#B5XPFrQtFU|G#r22XZI`P<9ra z3WxMP;Slp0KD>)0VM!d-eft^BWe5MmjroOBCU;3V1@OIfc&1?qkeh;^Us&gMTh7sm ziDZrzYH=2EuisTi;CTLO9C+!!p)YD%^#XiH5Q_W2eoXjl7NNb2|H|ZI>jXtCI%Kp^ zlPWNC87#F2>3?3Tfh?T9e8%$$>zS8z;{Q7qdftzu?e_gi_Oq1_i@j=qdV39>&=BO+ z`?G@u3g1#*r*9Xik5|8R%i7(3q3Vm`Pw65N++^N`d2OJ`@!cAB$PYr!)V z?yH7=8`)bjdu6$$1_jJ=O$XHm9W9UyPCUw!G# z(>OyCB^y1>UMv%rXjk?6%nOB^hIb-t2h8kj>o|RcClUc;66E1oNzgHg1r@mYA>C&e zN8-{kfd4cdsIsHxzA|owDJ_G2jR>ngyZQr1;n=@_w%#%5_R+h$Drd?CcmUrk>|?*B zgvvV@+b5)0xiB^Mb9Jn!`@+=rzYvIyP_G}2eB#0Xqq=O!U~^ujhw~CTL~(t`lMDw6 z?mj2sMnsnvmCK7{g{L3{Tr4R7jW#;8FXuwnD+HI0{2_x))Qx}axiOY)7U@h`zPJCc zwryf$E??j#aGJ#kfwZLB4dHEgR>9Oj?yl|_@ta25HM=ZpB3a%#vAgME`kGL95ilmD zV|J0!0{D<*zCM^5m2X;B`jcK2sW3c_yqY?oPTZ)5=k)D=VdDSUAKd*<6L@NxpDi59 zY)zFmxGfLMwUcuVWz_3RBF-EWz><=ZixrG7jeqm;x3r5!qT)o=p9v!$=uF%7^4H=& zbnFo_=9|wMId8wY6WyhA@o19ze)P6Z5ahglUTdkMx`$%?XQ+l5>h&%@xFSOB8i^au z-dm4yN1GF)(JR;@STKW67BJPR0A)DcGt08T$e<0nYqCu6B)In}_wx?EKgck*Xw-s& z67F_Z+lCvS_lcA_eWd%)A2180ucdqrK%eh`0}DRDCz%CU&hU{C1911 z5_4(LX>^mXdh;;kq*-GapSl^a=O9}(0QWHMC{$3#lfcf}%VD6^g8uUZI zS<9-)_&3$&%%nMotR2l>q4)RK66T2ie5l-Pwzz=UK>{76^AQJ|XMh{}1^q*a8Mq(R zH+y+=oR@sZ9)l!cCRqby8-rT6&AiZ2BX}xUg)RS)Qgf&G0vP-m)jhzp#sL|lI$m&3 z0cvN$(mpYlnGW=UIvb5~{GeT4@d5xmguKCd&;bOWS3nZnWA?Yd~ZO^!y}k@re|e^$UOC2Nf_JcsgT6vn%JlK9S*4M}q@WXjeprzHh$(_xlKTKh;aX zc0aG?7YLM5;iBv!i(4anJH`EkPC?b=K_-yW?KMkw;ie&B5vz@ zciN!{mcDRS>5j2^^y7%zT@JKLpM%9)UL5U04-sZ8JXaxxOnu%I?f+dkxOSh`)lPwZ zw0{L#v)U#~24pEAI0cZnuB!}PSaW*r{_Wpx0hQ?}_-Xkc<27&ydQFA^9*K^0zg-5f z)Brv6=D)cB?T2f#P$`j7f*4AQ{Vp#Om7nuo2OLnU9);---23bBf$^Olt}Iq_u4Yu% z8+W&U>kDFUX8jx+(*JB&DGBXGya4APFF^)-Urc2ii9;_WgKYs@oRIBrc3Ur}>OD8H zWqCWRNwvg>7t*-vn3bcx>=Dh&GGpz(Vc$0Vhxon^otxC3bismJ2UnO78HA-wjH|HT z$)mYtKgXkKyGExWzTj7+PL$z2!)T-`bJr4F{_+36fCKu z+JX-kj{ZY+Ui*vcEFii5Z0<&45cS?sF@Y_587_-M6=PrxrzU3nY^8)42h+Cn>u$pH z%88$Er2sM8c=0IL6WbowZu9HlL#X81X~1;?i$*5UhgWdI(o?X2a~l3}5C@~r&p~Wy zrc~1Sok;ys6R$X+5aZF`8z?!@B3ehU#QhE2;M63c&mw#f(hg0779Se4&HPZiJRMN# zB?!;BAEy>d23K$i-K$CpNrO1*Q)KlUGU4`~=GLG2{_@9K?;pH$LMzXt?~WExU{W2w z5B9HtDHC)?l|sX-PcHm6=KWPj8z(p3MQhRZ(|GaQNu87eFQn_f>Lm!w6UDuL%m+|< z{t?2s?8xe`cg^&`pf|u-MAeo;~XTTKy3jWwge6ajj1Y(AHeE81+ zAffyukrFcGZLi{#*1R}u(*hpU*nUN_hxozOGhu>|iS(s}MSMuCsOh_YZvkGiouU;$ z9z~H}3qgZ&mjTh6`a6dc8WR&R$B`0I^7XMtCHHYE2+Hijr6Gih#J3&i2Tqi4t;SE#$w>7oi-`TD2jcp1?Cj=nP=?biP&q~#gHph=})Ny zCJU(=aS|6Av(E^|T0>R_u&IXz`5-^<_!9Cu{!ow)m3k4rmNuh84SfTg)_hc)`1aFRGpadT1FutK-y<|#@ z(+9kJBkln9Nwm$EeDAW|Xbz#IPcSbL*FcYqoe`OX-qP+>2xZk)wIsCojp9UArbK?e zyS*|w-(Q3>K3sP)UeRxyw2il2Pf@J+D~Ka(R!N@Kf4{h|B$YHq!qC7S1u1j6>&FN z4k8(5yk2}C1mf7hvIG2s*s#Y;{ZLS^$WpK#49GpiAYlL{cbu^Q!@?o2>jTL`_44$~ zD=0$A)V%LUNl7UM2sb8c14(p{1i-0*VrBrJ6&>@p5#@zalx}aXWPRZfi~&_exzWx< z6HK%5-JUPo0nUl*(Ra>V#S8)wx6*m!`s^cTL$A8i?y0JvnSa~V47v6ykCpxqJ$ z73Ho>v)inXn%+Alp`cnuO2nmiGI8}I0?guyV&f^1SAaDKMV$Nfp~IDWEpB}P9Eww1 z$S-fbl$i!&@&kZ%mvM;j6(-;uI@|BGGra{!KY4wCHh~~=(;Gz*O(lzu7sYF{;A3$F zeQChe?q@|XU`RGRJk0Z~3gzDS#sE*sdLwcFN&q3OAM`741PGpwy_f9IQ8iul7tbH9 zC486=gA&N{ZT$5_?(PP^IrbP%l&2`zNg z69`i?lnLp%++ev*V^+WL; zSuLsji!=!^#%m>Z;yv@Ni&|U8^CE=?-hyGGEti9~bpGhM039%?7}rk1VU_5xIU){) zz$qj?M1mo?Z(tOERq*a2;!+n($wS9dM=A_OHz&$7t`B-hx01o4d%f?j^r1tWmpEA@ zs4)AFzYYVanI400-^T(m>j1z2QO~vOo>dOs52bfVf#l;!OTPffT>{Xc{!fvaq%=lS zU`I;_{V8tLF#QU6nFINQhiFpxcIN~!*8rg}Led2|H5HiNPv*2P6C=!ehv}SHVhL9U zW(;L|bsKE%*^+>-7zwB_sPKlsBvjl(s>q98wYWJ#XWeiaG+sh+*|D!c>nof28EQN6 zfG6j5bE4h|Mh7VYJPUS41?V--15ZZmEjH^W`o9sovi1$z*RKSBU(a=D9k8>OL&uA| zanMXfyIf4{xm9C~79Vd-y*=yobp{|97>XsAsSs_50QqGyyJg<)qxtq=K!%E4{gyy4 z2w}Md{cCC9c4FJGY(**2jTE$zgaWKP{1E8uf`>>wkCmwA&;g_2EAG8FgQo{+ioprP zl{mxH1Sjf1vKOFm zvf>+L&5>%2UN>%iNv!!6C`-ivysmoti-T&H?I3vLX%-+=flm9udTbUAox8e&+Qn&7v@)}#m|cBz zw3yetqco3)7xVYBE&1XT{3!p(2kPe!MbNR7#Rhaxvn=;}0A({jph0b+_u;>Y9cQ1Ti&Z{2kSE|+V1^zesbl$<^?ukqU@=1}M=%j$Xn z7Un29UUH=NXQ#J^v@waj^;T2L9PefdsMn_1Lu$xQ&_#CR)b*my&s~Ihm6Vh$_6Q*^ zAp?yl+%^jfsUw^O5|oo}`LRGw76 z0wW#AJj(i3@%k-paOUxC@7gGTW9Z8tJ#9{h34>{*XvwF!gshOY7poLPe&#ExM{$c8 zbdYAzzyA6`IZuv26Clyp?x1_~%I_9HXlm~BWW9v!7CGAzMGRgNVt7}p6*TBrbMszT z@;hj; z>=pF+5xVX%-)57^c0{ zu;|u&o_q?CBT82Ki%e67#)*UOGq(VutTN0gtu2OtW6*F@d2aLT(imM&61K7i>ydj*|=utX3#zxk}=&~K3xNz-mUD76?X)&lvQeW|j z^WJB!4`B5nEVCBtO2b^S(#3-=o>&b`SOokc=u5~-csVcEdZR>%+2Do){UretEi+J! ztXI$DK>UQFE3K}r6gX2lqdLzkC#rVn8)CM|EP@{>*gQi~XcQAed@I^ao{Isu1m5MM z;z*8f)B8}2ILl#r?|?x0xP~K*ZG%UVj}o;m&-1c?>83;DA}i&##cwWvNZBN6?LDq6 zE|Co!dENyeFznfcAa4nkWRvCMyVC&59X*tM>b_#r@CZerSuB|vh^J8N*E!nT#R}X! zwaX<(=y;?JHi+yru3>feIWr=hQwTI_7x;(rK$t@^#l)^Zo67QD!)2jnP|RXOzSWWu zV{eA=X})jtFnFL8I;y5FrMI60P1tGsqH314l7xph?8Y z1@S=_1RqQI?=tBMt(=e=FlI6C2=jhiF=zPHJX$}fj&4JJf_CNl%h#~`gv=UR&SWnK z-un+hI5i355sH;<$&m|uLk5o~Hif)WUV6YMI-DfWQK~DAmUZLoTersz7jVAiGQJj7`QTv@C~WQUk{z^mbeg{Fi=VW#Th#Jfd5@MrCr&}dWBnDECMho; z@k4KJFiANSI)I%ArSQez2X(NiNFNF$cpeDI%0mndlcR9$@;;e#LT?;N2dz!fpobR; z7S1=M_hoNjyO2zF7^rx6yidXKGTAgW8cYFzdCl^xJbRn3*y0Hsl9e}zJH1gDEJX`wV?%n>7RlhEOTm-o9!c1;HffT z*M;dv^Y1Qa{>*3s_>de_*%<74f8NT{{=eG$@_#75|NTs3-^RXV8Dm$rD9JX)*s>Lp zEu;`CJ7vpmgrbNDkw{8pYwSBwWXqD>2w9UY>-XGKulK9>FW=7}@cBF*eyH2IZ|9u* zY|ra?UDrKF(~717oy&!Q8h|hb{|uh_R0)9*Xf4DQHzEH#-9fC!S)fGEpR&wxY0;VwEZ@` zpa_JjUi)CwPb(uby=&+yXb(gJFzd+hfd*jkGXc;!=POQq((nHNcFv19Alc(l_A@$= z_tP<1tb95$9pPi2#CU;2@T`~-?!1khcHIG}>f#E&IT6RW4^idMB)#}*v!gglA%1gW zuN2Nvdjc9m`PiqqFzJD6{e*R=rv~nt5f)A|F8Z)csN#K^M|J_B6^O?`w~WDGo_2Yt zT#gUzky=>_7oO?0%bjT`#g3-6v2X|h+IUOf*b-!RQ<`k z@rtYT`+1M*CZD$oRG2cPm73{ELZIM=$p)S$%T>vNEB=>-fRPZ1WLE%3pnm}|9xnLDWGU2tq{c}?bm$@Js*rin$M&w> zUo}D8uMn$3C4XNN8tXs^4BF0dKY=y^Ng^W@Q=>5*Coz;`sLydm*raC2bu6C^#*ioz z8a;3yv}Nx>P+ALbI9H_L??z675ddM{YH>jvOf75t-tF-o>EleZ_wYusBWoA87#Sa{ zUaNa*`lN6w=91*Z1dxD#RB&V>ORjHrZZ!=Ccb_2wBtdWt0E8_?Za)c-O%3V}nOPl5 zW&F7#aoOGh=+wSi9hdx7tf^)AP^)(3s@;^>3WhFrh26?3>-ov~iU%r*G2f;h&{hd~ zQVu%qNu$^*rsI@|t8Fjn$R`nxX(JIN&xK<;wyfe!rZ*Q*+ZN+JhP9p`k1ulpB@^*W zfWW#bDjwT@t=Eh9t{BIsX5M4B_2w80d$*s*0rg>9I^>ZUrh70Ou(x~2@ zhGMR_BbU(nmN7|Y3tif^ervk|1Pvb%6eJM&WTfFC{ptK^t_1j^>;ah5?zkc&;%xp0 z-K$rvo1=Wr!*}VVi_nm4$y02YUEG)HO`S?HJOQuq%b(HRp7*w`Y|;d~_@eqV3eYwX z%(pL8(L;S7hJXM?_tTY5oKV|*!$!v6K2A~qqPHTQDMBK?l@LmPPpd297h&j$=Cz%S zSLZt2lc}(FGXr%U?Cl5w&!mr5`H0(I1y`l3PDOy1sl>RJZgHz(X-_suz5Pwl8M7yq z(z4=o=#{K=paBh%D3Ir-5QEg!hPe(u!sj>TheoDW*Hf%34CGI!+^U%<%}tEH7GEm) zQ_0;*wJ78&W%3Wp9qLd_r?q<(5&74u<0r{6ad(YF?@%U7U+F70=RB5U{CYxcE24+%WO2>KQ5)COQ}znybKdYvNq)Rpy9V!28F z$Sk**w)qXRg(vEDuV;IB{lJd=A0WC1YBdQ4HZN@REv)FbinV$D*sogFivCW1YtQ4i zO#K{BT@y+Q@(hx5ll?uFHR%O8iHqnaxZA;?_OqA+gOo=Dj=AIU)lDey?xiIqf4bLG zUNSG5n38Glt}RD%8nOOoUYu+(~4qabQT(bcJ zms9&(xeOs9p`6nEoe%SS;lh$}R}!;r!JyJ?TEkVB3K=N~VGP}PgQOP7Gy`CV7CiA# zyaE|#jOneB%2Lfbok!x~3xl?`d&0z3bGDZE&Y#7I98{l+Qy?x7W}?SVpHVFu*){SI zfU*RjP5r>aA#w4Z#yQWi7)I_y(S2?m2Wk^H?cAX%MXf(G>%%i!IpV2EA54Zt0`2gW zG0ZzfYB-7yKltHTrb9He@>$Nssz3({kn#ixLx;1rV@No-zu~&@2*Fm^B`jUsoAWL> zm&hFqLR&AR6y#<$k29*S|4W#;ap!MgCRr@6iWM-IL;{l8hWWra6_6aR0Xb0z(87oT zmz+4Sg)iC}$^imu5j3JHRc_M}G#n?f0p6|LKG}CF0YBngj_#M--CrF7Zx$A1ugE^X zdr+n=aqw{3O9Me%!eknr-}vpn2BG};I(iCR#vc_XVfulJ$&`Iv5DDZCI#d%-+0@jO z?Pv^KDIqc9cg|v}8_q!U_3j4%XC4E$s&)W;0E<1vW^sVapf0{Lj)w?=%M}X}558H} z23{63c|BK}ZniI7d09b^{nK*3%A~g*UK+>1mW>{>~%`Lpw!ehwgtL9+GSx`cW<;@r{e7vX<`i3|{ z%7Q0Cr27F8&$u$y?bqD%2Pq+NYB9imdIUOG86R--$~1u9-nmX<9EaY$81Sog|SceSaB;jQwp(+>c!{)JY&yz4PsfLm!L|wY9CR=2>H$X9xip8>7Y` zkZ0+30esQ5H+7^lP^=t{wttL{QK{ZEYMU2q_WeC;D=0ti--$1A8MX8Ys5B|*RT&&( zR}}zqK%>o-aY+De85G=~vjhTM^QczBYBQU)aR@!>@vo*ImOp2Po@)g0)0+fQBMqsj zW`TLg$rG@Z#=I9G2obnJ%>ef|w8GGLYPm zlJ;P6PsRZ>>Kve=zQj%g8CHs-JP#0MRsr4AI{?oJ5+EodOvGf4npp%n-4go_9TdjY^`pyi? z2DgJfWz%-Avr70=E5p}BGf{zOsdeisfe*{XSK=+@J?3g!?z1ed)0fF94+~IakUU_A&4uNT^5H1k+X5hy0MtN_0rxfk;%Zx48*@2;QcC;Y zT3aLd05*v|pkk9a0T8~V5xhq}iyICPc#hOUO@nOY3_fdw4+I>S;c;m*;CUBSu?(ni z@j#Jltk>eZK9^}I!AC%zjX$@!`PJokFxdrihDh0BfIz>Jm7}PHyny%VmeAL2r?!Bh zU0b&HBV!c_>zb!ey-7)S>yFZABdW%>&41t@wOAv6!9${j2&|G;{$F|Y*ZaTPwm=C4 zz=#%e@56gIuvCiyRwmjN*$_rbdct&qV+YKHK1@590}?<|Y$6hGviDhyK2M#IA{A1Z z^*JgYg%qvSQX&YW_xjNjw7kleJ6kJnhutm{>H>}Z*=(+mb@c$w=S5>K(k@gFN6|zG z?(uGxcCLO}U)F>Co6YxFbcztujwGa0)Z?V4*MiTezajvgc{W&Z%V{~X-%arpsxI(~ z1(V!(1zc;2byA$2&=Y$%^og=Le>+T~Or zP9NWGh)As`rSglw#1fBD6`)}_ty@)?@$q6CUo4|7H4|!&JB#L#4znIRM)wM1l(BgB zsmT?DGr})fYVxeEZxQ4D1Z}SCu*opl0>I|Z6XAtF-+c1eJc@!7#k0+XqM0(NEEl42 zbJ0C=>9uL5P|^ECup9ytTa=SlE|yBDr)o9Vj~9^`G!Z7%x7oF~F<|Y4nG2gL`H^Im zBX?^oAtTUAz)mMA!6{K`Wu6AkhNC)RKXIpfGs1DdQKtAxBiVy!4ulqaId9;0Ch`Tt zG*=fA-VWYfUkHrV63kcyd0-pIdC0yncr*fc8s)AP-1PIMQl#nPOJLSKJR4EAs+(zZ z$j*8$&KxJg%_<*vF19Uk0Yx50Z+O~`8?^}#okwLizrJ#y+HmF0noDQZD@>ktI>rhV zJhp}wXw|-b$S<*h7RDH$v6KDSYU!*!(w?6CtboUC=LWl5(-s1#E5!kmk0c9y28g*w zovis;9Y3B3X20vZlpEKpbMKFho+s149Fg@v^Q>hJ@t4&4EXNK7YN2|bVE8sYQ}G>?>+Z#KxeNJ6%$y87|TQ44(8 z`Web^gdV=vKb~CT%jlkb9_5SEE-qWqs+8MA)!UD>2Ds1*)g%u(O)=kXh0`-x3`BAD z)UR@Evf!7yezx?|WhglAaWp$mX4$!Kw@IIR4C!WHJ)?%RQNSn=TUoJo6vqs}OJK(- ztGQ+(Ib&w2@~RPz2=`$t6KrZ+%Sh2x&C-d9^cvhtPShf9adId^&oo;#8_|6x=1{U` z8$(3a!ztL)KR9Frn27hhmvFxxecIGh-haDrho)Nz3zDW-^XIwhOoK~Ksm|X;7rigT z-7ll-v*^=riobgxa<-?Oq26P3fQoLr05c9?u5J7p;m8pNN9Y>?u9{6_)z^3DMr0^r zI8)6PppL(p^Isi5SaG!+qKm<5Y``ozkI%UE(b-R;6z+7M$V_c{KZumG%< zL+>VAy}d4)?>s~N^SGa<@4Y_U>zU~|>D(D|m%gF?Kqvky{4z3KN6C+%C~kk{^mT??dal7FhG7i-?vgm)={sQ}y5jAxxxKT*N8W3UyP<&mtC&)n z`~q%;R8bO)?Nu&(S>}&=7=6}3I_vp3RFqblP^TrE;|Tpp%)R*sDhDrn-i&UoO@i$^ z1lJfso0U94llmhdvhTtPknAhg2pCT`gF!2g!Cx!SHWtaQUqIZfC!BY`6ta=$1EV2( z>w^^g8jJJ>K_szE9TO((PGCs~_ z6-3D?RP!>rjZ7#*i#N0l>%?K$XmfP9K2u~kQ+!UPM_M}A6C@Mxx1L|?Oz2Ks2wnae zKu*_z|H<}n_XTA|4y}{Ukb;gj=x7%|f{mq9GzX*xrKUu1n_6}o9wJ*1ms?~emFY%} zo&vJ31O31rkQX@pfHf?G&CKcb^b3)ouou%0b!%Y4D@yyV0n z(-+)^p%Ov)LV4&`btVH0UDpjD_E3~~Mrcnw-kd4D4D0n;MJX}+k&harH#N}h`~rgU zR{^02_igO>#FVH5@vpt>-2rvFl6*?}Aobe9 zu8*g<83Hy-u7@~$?mvy)E;Ujr4NvZ4J-<3S;k)Btj?s@sPnos)kT&seB2eZJ9C*0m ztWt)r2&bG0k1Y@mO!+Y~JU>oKB`>4+@idgM03ZJdUYV6h5KC;&`;f#Gopw3*wHH@VAJUoUU|_cLyLFOvF~@)ETT zEA21lExT`PBj2O__h(fOwM!LnN1R35^2F^YhSazhS^BBM=;`(bK?40=T&0A_`;hk*G1uzgev6if6r91kWv*Vm^m0LR4$0CeSsAS(M#ya!Dy`3s!mu4y`)+)?db? ze*sU?7g~-`$0>Q?YU)p8TaedE!`T(Tf)%51BC9_X`Ni(x1SdQ!2Sut)c)8ryX~HQO?U0?T3kZylKSp<;*rQ>JsS;>*!ozA;+P-2&oY zV=U3Co(v3UR~tiO((XZ#@Y6I6vYQZrEEp*mB->$^e~`d=(bkv%UrFjnJ07_-F5>|9 z+fdKzg%*woiqaps)q&4+=O2oqT$gc}SZZEIKL%oST0AiL>=Y4x=!3ha%=}HcO;<|g zQV8rR3%D;~WjtJR-PU6Fx~VHuVQ*Nse)flE{V@t93nI3LaoH<^IDz2jEal)l?~OE} zVsiw;#J0}p69OYPbR_t45d`LWOdQGj@Vi_vpAD$q(cY_L&HC^&0H0fw<#Ft&g)gpK z8n(F^L)j$o69f`UJLIYQR7x;!-n(P^NZF37-m|tQwL=9;zhQ%+tGb`5ej{JU0Ai>z zc)CgLk*<)<7HDu7$BI;=LM`m#VyEDFuG_rMzi%;b2*Z>^IC0||wOajPrXMMg`ld=k|>1_aH=!;IN^qfZl77cUNQ5wnWh(QQWnBhUy#GCEGN6C$uX(f{jz{%aR#@|eRA@1xDFtot~Dc{WDaOGV#(T5^*6JBIf z^qc2ZJ=tQzs97ag)%K}%v8=MP51HU^L?r}>Rn^%z{Zn-lJT_VP2dJ?2NKaUAP^I$0gglE;dPwLJc_r^e;F9C z$N-FwgvLmEg98%X`IEmo?=()5`lhI;-r9C6M#JC~2prYAXT$vh)lq-aoTs&%gzx39 z8xPw-&zB*60QKr{qOU^$yxYN=;7XwJ902U~_ZR56&YUl>#2Z>w-M#=$&|hl8^Ct_( z_%Ahn4X0*n2cUwv9_?M=_j4=L?fcDhNqJkJc4H+!_wG@;Pj{uVFe1@G==0BVnY>*o z+wD(C&Fls662Rw6j6lZH3|QkBe^=8{zI!kN8wP=zBre8;QCw_!@3GLUiyRc3r7 zpuKQX-?LZ9lb+pZff;pbhUl=&VG10vMdnfQ%Kfh1t0~sCvuGt8`Ru3w-rt za|aj`x3{!tm|0jH-vU^KutEBXQ#9aH7iCI|kTLZPzyM|@8zZ5?urw(G1hi}fd&Rrn z`>ik|o}_3_YWHZWSHkF^Bf(doKTaQge$=+cbjsf}8( zZSVXfhnj%hvi+BV$1W{AF50I_vRf?Q;!=B-`-_Rtk}R4J-sO>CK?*R2tQyv;>}Fx# zR$6%xNuA_AtD_5!2XK6S_Jhp@jq|DKtkkuK}Z z4?QIu4thtj1kqf~43#(m9+5^YLB@N!gIMA{+CX@5Tg!{u$XcwP`=7Xsg7@3I;fG#1ihVvvXy6dy1AX&7p%4PUTtLm5LG^LD^~`Nz%6o6GaOJ(poac`vBeWkJgfRh(S( zgP_`g(o!#itP6GX=BrV#>b(&4xM+W0C+H#n{60mJqf +Using [Minikube](https://github.com/kubernetes/minikube), you can run Kyma locally, develop, and test your solutions on a small scale before you push them to a cluster. Follow the Getting Started guides to [install Kyma locally](031-gs-local-installation.md) and [deploy a sample service](032-gs-sample-service-deployment-to-local.md).

    diff --git a/docs/kyma/docs/002-components.md b/docs/kyma/docs/002-components.md new file mode 100644 index 000000000000..fc884da9f901 --- /dev/null +++ b/docs/kyma/docs/002-components.md @@ -0,0 +1,42 @@ +--- +title: Components +type: Details +--- + +Kyma is built on the foundation of the best and most advanced open-source projects which make up the components readily available for customers to use. +This section describes the Kyma components. + +## Service Catalog + +The Service Catalog lists all of the services available to Kyma users through the registered Service Brokers. Using the Service Catalog, you can provision new services in the +Kyma [Kubernetes](https://kubernetes.io/) cluster and create bindings between the provisioned service and an application. + +## Service Brokers + +Service Brokers are [Open Service Broker API](https://www.openservicebrokerapi.org/)-compatible servers that manage the lifecycle of one or more services. Each Service Broker registered in Kyma presents the services it offers to the Service Catalog. You can provision these services on a cluster level through the Service Catalog. Out of the box, Kyma comes with three Service Brokers. +You can register more [Open Service Broker API](https://www.openservicebrokerapi.org/)-compatible Service Brokers in Kyma and provision the services they offer using the Service Catalog. + +## Application Connector + +The Application Connector is a proprietary Kyma solution. This endpoint is the Kyma side of the connection between Kyma and the external solutions. The Application Connector allows you to register the APIs and the Event Catalog, which lists all of the available events, of the connected solution. Additionally, the Application Connector proxies the calls from Kyma to external APIs in a secure way. + +## Event Bus + +Kyma Event Bus receives Events from external solutions and triggers the business logic created with lambda functions and services in Kyma. The Event Bus is based on the [NATS Streaming](https://nats.io/) open source messaging system for cloud-native applications. + +## Service Mesh + +The Service Mesh is an infrastructure layer that handles service-to-service communication, proxying, service discovery, traceability, and security independent of the code of the services. Kyma uses the [Istio](https://istio.io/) Service Mesh that enforces RBAC (Role Based Access Control) in the cluster. [Dex](https://github.com/coreos/dex) handles the identity management and identity provider integration. It allows you to integrate any [OpenID Connect](https://openid.net/connect/)-compliant identity provider with Kyma. + +## Serverless + +The Kyma Serverless component allows you to reduce the implementation and operation effort of an application to the absolute minimum. Kyma Serverless provides a platform to run lightweight functions in a cost-efficient and scalable way using JavaScript and Node.js. Kyma Serverless is built on the [Kubeless](http://kubeless.io/) framework, which allows you to deploy lambda functions, +and uses the [NATS](https://nats.io/) messaging system that monitors business events and triggers functions accordingly. + +## Monitoring + +Kyma comes bundled with tools that give you the most accurate and up-to-date monitoring data. [Prometheus](https://prometheus.io/) open source monitoring and alerting toolkit provides this data, which is consumed by different add-ons, including [Grafana](https://grafana.com/) for analytics and monitoring, and [Alertmanager](https://prometheus.io/docs/alerting/alertmanager/) for handling alerts. + +## Tracing + +The tracing in Kyma uses the [Jaeger](https://github.com/jaegertracing) distributed tracing system. Use it to analyze performance by scrutinizing the path of the requests sent to and from your service. This information helps you optimize the latency and performance of your solution. diff --git a/docs/kyma/docs/005-environments.md b/docs/kyma/docs/005-environments.md new file mode 100644 index 000000000000..8a480d013e61 --- /dev/null +++ b/docs/kyma/docs/005-environments.md @@ -0,0 +1,44 @@ +--- +title: Environments +type: Details +--- + +An Environment is a custom Kyma security and organizational unit based on the concept of Kubernetes [Namespaces](https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/). Kyma Environments allow you to divide the cluster +into smaller units to use for different purposes, such as development and testing. + +Kyma Environment is a user-created Namespace marked with the `env: "true"` label. The Kyma UI only displays the Namespaces marked with the `env: "true"` label. + + +## Default Kyma Namespaces + +Kyma comes configured with default Namespaces dedicated for system-related purposes. The user cannot modify or remove any of these Namespaces. + +- `kyma-system` - This Namespace contains all of the Kyma Core components. +- `kyma-integration` - This Namespace contains all of the Application Connector components responsible for the integration of Kyma and external solutions. +- `kyma-installer` - This Namespace contains all of the Kyma installer components, objects, and Secrets. +- `istio-system` - This Namespace contains all of the Istio-related components. + +## Environments in Kyma + +Kyma comes with three Environments ready for you to use. These environments are: + +- `production` +- `qa` +- `stage` + +## Create a new Environment + +To create a new Environment, create a Namespace and mark it with the `env: "true"` label. Use this command to do that in a single step: + +``` +$ cat < **NOTE:** These scripts do not delete the cluster from your Minikube. This allows you to quickly re-install Kyma. + +1. Use the `clean-up.sh` script to uninstall Kyma from the cluster. Run: + ``` + scripts/clean-up.sh + ``` + +2. Run this script to reinstall Kyma on an existing cluster: + ``` + cmd/run.sh --skip-minikube-start + ``` diff --git a/docs/kyma/docs/026-details-testing.md b/docs/kyma/docs/026-details-testing.md new file mode 100644 index 000000000000..36cd306b4dd9 --- /dev/null +++ b/docs/kyma/docs/026-details-testing.md @@ -0,0 +1,82 @@ +--- +title: Testing Kyma +type: Details +internal: true +--- + +For testing, the Kyma components use the Helm test concept. Place your test under the `templates` directory as a Pod definition that specifies a container with a given command to run. + + +## Add a new test + +The system bases tests on the Helm broker concept with one modification: adding a Pod label. Before you create a test, see the official [Chart Tests](https://github.com/kubernetes/helm/blob/release-2.7/docs/chart_tests.md) documentation. Then, add the `"helm-chart-test": "true"` label to your Pod template. + +See the following example of a test prepared for Dex: + +``` +# Chart tree +dex +├── Chart.yaml +├── README.md +├── templates +│   ├── tests +│   │ └── test-dex-connection.yaml +│   ├── dex-deployment.yaml +│   ├── dex-ingress.yaml +│   ├── dex-rbac-role.yaml +│   ├── dex-service.yaml +│   ├── pre-install-dex-account.yaml +│   ├── pre-install-dex-config-map.yaml +│   └── pre-install-dex-secrets.yaml +└── values.yaml +``` + +The test adds a new **test-dex-connection.yaml** under the `templates/tests` directory. +This simple test calls the `Dex` endpoint with cURL, defined as follows: + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: "test-{{ template "fullname" . }}-connection-dex" + annotations: + "helm.sh/hook": test-success + labels: + "helm-chart-test": "true" # ! Our customization +spec: + hostNetwork: true + containers: + - name: "test-{{ template "fullname" . }}-connection-dex" + image: tutum/curl:alpine + command: ["/usr/bin/curl"] + args: [ + "--fail", + "http://dex-service.{{ .Release.Namespace }}.svc.cluster.local:5556/.well-known/openid-configuration" + ] + restartPolicy: Never +``` + +## Test execution + +All tests created for [core](../../../resources/core/) charts run automatically after starting Kyma. +If any of the tests fail, the system prints the Pod logs in the terminal, then deletes all the Pods. + +>**NOTE:** If you run Kyma locally, by default, the system does not take into account the test's exit code. As a result, the system does not terminate Kyma Docker container, and you can still access it. +To force a termination in case of failing tests, use `--exit-on-test-fail` flag when executing `run.sh` script. + +CI propagates the exit status of tests. If any test fails, the whole CI job fails as well. + +Follow the same guidelines to add a test which is not a part of any **core** component. However, for test execution, see **Run a test manually** in this document. + +### Run a test manually + +To run a test manually, use the [testing.sh](../../../installation/scripts/testing.sh) script, which runs all tests defined for **core** releases. +If any of the tests fail, the system prints the Pod logs in the terminal, then deletes all the Pods. + +Another option is to run a Helm test directly on your release. + +```bash +$ helm test +``` + +You can also run your test on custom releases. If you do this, remember to always delete the Pods after a test ends. diff --git a/docs/kyma/docs/027-details-charts.md b/docs/kyma/docs/027-details-charts.md new file mode 100644 index 000000000000..a8b9612a6174 --- /dev/null +++ b/docs/kyma/docs/027-details-charts.md @@ -0,0 +1,152 @@ +--- +title: Charts +type: Details +internal: true +--- + +Kyma uses Helm charts to deliver single components and extensions, as well as the core components. This document contains information about the chart-related technical concepts, dependency management to use with Helm charts, and chart examples. + +## Manage dependencies with Init Containers + +The [ARD#004](https://github.com/kyma-project/community-new/blob/master/architecture-decision-records/adr-004-InitContainers_for_dependency_management.md) architecture decision record declares the use of Init Containers as the primary dependency mechanism. + +[Init Containers](https://kubernetes.io/docs/concepts/workloads/pods/init-containers/) present a set of distinctive behaviors: + +* They always run to completion. +* They start sequentially, only after the preceding Init Container completes successfully. + If any of the Init Containers fails, the Pod restarts. This is always true, unless the `restartPolicy` equals `never`. + +[Readiness Probes](https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#container-probes) ensure that the essential containers are ready to handle requests before you expose them. At a minimum, probes are defined for every container accessible from outside of the Pod. It is recommended to pair the Init Containers with readiness probes to provide a basic dependency management solution. + +## Examples +Here are some examples: + +1. Generic + + +```yaml +apiVersion: apps/v1beta2 +kind: Deployment +metadata: + name: nginx-deployment +spec: + replicas: 3 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.7.9 + ports: + - containerPort: 80 + readinessProbe: + httpGet: + path: /healthz + port: 80 + initialDelaySeconds: 30 + timeoutSeconds: 1 +``` + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: myapp-pod +spec: + initContainers: + - name: init-myservice + image: busybox + command: ['sh', '-c', 'until nslookup nginx; do echo waiting for nginx; sleep 2; done;'] + containers: + - name: myapp-container + image: busybox + command: ['sh', '-c', 'echo The app is running! && sleep 3600'] +``` + +2. Kyma + + +```yaml +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: helm-broker + labels: + app: helm-broker +spec: + replicas: 1 + selector: + matchLabels: + app: helm-broker + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + template: + metadata: + labels: + app: helm-broker + spec: + + initContainers: + - name: init-helm-broker + image: eu.gcr.io/kyma-project/alpine-net:0.2.74 + command: ['sh', '-c', 'until nc -zv core-catalog-controller-manager.kyma-system.svc.cluster.local 8080; do echo waiting for etcd service; sleep 2; done;'] + + containers: + - name: helm-broker + ports: + - containerPort: 6699 + readinessProbe: + tcpSocket: + port: 6699 + failureThreshold: 3 + initialDelaySeconds: 10 + periodSeconds: 3 + successThreshold: 1 + timeoutSeconds: 2 +``` + +## Support for the Helm wait flag + +High level Kyma components, such as **core**, come as Helm charts. These charts are installed as part of a single Helm release. To provide ordering for these core components, the Helm client runs with the `--wait` flag. As a result, Tiller waits for the readiness of all of the components, and then evaluates the readiness. + +For `Deployments`, set the strategy to `RollingUpdate` and set the `MaxUnavailable` value to a number lower than the number of replicas. This setting is necessary, as readiness in Helm v2.8.2 is fulfilled if the number of replicas in ready state is not lower than the expected number of replicas: + +``` +ReadyReplicas >= TotalReplicas - MaxUnavailable +``` + +## Chart installation details + +The Tiller server performs the chart installation process. This is the order of operations that happen during the chart installation: + +* resolve values +* recursively gather all templates with the corresponding values +* sort all templates +* render all templates +* separate hooks and manifests from files into sorted lists +* aggregate all valid manifests from all sub-charts into a single manifest file +* execute PreInstall hooks +* create a release using the ReleaseModule API and, if requested, wait for the actual readiness of the resources +* execute PostInstall hooks + +## Notes + +All notes are based on Helm v2.7.2 implementation and are subject to change in feature releases. + +* Regardless of how complex a chart is, and regardless of the number of sub-charts it references or consists of, it's always evaluated as one. This means that each Helm release is compiled into a single Kubernetes manifest file when applied on API server. + +* Hooks are parsed in the same order as manifest files and returned as a single, global list for the entire chart. For each hook the weight is calculated as a part of this sort. + +* Manifests are sorted by `Kind`. You can find the list and the order of the resources on the Kubernetes [Tiller](https://github.com/kubernetes/helm/blob/v2.8.2/pkg/tiller/kind_sorter.go#L29) website. + +## Glossary + +* **resource** is any document in a chart recognized by Helm or Tiller. This includes manifests, hooks, and notes. +* **template** is a valid Go template. Many of the resources are also Go templates. diff --git a/docs/kyma/docs/028-details-deploy-private-registry.md b/docs/kyma/docs/028-details-deploy-private-registry.md new file mode 100644 index 000000000000..7bc971c71cce --- /dev/null +++ b/docs/kyma/docs/028-details-deploy-private-registry.md @@ -0,0 +1,78 @@ +--- +title: How to deploy a Docker image from a private registry +type: Details +internal: true +--- + +## Overview + +Docker is a free tool to deploy applications and servers. To run an application on Kyma, provide the application binary file as a Docker image located in a Docker registry. Use the `DockerHub` public registry to upload your Docker images for free access to the public. Use a private Docker registry to ensure privacy, increased security, and better availability. + +This document shows how to deploy a Docker image from your private Docker registry to the Kyma cluster. + +## Details + +The deployment to Kyma from a private registry differs from the deployment from a public registry. You must provide Secrets accessible in Kyma, and referenced in the `.yaml` deployment file. This section describes how to deploy an image from a private Docker registry to Kyma. Follow the deployment steps: + +1. Create a Secret resource. +2. Write your deployment file. +3. Submit the file to the Kyma cluster. + +### Create a Secret for your private registry + +A Secret resource passes your Docker registry credentials to the Kyma cluster in an encrypted form. For more information on Secrets, refer to the [Kubernetes documentation](https://kubernetes.io/docs/concepts/configuration/secret/). + +To create a Secret resource for your Docker registry, run the following command: + +```bash +kubectl create secret docker-registry {secret-name} --docker-server={registry FQN} --docker-username={user-name} --docker-password={password} --docker-email={registry-email} --namespace={namespace} +``` + +Refer to the following example: +```bash +kubectl create secret docker-registry docker-registry-secret --docker-server=myregistry:5000 --docker-username=root --docker-password=password --docker-email=example@github.com --namespace=production +``` + +The Secret is associated with a specific Namespace. In the example, the Namespace is `production`. However, you can modify the Secret to point to any desired Namespace. + +### Write your deployment file + +1. Create the deployment file with the `.yml` extension and name it `deployment.yml`. + +2. Describe your deployment in the `.yml` file. Refer to the following example: + +```yaml +apiVersion: apps/v1beta2 +kind: Deployment +metadata: + namespace: production # {production/stage/qa} + name: my-deployment # Specify the deployment name. + annotations: + sidecar.istio.io/inject: true +spec: + replicas: 3 # Specify your replica - how many instances you want from that deployment. + selector: + matchLabels: + app: app-name # Specify the app label. It is optional but it is a good practice. + template: + metadata: + labels: + app: app-name # Specify app label. It is optional but it is a good practice. + version: v1 # Specify your version. + spec: + containers: + - name: container-name # Specify a meaningful container name. + image: myregistry:5000/user-name/image-name:latest # Specify your image {registry FQN/your-username/your-space/image-name:image-version}. + ports: + - containerPort: 80 # Specify the port to your image. + imagePullSecrets: + - name: docker-registry-secret # Specify the same Secret name you generated in the previous step for this Namespace. + - name: example-secret-name # Specify your Namespace Secret, named `example-secret-name`. + +``` +3. Submit you deployment file using this command: + +```bash +kubectl apply -f deployment.yml +``` +Your deployment is now running on the Kyma cluster. diff --git a/docs/kyma/docs/031-gs-local-installation.md b/docs/kyma/docs/031-gs-local-installation.md new file mode 100644 index 000000000000..aa0860865ee3 --- /dev/null +++ b/docs/kyma/docs/031-gs-local-installation.md @@ -0,0 +1,136 @@ +--- +title: Local Kyma installation +type: Getting Started +internal: true +--- + +## Overview + +This Getting Started guide instructs developers to quickly deploy Kyma locally on a Mac. Kyma installs locally using a proprietary installer based on a Kubernetes operator. +The document provides the prerequisites, the instructions on how to install Kyma locally and verify the deployment, and the troubleshooting tips. + +## Prerequisites + +To run Kyma locally, download these tools: + +- [Minikube](https://github.com/kubernetes/minikube) 0.28.0 +- [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) 1.9.0 +- [Helm](https://github.com/kubernetes/helm) 2.8.2 +- [Hyperkit driver](https://github.com/kubernetes/minikube/blob/master/docs/drivers.md#hyperkit-driver) + +Read the [prerequisite reasoning](019-prereq-reasoning.md) document to learn why Kyma uses these tools. + +## Setup certificates + +Kyma comes with a local wildcard self-signed [certificate](../../../installation/certs/workspace/raw/server.crt). Trust it on the OS level for convenience. Alternatively, accept exceptions for each subdomain in your browser as you use Kyma. + +Follow these steps to "always trust" the Kyma certificate on macOS: + +1. Open the Keychain Access application. Go to **File**, then select **Import items...** and import the Kyma [certificate](../../../installation/certs/workspace/raw/server.crt). +2. Go to the **Certificates** view and find the `*.kyma.local` certificate you imported. +3. Righ-click on the certificate and select **Get Info**. +4. Expand the **Trust** list and set **When using this certificate** to **Always trust**. +5. Close the certificate information window and enter your system password to confirm the changes. + +>**NOTE:** +- The process is complete when you close the certificate information window and enter your password. You don't get the expected results if you try to use the certificate before completing this step. +- "Always trusting" the certificate does not work with Mozilla Firefox. + +## Install Kyma on Minikube + +> **NOTE:** Running the installation script deletes any previously existing cluster from your Minikube. + +1. Change the working directory to `installation`: + ```bash + cd installation + ``` + +2. Run the `run.sh` script: + ``` + cmd/run.sh + ``` + +The `run.sh` script does not show the progress of the Kyma installation, which allows you to perform other tasks in the terminal window. However, to see the status of the Kyma installation, run this script after you set up the cluster and the installer: + +``` +scripts/is-installed.sh +``` + +Read the [Reinstall Kyma](025-details-local-reinstallation.md) document to learn how to reinstall Kyma without deleting the cluster from Minikube. +To learn how to test Kyma, see the [Testing Kyma](026-details-testing.md) document. + +### Custom Resource file + +The Custom Resource file contains controls the Kyma installer, which is a proprietary solution based on the [Kubernetes operator](https://coreos.com/operators/). The file contains the basic information that defines Kyma installation. +Find the custom resource template [here](../../../installation/resources/installer-cr.yaml.tpl). + +### Control the installation process + +To trigger the installation process, set the **action** label to `install` in the metadata of the Custom Resource with the installer configuration. +To trigger the deinstallation process, set the **action** label to `uninstall` in the metadata of the Custom Resource with the installer configuration. + +### Generate a new Custom Resource file + +Use the `create-cr.sh` script to generate the Custom Resource file. The script accepts these arguments: + +- `--output` - mandatory. The location of the Custom Resource output file +- `--url` - the URL of the Kyma package to install +- `--version` - the Kyma version +- `--ipaddr` - the load balancer IP +- `--domain` - the instance domain +- `--sci-ui-user` - SCI credentials for the UI API +- `--sci-ui-password` - SCI credentials for the UI API + +For example: +``` +$ ./installation/scripts/create-cr.sh --output kyma-cr.yaml --url {Kyma_TAR.GZ_URL} --version 0.0.1 --domain kyma.local +``` + +## Verify the deployment + +Follow the guidelines in the subsections to confirm that your Kubernetes API Server is up and running as expected. + +### Access Kyma with CLI + +Verify the cluster deployment with the kubectl command line interface (CLI). + +Run this command to fetch all Pods in all Namespaces: + + ``` bash + kubectl get pods --all-namespaces + ``` +The command retrieves all Pods from all Namespaces, the status of the Pods, and their instance numbers. Check if the **STATUS** column shows `Running` for all Pods. If any of the Pods that you require do not start successfully, perform the installation again. + +### Access the Kyma console + +Access your local Kyma instance through [this](https://console.kyma.local/) link. + +* Click **Login with Email** and sign in with the `admin@kyma.cx` email address and the generic password from the [Dex ConfigMap](../../../resources/dex/templates/dex-config-map.yaml) file. + +* Click the **Environments** section and select an Environment from the drop-down menu to explore Kyma further. + +### Access the Kubernetes Dashboard + +Additionally, confirm that you can access your Kubernetes Dashboard. Run the following command to check the IP address on which Minikube is running: + +```bash +minikube ip +``` + +The URL of your Kubernetes Dashboard looks similar to this: +``` +http://{ip-address}:30000 +``` + +See the example of the website address: + +``` +http://192.168.64.44:30000 +``` + +## Troubleshooting + +If the installer does not respond as expected, check the installation status using the `is-installed.sh` script with the `--verbose` flag added. Run: +``` +scripts/is-installed.sh --verbose +``` diff --git a/docs/kyma/docs/032-gs-sample-service-deployment-to-local.md b/docs/kyma/docs/032-gs-sample-service-deployment-to-local.md new file mode 100644 index 000000000000..ad0054c15323 --- /dev/null +++ b/docs/kyma/docs/032-gs-sample-service-deployment-to-local.md @@ -0,0 +1,72 @@ +--- +title: Sample service deployment on local +type: Getting Started +internal: true +--- + +## Overview + +This Getting Started guide is intended for the developers who want to quickly learn how to deploy a sample service and test it with Kyma installed locally on Mac. + +This guide uses a standalone sample service written in the [Go](http://golang.org) language . + +## Prerequisites + +To use the Kyma cluster and install the example, download these tools: + +- [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) 1.9.0 +- [curl](https://github.com/curl/curl) + +## Steps + +### Deploy and expose a sample standalone service + +Follow these steps: + +1. Deploy the sample service to any of your Environments. Use the `stage` Environment for this guide: + + ```bash + kubectl create -n stage -f https://github.com/raw/kyma-project/examples/master/http-db-service/deployment/deployment.yaml + ``` + +2. Create an unsecured API for your example service: + + ```bash + kubectl apply -n stage -f https://github.com/raw/kyma-project/examples/master/gateway/service/api-without-auth.yaml + ``` + +3. Add the IP address of Minikube to the `hosts` file on your local machine for your APIs: + + ```bash + $ echo "$(minikube ip) http-db-service.kyma.local" | sudo tee -a /etc/hosts + ``` + +4. Access the service using the following call: + ```bash + curl -ik https://http-db-service.kyma.local/orders + ``` + + The system returns a response similar to the following: + ``` + HTTP/2 200 + content-type: application/json;charset=UTF-8 + vary: Origin + date: Mon, 01 Jun 2018 00:00:00 GMT + content-length: 2 + x-envoy-upstream-service-time: 131 + server: envoy + + [] + ``` + +### Update your service's API to secure it + +Run the following command: + + ```bash + kubectl apply -n stage -f https://github.com/raw/kyma-project/examples/master/gateway/service/api-with-auth.yaml + ``` + +>**NOTE:** The update might take some time. + +Since now, to access the service, valid bearer ID token has to be used in the Authorization header. diff --git a/docs/kyma/docs/033-gs-sample-service-deployment-to-cluster.md b/docs/kyma/docs/033-gs-sample-service-deployment-to-cluster.md new file mode 100644 index 000000000000..693f6eba3997 --- /dev/null +++ b/docs/kyma/docs/033-gs-sample-service-deployment-to-cluster.md @@ -0,0 +1,83 @@ +--- +title: Sample service deployment on a cluster +type: Getting Started +--- + +## Overview + +This Getting Started guide is intended for the developers who want to quickly learn how to deploy a sample service and test it with the Kyma cluster. + +This guide uses a standalone sample service written in the [Go](http://golang.org) language. + +## Prerequisites + +To use the Kyma cluster and install the example, download these tools: + +- [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) 1.9.0 +- [curl](https://github.com/curl/curl) + +## Steps + +### Download configuration for kubectl + +Follow these steps to download **kubeconfig** and configure kubectl to access the Kyma cluster: +1. Access the Console UI and download the **kubectl** file from the settings page. +2. Place downloaded file in the following location: `$HOME/.kube/kubeconfig`. +3. Point **kubectl** to the configuration file using the terminal: `export KUBECONFIG=$HOME/.kube/kubeconfig`. +4. Confirm **kubectl** is configured to use your cluster: `kubectl cluster-info`. + +### Set the cluster domain variable + +The commands throughout this guide use URLs that require you to provide the domain of the cluster which you are using. To complete this configuration, set the variable `yourClusterDomain` to the domain of your cluster. + +For example if your cluster's domain is 'demo.cluster.kyma.cx' then run the following command: + + ```bash + export yourClusterDomain='demo.cluster.kyma.cx' + ``` + +### Deploy and expose a sample standalone service + +Follow these steps: + +1. Deploy the sample service to any of your Environments. Use the `stage` Environment for this guide: + + ```bash + kubectl create -n stage -f https://minio.$yourClusterDomain/content/root/kyma/assets/deployment.yaml + ``` + +2. Create an unsecured API for your service: + + ```bash + curl -k https://minio.$yourClusterDomain/content/root/kyma/assets/api-without-auth.yaml | sed "s/.kyma.local/.$yourClusterDomain/" | kubectl apply -n stage -f - + ``` + +3. Access the service using the following call: + ```bash + curl -ik https://http-db-service.$yourClusterDomain/orders + ``` + + The system returns a response similar to the following: + ``` + HTTP/2 200 + content-type: application/json;charset=UTF-8 + vary: Origin + date: Mon, 01 Jun 2018 00:00:00 GMT + content-length: 2 + x-envoy-upstream-service-time: 131 + server: envoy + + [] + ``` + +### Update your service's API to secure it + +Run the following command: + + ```bash + curl -k https://minio.$yourClusterDomain/content/root/kyma/assets/api-with-auth.yaml | sed "s/.kyma.local/.$yourClusterDomain/" | kubectl apply -n stage -f - + ``` + +>**NOTE:** The update might take some time. + +Since now, to access the service, valid bearer ID token has to be used in the Authorization header. diff --git a/docs/kyma/docs/034-gs-local-develop-no-docker.md b/docs/kyma/docs/034-gs-local-develop-no-docker.md new file mode 100644 index 000000000000..057272a91244 --- /dev/null +++ b/docs/kyma/docs/034-gs-local-develop-no-docker.md @@ -0,0 +1,130 @@ +--- +title: Develop a service locally without using Docker +type: Getting Started +internal: true +--- + +## Overview + +You can develop services in the local Kyma installation without extensive Docker knowledge or a need to build and publish a Docker image. The `minikube mount` feature allows you to mount a directory from your local disk into the local Kubernetes cluster. + +This guide shows how to use this feature, using the service example implemented in Golang. + +## Prerequisites + +Install [Golang](https://golang.org/dl/). + +## Steps + +### Install the example on your local machine + +1. Install the example: +```shell +go get -insecure github.com/kyma-project/examples/http-db-service +``` +2. Navigate to installed example and the `http-db-service` folder inside it: +```shell +cd ~/go/src/github.com/kyma-project/examples/http-db-service +``` +3. Build the executable to run the application: +```shell +CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main . +``` + +### Mount the example directory into Minikube + +For this step, you need a running local Kyma instance. Read the [Local Kyma installation](031-gs-local-installation.md) Getting Started guide to learn how to install Kyma locally. + +1. Open the terminal window. Do not close it until the development finishes. +2. Mount your local drive into Minikube: +```shell +# Use the following pattern: +minikube mount {LOCAL_DIR_PATH}:{CLUSTER_DIR_PATH}` +# To follow this guide, call: +minikube mount ~/go/src/github.com/kyma-project/examples/http-db-service:/go/src/github.com/kyma-project/examples/http-db-service +``` + +See the example and expected result: +```shell +# Terminal 1 +$ minikube mount ~/go/src/github.com/kyma-project/examples/http-db-service:/go/src/github.com/kyma-project/examples/http-db-service + +Mounting /Users/{USERNAME}/go/src/github.com/kyma-project/examples/http-db-service into /go/src/github.com/kyma-project/examples/http-db-service on the minikube VM +This daemon process must stay alive for the mount to still be accessible... +ufs starting +``` + +### Run your local service inside Minikube + +1. Create Pod that uses the base Golang image to run your executable located on your local machine: +```shell +# Terminal 2 +kubectl run mydevpod --image=golang:1.9.2-alpine --restart=Never -n stage --overrides=' +{ + "spec":{ + "containers":[ + { + "name":"mydevpod", + "image":"golang:1.9.2-alpine", + "command": ["./main"], + "workingDir":"/go/src/github.com/kyma-project/examples/http-db-service", + "volumeMounts":[ + { + "mountPath":"/go/src/github.com/kyma-project/examples/http-db-service", + "name":"local-disk-mount" + } + ] + } + ], + "volumes":[ + { + "name":"local-disk-mount", + "hostPath":{ + "path":"/go/src/github.com/kyma-project/examples/http-db-service" + } + } + ] + } +} +' +``` +2. Expose the Pod as a service from Minikube to verify it: +```shell +kubectl expose pod mydevpod --name=mypodservice --port=8017 --type=NodePort -n stage +``` +3. Check the Minikube IP address and Port, and use them to access your service. +```shell +# Get the IP address. +minikube ip +# See the example result: 192.168.64.44 +# Check the Port. +kubectl get services -n stage +# See the example result: mypodservice NodePort 10.104.164.115 8017:32226/TCP 5m +``` +4. Call the service from your terminal. +```shell +curl {minikube ip}:{port}/orders -v +# See the example: curl http://192.168.64.44:32226/orders -v +# The command returns an empty array. +``` + +### Modify the code locally and see the results immediately in Minikube + +1. Edit the `main.go` file by adding a new `test` endpoint to the `startService` function +```go +router.HandleFunc("/test", func (w http.ResponseWriter, r *http.Request) { + w.Write([]byte("test")) +}) +``` +2. Build a new executable to run the application inside Minikube: +```shell +CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main . +``` +3. Replace the existing Pod with the new version: +```shell +kubectl get pod mydevpod -n stage -o yaml | kubectl replace --force -f - +``` +4. Call the new `test` endpoint of the service from your terminal. The command returns the `Test` string: +```shell +curl http://192.168.64.44:32226/test -v +``` diff --git a/docs/kyma/docs/035-gs-publish-service-image-and-deploy.md b/docs/kyma/docs/035-gs-publish-service-image-and-deploy.md new file mode 100644 index 000000000000..b3846a16c1d4 --- /dev/null +++ b/docs/kyma/docs/035-gs-publish-service-image-and-deploy.md @@ -0,0 +1,64 @@ +--- +title: Publish a service Docker image and deploy it to Kyma +type: Getting Started +internal: true +--- + +## Overview + +In the [Getting Started](034-gs-local-develop-no-docker.md) guide for local development of a service, you can learn how to develop a service locally. You can immediately see all the changes made in the local Kyma installation based on Minikube, without building a Docker image and publishing it to a Docker registry, such as the Docker Hub. + +Using the same example service, this guide explains how to build a Docker image for your service, publish it to the Docker registry, and deploy it to the local Kyma installation. The instructions base on Minikube, but you can also use the image that you create, and the Kubernetes resource definitions that you use on the Kyma cluster. + +>**NOTE:** The deployment works both on local Kyma installation and on the Kyma cluster. + +## Steps + +### Build a Docker image + +The `http-db-service` example used in this guide provides you with the `Dockerfile` necessary for building Docker images. Examine the `Dockerfile` to learn how it looks and how it uses the Docker Multistaging feature, but do not use it one-to-one for production. There might be custom `LABEL` attributes with values to override. + +1. In your terminal, go to the `examples/http-db-service` directory. If you did not follow the [local service development](034-gs-local-develop-no-docker.md) guide and you do not have this directory locally, get the `http-db-service` example from the [examples](https://github.com/kyma-project/examples) repository. +2. Run the build with `./build.sh`. + +>**NOTE:** Ensure that the new image builds and is available in your local Docker registry by calling `docker images`. Find an image called `example-http-db-service` and tagged as `latest`. + +### Register the image in the Docker Hub + +This guide bases on Docker Hub. However, there are many other Docker registries available. You can use a private Docker registry, but it must be available in the Internet. For more details about using a private Docker registry, see [this](028-details-deploy-private-registry.md) document. + +1. Open the [Docker Hub](https://hub.docker.com/) webpage. +2. Provide all of the required details and sign up. + +### Sign in to the Docker Hub registry in the terminal + +1. Call `docker login`. +2. Provide the username and password, and select the `ENTER` key. + +### Push the image to the Docker Hub + +1. Tag the local image with a proper name required in the registry: `docker tag example-http-db-service {username}/example-http-db-service:0.0.1`. +2. Push the image to the registry: `docker push {username}/example-http-db-service:0.0.1`. +```shell +#This is how it looks in the terminal + +The push refers to repository [docker.io/{username}/example-http-db-service] +4302273b9e11: Pushed +5835bd463c0e: Pushed +0.0.1: digest: sha256:9ec28342806f50b92c9b42fa36d979c0454aafcdda6845b362e2efb9816d1439 size: 734 +``` +>**NOTE:** To verify if the image is successfully published, check if it is available online at the following address: `https://hub.docker.com/r/{username}/example-http-db-service/` + +### Deploy to Kyma + +The `http-db-service` example contains sample Kubernetes resource definitions needed for the basic Kyma deployment. Find them in the `deployment` folder. Perform the following modifications to use your newly-published image in the local Kyma installation: + +1. Go to the `deployment` directory. +2. Edit the `deployment.yaml` file. Change the **image** attribute to `{username}/example-http-db-service:0.0.1`. +3. Create the new resources in local Kyma using these commands: `kubectl create -f deployment.yaml -n stage && kubectl create -f ingress.yaml -n stage`. +4. Edit your `/etc/hosts` to add the new `http-db-service.kyma.local` host to the list of hosts associated with your `minikube ip`. Follow these steps: + - Open a terminal window and run: `sudo vim /etc/hosts` + - Select the **i** key to insert a new line at the top of the file. + - Add this line: `{YOUR.MINIKUBE.IP} http-db-service.kyma.local` + - Type `:wq` and select the **Enter** key to save the changes. +5. Run this command to check if you can access the service: `curl https://http-db-service.kyma.local/orders`. The response should return an empty array. diff --git a/docs/kyma/docs/assets/api-with-auth.yaml b/docs/kyma/docs/assets/api-with-auth.yaml new file mode 100644 index 000000000000..04506ce72628 --- /dev/null +++ b/docs/kyma/docs/assets/api-with-auth.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: gateway.kyma.cx/v1alpha2 +kind: Api +metadata: + labels: + example: http-db-service + name: http-db-service +spec: + authentication: + - type: JWT + jwt: + jwksUri: http://dex-service.kyma-system.svc.cluster.local:5556/keys + issuer: https://dex.kyma.local + hostname: http-db-service.kyma.local + service: + name: http-db-service + port: 8017 \ No newline at end of file diff --git a/docs/kyma/docs/assets/api-without-auth.yaml b/docs/kyma/docs/assets/api-without-auth.yaml new file mode 100644 index 000000000000..8ea4295c6c24 --- /dev/null +++ b/docs/kyma/docs/assets/api-without-auth.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: gateway.kyma.cx/v1alpha2 +kind: Api +metadata: + labels: + example: http-db-service + name: http-db-service +spec: + authentication: [] + hostname: http-db-service.kyma.local + service: + name: http-db-service + port: 8017 \ No newline at end of file diff --git a/docs/kyma/docs/assets/deployment.yaml b/docs/kyma/docs/assets/deployment.yaml new file mode 100644 index 000000000000..6fa8e41d0a9a --- /dev/null +++ b/docs/kyma/docs/assets/deployment.yaml @@ -0,0 +1,42 @@ +apiVersion: v1 +kind: Service +metadata: + name: http-db-service + labels: + example: http-db-service + annotations: + auth.istio.io/8017: NONE +spec: + ports: + - name: http + port: 8017 + selector: + example: http-db-service +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: http-db-service +spec: + replicas: 1 + template: + metadata: + labels: + example: http-db-service + annotations: + sidecar.istio.io/inject: "true" + spec: + containers: + # replace the repository URL with your own repository (e.g. {DockerID}/http-db-service:0.0.x for Docker Hub). + - image: eu.gcr.io/kyma-project/example/http-db-service:0.1.11 + imagePullPolicy: IfNotPresent + name: http-db-service + ports: + - name: http + containerPort: 8017 + env: + - name: dbtype + # available dbtypes are: [memory, mssql] + value: "memory" + + diff --git a/docs/manifest.yaml b/docs/manifest.yaml new file mode 100644 index 000000000000..bb5d40bb3e1d --- /dev/null +++ b/docs/manifest.yaml @@ -0,0 +1,28 @@ +--- +metadata: + name: kyma-docs +spec: + root: + displayName: Kyma + id: kyma + components: + - displayName: Service Catalog + id: service-catalog + - displayName: Service Brokers + id: service-brokers + - displayName: Application Connector + id: application-connector + - displayName: Event Bus + id: event-bus + - displayName: Service Mesh + id: service-mesh + - displayName: Serverless + id: serverless + - displayName: Monitoring + id: monitoring + - displayName: Tracing + id: tracing + - displayName: API Gateway + id: api-gateway + - displayName: Authorization and Authentication + id: authorization-and-authentication diff --git a/docs/monitoring/docs.config.json b/docs/monitoring/docs.config.json new file mode 100644 index 000000000000..f68a7bf2d0d1 --- /dev/null +++ b/docs/monitoring/docs.config.json @@ -0,0 +1,8 @@ +{ + "spec":{ + "id":"monitoring", + "displayName":"Monitoring", + "description":"Overal documentation for Monitoring", + "type":"Components" + } +} \ No newline at end of file diff --git a/docs/monitoring/docs/001-overview-monitoring.md b/docs/monitoring/docs/001-overview-monitoring.md new file mode 100644 index 000000000000..f6fd89836511 --- /dev/null +++ b/docs/monitoring/docs/001-overview-monitoring.md @@ -0,0 +1,14 @@ +--- +title: Overview +type: Overview +--- + +To enrich Kyma with monitoring functionality, third-party resources come by default as packaged tools. The `kube-prometheus` package is a Prometheus operator from CoreOS responsible for delivering these tools. Monitoring in Kyma includes three primary elements: + +* Prometheus, an open-source system monitoring toolkit. +* Grafana, a user interface that allows you to query and visualize statistics and metrics. +* AlertManager, a Prometheus component that handles alerts that originate from Prometheus. AlertManager performs needed deduplicating, grouping, and routing based on rules defined by the Prometheus server. + +## The advantage of kube-prometheus + +Convenience and efficiency are the main advantages to using the `kube-prometheus` package. `kube-prometheus` delivers a level of monitoring options that would otherwise involve extensive development effort to acquire. Prometheus, Grafana, and AlertManager installed on their own would require the developer to perform customization to achieve the same results as the operator alone. `kube-prometheus` is configured to run on Kubernetes and monitor clusters without additional configuration. diff --git a/docs/monitoring/docs/020-architecture-monitoring.md b/docs/monitoring/docs/020-architecture-monitoring.md new file mode 100644 index 000000000000..6c0c72f3a1d1 --- /dev/null +++ b/docs/monitoring/docs/020-architecture-monitoring.md @@ -0,0 +1,34 @@ +--- +title: Architecture +type: Architecture +--- + +This document outlines the monitoring architecture of Kyma, highlighting information sources that Prometheus polls for data to process. + + +![Monitoring architecture in Kyma](assets/monitoring.png) + + +## The Prometheus Operator + +The Prometheus Operator is a CoreOS component integrated into Kyma that enables Prometheus deployments to be decoupled from the configuration of the entities they monitor. The task of the Operator is to ensure that Prometheus servers with the specified configuration are always running. If the developer does not specify a configuration for Prometheus instances, the Operator is able to generate and deploy one. The Prometheus instance is responsible for the monitoring of services. + +## The Service Monitor + +The Service Monitor works in orchestration with the Prometheus resource that the Operator watches. It dictates to a Prometheus resource how to retrieve metrics and enables exposure of those metrics in a standard manner. It also specifies services the Prometheus instance should monitor. Using labels, the Prometheus resource includes a Service Monitor. + +## Monitored Data sources + +Prometheus contains the flexibility to poll data from a variety of sources. Virtual machines on which Kubernetes runs make time-stamped data available, reporting on jobs started, workload, CPU performance, capacity, and more. In this case, the Service Monitor watches the Kubernetes API master to detect any job creation. The job produces time-stamped data that Prometheus consumes. + +Pods may contain applications with custom metrics that Prometheus can poll through the Prometheus exporter. + +## Grafana + +Kyma employs Grafana as a third-party resource in `kube-prometheus` to deliver a feature-rich metrics dashboard and graph editor. + +To access the Grafana UI, use the following URL: https://grafana.{DOMAIN}. Replace DOMAIN with the domain of your Kyma cluster. + +## Alertmanager + +Alertmanager receives harvested metrics from Prometheus and forwards this data on to the configured channels, such as email or incident management systems. diff --git a/docs/monitoring/docs/assets/monitoring.png b/docs/monitoring/docs/assets/monitoring.png new file mode 100644 index 0000000000000000000000000000000000000000..817ec821aafa02b10c2359c62a86e5114e85c326 GIT binary patch literal 53613 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 +``` + +Kubeless creates a new namespaced endpoint that you can use to create and manage custom objects. Learn how to use CRDs to create objects in the Kubeless documentation on the [Kubeless website](https://kubeless.io/). diff --git a/docs/serverless/docs/021-details-managing-lambdas.md b/docs/serverless/docs/021-details-managing-lambdas.md new file mode 100644 index 000000000000..485e90f60dde --- /dev/null +++ b/docs/serverless/docs/021-details-managing-lambdas.md @@ -0,0 +1,17 @@ +--- +title: Managing Lambdas +type: Details +--- + +Kubernetes provides Kyma with labels that allow you to arrange lambda functions and group them. Labeling also makes it possible to filter lambdas functions. This functionality is particularly useful when a developer needs to manage a large set of lambda functions. + +Behind the scenes, labeling takes place in the form of key value pairs. Here is an example of code that enhances a function: + +``` +"labels": { + "key1" : "value1", + "key2" : "value2" +} +``` + +For more details on labels and selectors, visit the [Kubernetes website](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/). \ No newline at end of file diff --git a/docs/serverless/docs/030-architecture.md b/docs/serverless/docs/030-architecture.md new file mode 100644 index 000000000000..24379d5a7c5f --- /dev/null +++ b/docs/serverless/docs/030-architecture.md @@ -0,0 +1,54 @@ +--- +title: Architecture +type: Architecture +--- + +## Overview + +The term "serverless" refers to an architecture that is Internet-based. Application development that uses serverless technology relies solely on a combination of cloud-based, third-party services, client-side logic, and service-hosted remote procedure calls, also known as "Functions as a Service" or FaaS. Developers use lambdas to create this combination. As a result, this combination replaces the common use of a server. In the context of Kyma, lambda functions connect third-party services and Kyma. Developing with this serverless approach reduces the implementation and operation effort of an application to the absolute minimum. + +## The Serverless architecture + +The following diagram illustrates a generic serverless implementation. + +![General serverless architecture](assets/serverless_general.png) + +The application flow takes place on the client side. Third parties handle the infrastructural logic. Custom logic can process updates and encapsulate databases. Authentication is an example of custom logic. Third parties can also handle business logic. A hosted database contains read-only data that the client reads. None of this functionality runs on a single, central server. Instead, the client relies on FaaS as its resource. + +The following diagram shows an example of tasks that lambdas can perform in Kyma after a user invokes them. + +![Lambdas in Kyma](assets/lambda_example.png) + +First, the user invokes the exposed lambda endpoint. Then, the lambda function can carry out a number of tasks, such as: + +* Retrieving cart information from Enterprise Commerce +* Retrieving stock details +* Updating a database + +## Open source components + +Kyma is comprised of several open source technologies to provide extensive functionality. + +### Kubeless + +Kubeless is the serverless framework integrated into Kyma that allows you to deploy lambda functions. These functions run in Pods inside the Kubeless controller on a node, which can be a virtual or hardware machine. + +Kubeless also has a command line interface. Use Node.js to create lambda functions. + +### Istio + +Istio is a third-party component that makes it possible to expose and consume services in Kyma. See the [Istio documentation](https://istio.io) to learn more. Istio helps create a network of deployed services, called a service mesh. + +In Kyma, functions run in Pods. Istio provides a proxy for specified pods that talk to a pilot. The pilot confirms whether access to the pod is permissible as per the request. In the diagram, Pod B requests access to Pod A. Pod A has an Istio proxy that contains a set of instructions on which services can access Pod A. The Istio proxy also notifies Pod A as to whether Pod B is a part of the service mesh. The Istio Proxy gets all of its information from the Pilot. + +![Istio architecture](assets/istio.png) + +### NATS + +The Event Bus in Kyma monitors business events and trigger functions based on those events. At the heart of the Event Bus is NATS, an open source, stand-alone messaging system. To learn more about NATS, visit the [NATS website](https://nats.io). + +The following diagram demonstrates the Event Bus architecture. + +![Event Bus architecture](assets/nats.png) + +The Event Bus exposes an HTTP endpoint that the system can consume. An external event, such as a subscription, triggers the Event Bus. A lambda function works with a push notification, and the subscription handling of the Event Bus processes the notification. diff --git a/docs/serverless/docs/035-programming-model.md b/docs/serverless/docs/035-programming-model.md new file mode 100644 index 000000000000..35b2af033c6d --- /dev/null +++ b/docs/serverless/docs/035-programming-model.md @@ -0,0 +1,76 @@ +--- +title: The Node.js Programming Model +type: Model +--- + +## Overview + +Kyma supports Node.js 6 and 8. The function interface is the same for both versions. It is still best practice to start with Node.js 8, as it supports Promises out of the box. The result is less complicated code. + +Please set the runtime version (Node.js 6 or 8) while creating a function. + +In the next sections, we will describe how the system creates Node.js functions. + +### The Handler + +The system uses ```module.exports``` to export Node.js handlers. A handler represents the function code executed during invocation. You have to define the handler using the command line. The Console UI only supports ```main``` as a handler name. + +```JavaScript +module.exports = { main: function (event, context) { + return +} } +``` + +Kyma supports two execution types: **Request / Response (HTTP)** and **Events**. In both types, a ```return``` identifies a successful execution of the function. For event types, the event is reinjected as long as the execution is not successful. Functions of the Request Response type can return data to the requesting entity. The following three options are available: + +| Return | Content Type | HTTP Status | Response | +| --------------------------- | ---------------- | ----------- | ------------- | +| ```return``` | none | 200 (OK) | - | +| ```return "Hello World!"``` | none | 200 (OK) | Hello World! | +| ```return {foo: "BAR"}``` | application/json | 200 (OK) | {"foo":"BAR"} | + +A failing function simply throws an error to tell the event service to reinject the event at a later point. An HTTP-based function returns an HTTP 500 status. + +### The Event Object and Context Object + +The function retrieves two parameters: Event and Context. + +```yaml +event: + data: # Event data + foo: "bar" # The data is parsed as JSON when required + extensions: # Optional parameters + request: ... # Reference to the request received + response: ... # Reference to the response to send + # (specific properties will depend on the function language) +context: + function-name: "pubsub-nodejs" + timeout: "180" + runtime: "nodejs6" + memory-limit: "128M" +``` + +The Event contains the event payload as well as some request specific metadata. The request and response attributes are primarily responsible for providing control over http behavior. + +### Advanced Response Handling + +To enable more advanced implementations, the system forwards Node.js Request and Response objects to the function. Access the objects using ```event.extensions.```. + +In the example, a custom HTTP response is set. + +```JavaScript +module.exports = { main: function (event, context) { + console.log(event.extensions.request.originalUrl) + event.extensions.response.status(404).send("Arg....") +} } +``` + +The example code logs the original request url. The response is an HTTP 404. The body is ```Arg....```. + +### Logging + +Logging is based on standard Node.js functionality. ```console.log("Hello")``` sends "Hello" to the logs. As there is no graphical log tool available, use the command ```kubectl``` to display the logs. + +```sh +$ kubectl logs -n -l function= -c +``` \ No newline at end of file diff --git a/docs/serverless/docs/040-cli-reference.md b/docs/serverless/docs/040-cli-reference.md new file mode 100644 index 000000000000..215b200650fa --- /dev/null +++ b/docs/serverless/docs/040-cli-reference.md @@ -0,0 +1,91 @@ +--- +title: CLI reference +type: CLI reference +--- + +## Overview + +This section provides you with useful command line examples used in Kyma. + +## Prerequisites + +* [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) 1.9.0 + +To develop, deploy, or run functions directly download these tools additionally: + +* [Kubeless CLI](https://github.com/kubeless/kubeless/releases) +* [Node.js, version 6 or 8](https://nodejs.org/en/download/) + +### Set the cluster domain variable + +The commands throughout this guide use URLs that require you to provide the domain of the cluster which you are using. To complete this configuration, set the variable `yourClusterDomain` to the domain of your cluster. + +For example if your cluster's domain is 'demo.cluster.kyma.cx' then run the following command: + + ```bash + export yourClusterDomain='demo.cluster.kyma.cx' + ``` + +## Details + +Use the command line to create, call, deploy, expose, and bind a function. + +### Deploy a function using a yaml file and kubectl + +You can use the Kubeless CLI to deploy functions in Kyma. + +```bash +$ kubectl apply -f https://minio.$yourClusterDomain/content/components/serverless/assets/deployment.yaml +``` + +Check if the function is available: +```bash +$ kubeless function list hello +``` +### Deploy a function using a JS file and the Kubeless CLI + +You can deploy a function using the Kubernetes and Kubeless CLI. See the following example: + +```bash +$ kubeless function deploy hello --runtime nodejs8 --handler hello.main --from-file https://minio.$yourClusterDomain/content/components/serverless/assets/hello.js --trigger-http +``` + +### Call a function using the CLI + +Use the CLI to call a function: + +```bash +$ kubeless function call hello +``` + +### Expose a function without authentication + +Use the CLI to create an API for your function: + +```bash +$ kubectl apply -f https://minio.$yourClusterDomain/content/components/serverless/assets/api-without-auth.yaml +``` + +### Expose a function with authentication enabled + +If your function is deployed to a cluster run: + +```bash + curl -k https://minio.$yourClusterDomain/content/components/serverless/assets/api-with-auth.yaml | sed "s/.kyma.local/.$yourClusterDomain/" | kubectl apply -f - +``` + + +If Kyma is running locally, add `hello.kyma.local` mapped to `minikube ip` to `/etc/hosts` + +```bash +$ echo "$(minikube ip) hello.kyma.local" | sudo tee -a /etc/hosts +``` + +Create the API for your function: + +```bash +kubectl apply -f https://minio.$yourClusterDomain/content/components/serverless/assets/api-with-auth.yaml +``` + +### Bind a function to events +You can bind the function to Kyma and to third-party services. For details, refer to the [Service Catalog](../../service-catalog/docs/001-overview-service-catalog.md) documentation. diff --git a/docs/serverless/docs/assets/api-with-auth.yaml b/docs/serverless/docs/assets/api-with-auth.yaml new file mode 100644 index 000000000000..da9e6998c5f1 --- /dev/null +++ b/docs/serverless/docs/assets/api-with-auth.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: gateway.kyma.cx/v1alpha2 +kind: Api +metadata: + labels: + function: hello + example: serverless-lambda + name: hello +spec: + authentication: + - type: JWT + jwt: + jwksUri: http://dex-service.kyma-system.svc.cluster.local:5556/keys + issuer: https://dex.kyma.local + hostname: hello.kyma.local + service: + name: hello + port: 8080 \ No newline at end of file diff --git a/docs/serverless/docs/assets/api-without-auth.yaml b/docs/serverless/docs/assets/api-without-auth.yaml new file mode 100644 index 000000000000..5245cf09d9fb --- /dev/null +++ b/docs/serverless/docs/assets/api-without-auth.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: gateway.kyma.cx/v1alpha2 +kind: Api +metadata: + labels: + function: hello + example: serverless-lambda + name: hello +spec: + hostname: hello.kyma.local + service: + name: hello + port: 8080 \ No newline at end of file diff --git a/docs/serverless/docs/assets/deployment.yaml b/docs/serverless/docs/assets/deployment.yaml new file mode 100644 index 000000000000..7f37bebbd4ea --- /dev/null +++ b/docs/serverless/docs/assets/deployment.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: kubeless.io/v1beta1 +kind: Function +metadata: + name: hello + labels: + example: serverless-lambda +spec: + deps: "" + function: |- + module.exports = { + main: function (event, context) { + return 'hello world'; + } + } + runtime: nodejs8 + type: HTTP + handler: handler.main diff --git a/docs/serverless/docs/assets/hello.js b/docs/serverless/docs/assets/hello.js new file mode 100644 index 000000000000..79f53c179b2d --- /dev/null +++ b/docs/serverless/docs/assets/hello.js @@ -0,0 +1,5 @@ +module.exports = { + main: function (req, res) { + res.end('hello world') + } +} diff --git a/docs/serverless/docs/assets/istio.html b/docs/serverless/docs/assets/istio.html new file mode 100644 index 000000000000..1b15a58f83e3 --- /dev/null +++ b/docs/serverless/docs/assets/istio.html @@ -0,0 +1,11 @@ + + + + +istio + + +
    + + + \ No newline at end of file diff --git a/docs/serverless/docs/assets/kyma_connected.html b/docs/serverless/docs/assets/kyma_connected.html new file mode 100644 index 000000000000..38b2a7ff3967 --- /dev/null +++ b/docs/serverless/docs/assets/kyma_connected.html @@ -0,0 +1,12 @@ + + + + +kyma_connected.html + + + +
    + + + diff --git a/docs/serverless/docs/assets/kyma_connected.png b/docs/serverless/docs/assets/kyma_connected.png new file mode 100644 index 0000000000000000000000000000000000000000..1addee3b259def9fd7df9cd450347c88a0f8add6 GIT binary patch literal 5332 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 + + + +lambda_example.html + + + +
    + + + diff --git a/docs/serverless/docs/assets/lambda_example.png b/docs/serverless/docs/assets/lambda_example.png new file mode 100644 index 0000000000000000000000000000000000000000..ef59bb9501bb043efae659680f13dc451bdf9dd9 GIT binary patch literal 23766 zcmeFZXH=9;*YBx-0+K;Mat4tkIp?I3L=?d$XQdlxkR(Y2Bqvca;w=gWazoS5M9EnM zfu_khXUXAI<8$8gyl2g5tcQ=y73!+mRkinT|93^+*ViVy#(3@0rAuUY?`S-@ zbP3P<(xuD7L|4Htt2q+&moBkix~rl3(8ppei}3Bk=Dj}@*FJK7V86R%?ZZiNMbxUt zR5T|-*0DK1)gz9A7SWqYi#QW?G^7Imic7u2nOc81SX4ZZ?tk957EJvr7rwceNJQF4eeMw1) zr=~(gbeZG61YY-a1iC0MHETB?qiEY5m@Rsox`VvUgo?q)u4H(o7dd4-G{jxX{;&H! z=n{0$dB^+PA+fg+?RC-$+pl)g$I7!XN72$|k0t-@;p_w*+8+er`-?J&f^~^wbST*v zoku#^#BXSpW~a2VP}u)!2Rp&C_6Iw-^gJPA{P8D>qNN}=b^ocHFawvG)c z{`TF!I>bH-?OiJkSzn%=%#av{o>eBT7$_3|ul`aYN~Nt4@@u_;$l5R)esblT5+iUsVL)T^Pe{bz2 zf~fK2npIB(*Lz*^vT!d6tozs5BO`@tjG-O;78(`v|P*Ylgc^1t*l>LJGU)FH( zMK@JPYYq!A_l9@~F>hzON6K^ymH%-6Yw-UX%`4QgbFOJ8qU+#VDayXx%VFkf^P1+` ze;ZGCs+&h$EM(F(-K*^e(;+^y-yG;&nd{j8{HxpkWq`eOsB6K{b(D;2z+cfXrx( z$sc>QlRTtEQjqNLVMjOmHeSt2ia=={t!DEj#y7~!Cg3X<2c_nR$Gz*3mpR|_Ui~cB zp$d#p`&hm;b?xEwN}AH=e|_=RqfcP>rUlz?M6pS z2Umhb(VO+6Z6c@8+?SWCZ|RIqF;7UMd(|E4x}Ao97s9&MnXHiRL$6mHYP5bun+2{5zm|FFgoWX?zSh+k&+d^P za|XU&gU;XyBb$lJOB(D?$DR9h`3xAb-#_`jdma=U8{a~KA8a!1vdre7tS5c^G#$f7 z)6L4{D0M`L=;cj2MQ4GTkOspvLjM^=^bC8Z4&|4#J{h%|O|qC}WWG-us&a{lyW3AF z{W5`M7%zAC_fN+<9JB6UNeS-Jc~kgX?M)IoG%8CTTR&Oi68_Z!oe9c-jgLXUvwp7V zRg3;aUnywC%a);pcX!7k+M$0zBZBqf86;HVn1=xbQPkcvLE`9LNrdxw`n#_>-=3%S zu6}seb+sVBh$Gr2eU~%kVf8>f&%pQUL?|dQ~SWro*=PN?j#lVMOu;R;zmhD4SP5b<;|Px2WP{l ztfJ_%wR@x)b-n$AqVk?u%dzy!ckYU~|8vBT4pre1AABOwTyzgS1UH=2O5b~P!&Ow_ z700&`knw_Ab-w(kUVBHs*`z;F@vN&wy@r0J``gfR&XfZ9n~R1DFm59$@qC+E;w3R0 z!5i^H^m|mt=>>162wC`Q+TJk{meb(<{yTE{4!fn|#=olXRn@I(sJ$NyuZK>%{Bm{B zF}J{ody$3NUCb-VhXOT4;MULd{JzP&1aIbR=(;6JxR@<3-WL|~R`97Vfdh?qNVb^# z`MVa0r-Q@l$7w9X6^xHb~T@)O+Cys#EQNxW~Cq@dNemd>KKR6T#itFXXZ zxeP|K*m`>Ls}^yl`*pb&ur+y9IdLW1GHAS&!V)qm`nbb|eg&yaA(AnLjyLMC9RfvAMZdi= ztZUuae5!3+Q+Uv310gH}hAi=(2pD>?zi#e&KK@>vLUCXZ;A?NegZhn)&gqg7G2PBWv zasrmH6-8rijbwx0AB*hg*~Tjlu2=8qGbz3(6DqF9i=Ee@#vh78J1U7|CzH9Wbdm9{ z@nVoKo}~e=$01U6rw&h?6=Ze`cG+$ z&81kngfqKG)NKAB@meb%8oA4tWp{qq8^p-6!0j6*es}+|&`V04m3%~8&2~pVQ)+Wp z(!0_%(81+}=ID{_P7&ARwTkRSbEV}kIiqg=#a3A|b8n4SyTp5+Z(uxn6^AAn>Wq|* z7P(q`RYR^~#=IwBY=H;XFPg@ddNZ>2{6BvXshg0m5NAT^9R8t_ zo>g&_ufPnx-$2MV48|DR7*63$^D~%`b&>9~Ywcl6kJ2%;!pHw`E=8D0h4-W9%AnVv zsbi%eX;S?!9TD>kQI%r7EVezgoTsvdfQO}M@xBLEewvs;E5Y_Vq!PG7a-HWXBV*{&2yTM<)01Q+*fUh-#OXo z#h#x|oL4q)g1J&oW1!Gy5p?RwC7_lYdPVxydfTWq+lJ)0AM|rD1nsppdQ7zCztb%( zPukuQf+?sNUml0poiqOMU9Vj53Zx-CvEy*Pi7@Fn7Us7&6ir$$x^6i=pm|BNS!!pQ zm@jj(%6~r1akRRt`kr+yZ>P>Y?d9_QaX+;0^;(*tEU%IF?%qS6&SP>aslXGTq4$Tf zz-?3ZG4q{!w~jA0H}1FLIa9s+OK1I{o-_-y>HBwu(v_0Rz(OEnEwzy8A@_xfCsQ%RC?crox_!LjUNM*zqg(M;;=BY3%HGgC z67(AQ)4UT^*C^{_E~rdp=SIega4Ui7=8}juCSGaD$*-JG?;#gsx-QfGu_bk;C03lS z#s8F)LF`jc8c%k6E{iGEQCRQUC<{ZT`S zIR~TygLXLTh4o!OVS)^VLgP90rnzZl4!G7d|kpE-|K_=ENNsrl; z%E_*CA;Y!y$>UR`a?qqY$%eqlR_%v`(>e7(m-$rE>;+ach3N>cUwrGyH674hir*hK zw`NvB^umIrwNxgP1g_1mmv;*({zRg)gU&F(kXTcQC;K^~uS4Yg@n$Kj&m|0ylj+Wd zCHUkr^#Q+TyL;_4azpNh*Oh!pkQ}y8G4C=kv7kuDpJMik_aKjVJ#F> zUH8BEgPjfxk;Uj1v9e|I4TYM`E&qe1ylm!_w%he&VnKou5u{?4f;a7c=qvttm~uLE zZb~o(6+M{^xfVUPKM~|AQg>2HV{$_7?F$Pble#@lC|Dh#e6AW6Gwgz1n-{aOBbky( zEAu~{bKD^fX2UGdZ7s&i8Wm2l2XGb;njxPT!%{9xDATf=>Kzo_=8 zBfxEYVdtacdW7a~KnjJUaCNcX33%a!7_({cF<9%6fNhXe|tuh`_QeL=*8L(}Jg?X^QjvD!F69iAmk;n-o# zW|pPh#injkN+fzXIX3wVs?%IXD~@?d%&kwH-eNS-cXC=wA_Ot;K&R5+V_-%cbP8-` z3v{T{{fWBC18VOuOsd32(v#3rytf&bIq~-!DU?w{tOw6ewpxeY%WpfXoz7jN;}vmG zI$lM{rI4KX);>5Ow#3YDV`SrRF$jp9ObTw0Awx*A*#4wN`=4Hy^VUGViD8?L735&J zH%_R{7Enf$Qf)a>_S6VX+nG#1O?!U0*~n>;jj}zKxpnE|bey1ViOfP&%cq_>-sbPQ zg6q{zA+BIUDxop(CvwO*eMKVdRtVht!1rB>erW>hCp@yfkT?|kh&u^d6neZsr}WbQ z>~LWn|GGBb0)H)J(`#W)6W&f%R?p zPse1Y={$Px2<;=s=qL)dh;sX6(-v5oR&P$Ji47ap$hn|AS^k@fR*2wmocy}e;Zg8+ zk8a5nk{fAM$S3W05Ipno+y{Y6@q#h|ZzN|#ulMbfz&ymgM_*r&*AQcixe?#>h2Hy6 z%sZmP2A+)iK1#Y)nJtfJ6OU^2D|PTsm|~upav@{?QpNFGF5* zxLKd0f8bU#`HEzae8cx(vylP~^SSq2-{J9Eyd>`bNl@?x<$R-S@s-Zi#-Lk#pL_ZIkoS6J7nmVI zr+>z#o4IKeJ%)ZxGtheLn%LpFR?gAQY|20gs<$xDb}A6VM?A->b-D_fhi%feJ)@F} z@bKodM2Zt-HpWRpNTwiNQsNbSK#`DMIT)E)KY*h8u_4~w`Tqj$&jv*ctg#FUR>*qDRm z4dL{2LN+k4v%AN$zoMv4nT)L)Th39>jjK#m^NO>*#>~bpzi=hHj*b^{`z^e^hbbSFgKD`jLC8Pk@0!4>b5k#p;YFtCe#k z`gI63t#{u@HT>{xtF=Xt=__&7YHN&?Rib@j<=N57nA$tEd2>d_t^({72`vkv)8ysm z6?bts6lXn4ki%Sc5fY1>W6Bn~^zHe}uXo7|xVQ z@lhkYeX-eGn6R?BHT<>BVf!JESieGEG~WK6Fyp*U0zI_)s--M%V@Z~O@6+9s<&znI8G|fm>C@2d znC1r`7n{aTH--jw>(Flyem5lVi&(ty)8F6T=`^$o3BF|h z*>h@)r_?NVdF~kbin}%5ImpN<8!V=D-d4ju``3I~3f<)2c2fPm>6SntANIbDt^6{! zIB{Qd7s=rnwHB_8-V8lgo4~6_GW@tTal;T&9I{XTXPdUg+EA-$KDnhHgZ&#nGiFB1 zUp2Gs%+`g9hWU8x22&{iDW6JI@t4{3aIdX8Q(UdH4nqXHrBP5ly`?`oCh;xTA)c_& z(|I@8p++vu+45I1PfZ&SEc=KC@0|(=3?1Qp`5I;ej^-c;226n8LhTYkCW>kMg**XZ%5>>%IjZxb90{z6^!G;dxS?MgsX?5J4@$@W>O~CBOeUJ$)v-@Mu_* z;$XnVfl1Kf`5#_O(Iw5oc6?4kv0y-WhCZedk31Y}zX&rQ5o+LHjeJStou~NNh-Uh6 zyl)dL76V1_gfta#YJrA6YP#cji-20|B3{;&QCaT!SKMn&jrTsUJ1*#3?)+#< z3{a83k-PvE&z}oSWk5xZS3bS|Qv2Dn!axBt6_>F=Xv+Bi$vrN0;m*$rSr~-brCn{2 z*wX)_d(Lt95Sxsix;i5ikL>uYK*KPq`tPOe|DurGuGelbgY?ID_vwY*OY_r-Pz$4B zf%7g-$_V^pW18d^76CA(@S46wwGT3XZ<%&`T<{Bg6}abdy7ad!W1KylM5d>AT2Kr% z>qv{r`@eHr(-|oRX7p*zC`j!C^jT7@5UWf~JLgT={3;BVQf}!XN9^ zKFz<*@u8hdv*E1(uTnI@Q#BR;WRwm~_M8qa)z4W&&0@)i%~X`x0^}uEzi{GBBbv-< zmvaC475b7lUP9BTHllZ0C7qHon`ix3ljGq7O#%tuhg49qM{w|-DE0iy-y$yFqgnOz ziv;dHdVOz2rV%m;yaY{-k!Xo6)7pnrm-Sd55kS-*F<;)QoThYsd5e=>NoVv?q6_0K zJtC*=J4O<7(FcO$pNKDSnNADq`6r7!(ojj#rfnF!PphrsUntYW_#uiYqLJ89-+h zisvUgk$V}b!Ngv>Kh-HPWYVKn6iNqHNqdiyv|oL?|2Q`8<-zD8N5W;|i~06WBM;m* zBwTmm4!Gh1v`2P9@GDGx8pNVVgkg@B{Z0FDvmgXTd^qk6leKww@ZNtR!&}|dQ)v*-fI*f&A}o|ZI;49#rb?dudqP^59eT5ScT`S0=*2W7UT6uzV}XC+Hqwd z8Xp@pu_fahSzG~Zv;scUf|+ekTC2yWD9g(Z909D?z;Eu|W3zy!hk!D!7B`Y3U`R|$ zkIW3|F=%U6F?77zw_q)aSMQd|MxQpDC#>#dTZiu#Sp3ME76@>Vks! zCWTRxcznCX@p2JTC+a_u0JC2uXLd4B*b1eII%qU@-x#kUi3{pg+?Tn0(8d)%+?6H{ zTPdjUQlo*q866SkPau9cIGcDWVwv*>Sn{pZIMk_Ca9fB#q*f&emKiZ3NGIP|Qrd+r z92=G%d!9t2m*G zaHTw8>0xV#UL_nC&mY`7S{ABW{3z@fu6R9k3;@%$CR%K#-SzhB5}xXI-V9X9V-xEu z`%ik`E1GAVpKb(6`5(Apt;1Q&_BH{HeO?p*{Cr^&X`3b@KC2y!?+;==R16DVJf4!> zIb}4pH&36zyD*hz%AYnSqE5fzT754p3rd|i;QVyoMc@r}z*;_{r<6h50Tsi< zNvay2T6w&VUV&<_*PWl~?{a*r^4?fKGWFkT&Zf|D5)Ia&gRULDniP0QQSv(C(?q8*y6bedinxX@mw98%%p zRezZfB{Yc3&Jc@29k6KyR;8iP&v9(ZO#awO~t{cJdI{F`yPZ_F}aX`H#fBJcH_^G*JEI9uRi(@4DX+2ZR4*0a9A(?3HM0Qyaw z);JFp3X8Y*`p?+%(TlX%a6aevTP+DcJv-irR2C@yq(W!tJ1f6Y>5)j{y<}%D=bc#) z#v9M6^6(vR2-qR))OggR|4!*wyzRzmN&!`9mQKAV!g|+@a`m&G;ZktrFf6i12s4Uo z9?W6ENUT~p(^6?)jfNd;Z_v-Yn?vAGWTp`_8@8S zN@l_1o+BajYH2Jh&3J^~*D_b>I=xgGuy^ra(L)YKzob*A;?@J+`%*RNsH{$ zVv_Z1QzK`R>Iz7K13WC1C_b3#Wnc6QOvRHY4+KQjauRxmaTIuvm6Ifbw>S_T6@LEpp&|C~(h<{uZ zx?=oACowl`Tc`Mf*T_>ozIjT%2&q!z2-?yIU=_-uQDSnfE};pDP_~RGWu;KeWz(Ra zP>jB`{QG-~IamgfzwQZOkXV-$sQM@^qxb%pcag2~bV7j{Yh3l>$LOjxLspBJ*x^=> z70M)N7U%Y2#;WN*aH>2aG5;y&rlb9h4FYD%*w;U2nB)&?O`F&#ZsjUX{AiH2VJD?Z zC|kJPR!vS0$aef1Y9>zqWSi2-g2dC=INCbImEzGIVqlI&)ORYUF5)ppe*ul=vA6t2 z(CMbgbtX=*AUx*lI=D(iaD93SWL%3M8JjlavYY#o5PxL>%l`87vtzXD>q#c=WQ!M1 z_l0Pp1xvwZ!x4H=B~t1E78aBrs+iu>ZkN(3tYz+UQ{NAnEIH3o@gAAR)3hR1b=|Fi zQailEXC;zDX8l>7D}6cPG{OEPTK~Ya{kQK95l*;v;>Th1=jGj&D1v+o9DCop2%euJ zfKyXN^L*fQkj5wFpzs^B0s{xjq8hw5NnJKZg6RtBHUoAZ9Ke*}T)0_D?M=2Vduu&c zFJr#cD&o3jA!_;>-#Y4M!;tq@0a>Lb-|PA(E|qi7R<>$N{ME074LL=(EsxjutqrBo zin|n|m?WGMuu8FXwZ<{9`?V{Bmb2_05p8zIZ}ZK-I0QtPY6v!CoAzhGGm_W6Hwe?( zIMiRGV2{P#M#v+d9qOB()50dDo-f`{nd%HEXiy_avb)?4 zEM_QLc!L0&Wo9R{_#q@Si%4AfPQufe5qes0yI`QB3-rBwDak+y?l{*r+{c%8 zw+SlY;XU#+5A*UeJ`ma%P7PbJ<9!FAcJa%$OcT&$C6h)o^?yy`likBn7LP8`bzKJR zd%F>!Xn>dbrxmf)|5u zI7{Zk6aooFv-!)(*S8=DC9V{-P^Z_@10A79ep2z+Z?y;5kC}B`D|5Y_YgH?S!s;!y znv}tZEpvF96SV=($I}n4Vib^1`0dVE19lCae}DT81#jl9fKz=5Yhh9HN69NMkSHPk zRL{QAk?U(ut7i*3>8t5#COS)8!YApWEnSP=+`*PY$2d)7qX`|Lv>wsJb>mjI@xFm> z{$SPib{N;q91jVB_7CSM>b5TR=Yd$~bRKD6X;&|+&}8&AEQS$+v>W~yR?r~}`WRW4 zA|@Pgzqn-hjjNfkx9)odj0`qB!j^^sz7zV)_T zU>#?{b;(FL=7s(Dl%3F9o`COgXyK@e>|ZL=lr?@u?SmjdIB&0HFkXpzdBE&sWoK-9 zG41TBv||DEp@6gFrs315Tz@J6eRhQ@@$3QD$q^hLxOpFJw<`Eth>nYw2}H|0;|9-) zxPUPFe<2P$&pVj%CJ7)u_6@mO!65J?WW15~i0)zmHf=KG0?$%s#x2Mm=^yzm0V9&H zAqiD61sLciiIn8E>!7he>)Y~6FYLL4GmeoMJnJul>c3zvs=tB9ai0#)G*cnhAln7F@91tYM1w$r zRO&`j(g%{qPr;lCmvkLh(AfEAV?=joMr$Q`C+Dwsa&1e(sb8C2CrMjRFBYe-!9|*b zR|{PyYP~?9G=j5I*V~R=sn#IMj5`jTrW1O?&3Yhe+hQ_QXbua$LieSApODSp3Pgih zxWF{BPi+p!M#^weop9A6r#z6SP*;e&CC@t%`#pkuYW_1*a7*@WYFk{#g}FV7Ghded zO=S0UTV{XMBR1$Fc6$nfzG!n0UFdHH9885Hh`&2co*%7<;9PiU$6NkEu!1(pmf!-| zuX*pEMRYqgw3OJkj(q!G_+%p$#Mn81mVbBZ%S?qf!AQO~CT>(aM(hHvSSp+)-{bHk z`GEO>nVpXP#W37z6#SF^f}m8)HwL)MbWfZ({D6`J)=c|2KUM&X0KsOVImi(XqP<;h zJNJORrfjE2zE=vYHfSK-F*kMv(PbId1>J3IYER2Yz5qI0ZWIM>BMsK9-0l#7fr!f+ z@(K7yHERHMtZzhA%)~2?pDHU$kGiSL&nBotLEKo1(;LWntvVSfpO(=)t;MNHyub#G zrP5}$?o~R@Pn+#53F3wRqdzN-6bhR!{{X>@r$;+a z%J4totm#o}BiVeqWg@jl^K3rp-MrhSKNbcIblrbm^!!o*&eGTgl3GE`@C@iTzSjWJ ziBzw`j!Zt~U8v`g_%pjusbMSpeK~AKyux+_%?lvAK!B}u80TDoui0wkH1bU?~Xt@@A`Z|J@aGLXWWeS%WB1JP?y1=aM0E@E5=C z^1W+L{K**m!aKh^RklCvcYfM&5sfd{){7H!T1+P9{K6edSE;}hPk47&$?TT?`?f9L z!&;{EkUj>Q$SBG?_;|XkrZ;DlZrFkkFQU0abX&s+1l`W&j(&6;gEfBM$IIV~poyAv zqCFUn0TAKK;KUu!H2Y+@Y3U+Tu%Bc)T98>STJ1GOFcJuGuuYLPFc8XCSO%T`w(o6Y zTn9a>KgzLs5h=dTp8!s$bEi;+S(Y37Em;p~pf*a0=B7xtznym6BF6lZ?h*M zLA3bWVI5_91fUwEGg&C>hMI~mJcVQe#I%keWQdl}+Xw2zq4s>hY`S3!oYg*xPlg}x z7=;t&2fi%4z605=O`Ex0TKVn@*bqRBat6X5@C%-g6&vrP5S^nGvc* zLz$u z_b5XRP&D=aIUX=FknQsUa{#@{wy-x+?!18Xk^SBZ8@m@NFBUZHvDHkE1(KMh)hV^B zY1+u(tsMRoQ^)mMNn7=KHbeRvlSEo2>A4#nd($8(@UBwez0fy)0#022Ssh){^Li7XNM42B8T>4Q_% z1@Fysmz~ZDV9jabC1e#1JS>8^H zXPT~nBJTW*=aPHm4qy-(QYP-$Qx-y@G;AJdMbb}Czz8X|n?#PkaP|XzvIeA?`3Om! zI{d4o#jUJU}P$p zDh=VSQo=Tguu-<%sE-xiI%72aY~EPp^G~vg+&k`y3x)hzdQvIT zD#`N;4@BRf1ZYKn_g=r)pY*eqS$%)Zy#njDE7He<)fFgRU7VA<9+$b{CkqYv+yDA> zcbyJaX3%m7mV!~FAcJhrB5l>ceanft1RNU9r%j$KbB%(Pwk_n|;KP_PgPqnolY2dC zCgGX=b+DH*kon&mqsum89FD2t0nUv5+aq^*VUSXp=KQk90hYb+>tq;e`ioSIaQJ{y z4%klHoFKz7za51PA8Kvp(&9XH7f`v?1oI(r1_GUVbvD))+PT zhuFP)5neN#XUiZHjn=TkFF-+FpK;DDBKAMCFsz@;AmIgrXyT{LI}?i~n_ z{ri@`_gR7w038sjd?&BC_mu74m|7qd;88D-&>6lbQ1EawfHI1L11mb0@^uiYxAKbD z!8g$5rc3v;R85*&6S3zFi_j_IEb3*$F{0oWP4>>D*2;zV&L@7;k$jnV)D_JBD}X}Q z0+M)R$!lqj@1KHljrXvsrChJ6F#4+eaR6qYR*ZRNbfP%OO=tam z!LLx@LKG@RALD`}ko5Rg1F)bJboe$-xSjQdd#B`NyAzA6g#htYb|OzD_^8;`saWap z#U@o3)y&!N`=ANKJSk?=rB6WWIKy*wxbHpqkZQaga8WUf_h=u8NW>i{p5|rEhpvGt zj`foa+c;U$;N_qoT;h2Y1>XaK+DKa>mwASP(&48)TzLG`!gr1q3#wLD0Wfs;VR3Kc zaLHiGsPbA{zRj5vIibxA55*-2uWV(=d4B7BW&uiWdMIum57nL7&{-Z#se3& z4Nyh8aF$wX?^WxDydF^5voMmR2P!YJvc8-Gk2y=_3Vq8f$iXRykq26qKq}bNvj_xx z5NJuW8JoY;6n;Ax(r18`KN$C+T_N0vFCTo;2@>_0a`0Ibb)}$5KAF9x{?@G|9~QUz z%W|MU%K9>cRGzfD&14}nukr)U4 z`$-tV&2~hs7%_bR0Y2eDn)X|tCRgg>b4&+kU!k-Ew(%|Ma zrVJY11EK@8%$s*}f1I{_tTsS&o@L%dlEL7IQ`H7>U9FD0spSLI`pd$}{*&x8I?Tmv z3Nm<`tB77kPHby?wAbzV;md4KCOKbaQBWUFhgk^?QXuW8#7?zl?kH>@_JjDS)nHY9 zmlLTS&$^}|z~Fww<>h~~V__exp;;rcF9y)Iu$Hd0o;p+FB~#8Kqdamc_kkNLX#b6Y zBi2^YNVLr2SJ*O@&&pcg`WMx~4irU;3{{w$>LvE-7kjIwB=}fFgcRK;TtNX$6|PVE zPyLS?TyDLKQH{qNGU$3l?ma!*h6 z&FQvr6?eoFAf;Z^vmrPDh=jXZ$h5en;2;s3#tpUjSHn}8bPNc!gTyqA{5P~}*KZ4_ zq1%)A(D{4}@+T^~!ID=lhdMRW6Uf+LNCd?4iHJg3rtzsX=_skv&^0>B5gN#jNc!mz z-RnvXv~DVI*=r{$ifr*2zJeI3dM}^%M{1X`%RG680;`1^8G~38@xyC3K%iYD>}9nN zj6|-GvKQDl96v!-N{bEJ_pO(=;({zt8mC=+>zmniw&DyD8+mN;U7V0F5w`;$v^8Zt z|8;i{%+kRl%0ICb43l)R(YcJ`)9j~AQeKz&S)0!2=RYhQb=0ct->2im zRqSRc9R=^yCTOxdtF^>HuVGc~;@WyR2-+BhN}8x~0;GyBl5Z;ov_=!*)*0Q7p>3la zx7)|zbe;5KkHVI%Jw&JDG?Pg`^_FtfZ(b%Hp5Kw$l@soyRQi%iJbh@|1G z68E#CsUf<_y}msae#crqT{HV2p@92)6GlpdK?;tHT}`V?Q~<@VDO5}#K|xcPnYc^; z=>3aje!h|RYbD*$mYM2)0PK=YB@0|?@8Yz>*vt!ZVBf`<-Wlc4Xj9;bs3B6#M1(!b zMm0JtCzx`7*BouHext{Y)^vz#olW?h!&Xc{--B+rf|B!?*X65kI0wA*8`ru*{0W)c zszxnT*$|eV%jCLz$@3bzj_2y)QONl)s8KD8qDeZ2uc}+C4eI*(OP|&Da zi!qCG&gX$H77Qnnt!7x|j@#D|(rehn+Js>Khw(~LRNfJ^8eCYv-ELDpWX#q(Y|TN0 zkG&2RTe}IzdRy1wj-Orr$GW7-X@BPU=Tl}Zgmbd;SEM2yB0=Jsyg_GBf-Nvmhf!7g zYB-4y5k1m~Y9N!}jv3iUDh#D zYW_@GvQZ$QO;ah&#g(pTBUd8RCa%tXBxUXZ7R$zTlBSPp)ueH;=q^yq$FXD~I4xKLsBS;%1-S}ZZw-d2dSm*E1OKTH z@(iS6oPu2u97yQ^Yf*ch1X5Z^I5bJKqU6g$cAnPpXcr3uBl&{IjMNb$iHAgVXexVB zGUVECTgY$6@{ick6U7(2l&Zl8s+9LpYti*{ukeCqB(FLCqNF|ZXC*0A&THLs*!2$O zu?bsyHo@ohi_i2UV7ClHp{xw;Y6l!0AA3)2Gv4f&WLDxMuF#wPB&J0gM6!j&FsNM( zRyBLtD8+T9r0Gym<7-e47+(TGwUkbQTJ}p+GMUyn?1Km4^^T*99@`&f+>*PMkFG)} z70ry>Ywk~-?gu%pTvu%pvbrWijj!aCAiEdDmlO77j!$jK^*fgjRbfQ?DC#GhBN65* zuHGx_XYK&KNRx|b-~sBFw?y1zdM{gPiD95tw(Md>Ui-^wOOCpJ8Sxca?>X&9(}n)4 z17i3@cBZ3;n{WP6r7U~)yJL*IOUTZUk}iUqyj+`vga;CM_d0ZQW){&HZwRe9g4*`NP)T$Z6qi^gcVS_l{PX?)bdSJIx8Cs%R@a1`RQzyT@DUY@|dx_Y5bM!fG zu2uNDNbmlGleSn}rJyJt&4569toD=Tr-8!EcO%hd#b$vI2|%Dm8~iBi8b~`5*l>L~ z1msOBazIrIN4>gHozCFrAKEHAAVxe8d>5vQ<4tYFZC}Iy>*^%X?|!9$gKH#jQpIlh z1AZ0C#${@v0C?Dz$ybMi3s^UWct{6|A9&;08#!}2z~#a;GLj_J7x3?nO~;o_9ODa& zv9sO+mp9vfPM_k!B|yV^ADG_Y2E6VSY4rcDePNH}&jF1}(moJ<6V?nar;mU4>~;JF zNmJ6Xw@v`Sj7UsS?3*rtUt+;V2=)sy_li3=<{BIL;HlO-badcyGX>*s41aYu|M|H8 zjNE_B@;`I#KMTtL+tu>s#X(9>1CvqxSQVnTVPK^%wKC$u{i@PT_45Q&2*KYxefgX- z;a&R-NIh538k;l#{XIDZ&=u#9!+)zQ_<$=w(7aN4-?B0`=x~ny7|4HNKtv_*&GJ^? zKJI9h9PRi*SbvGFHX0wAC;POD1THq0;#iA5dW9rl;m%6B@S!f4G2bbs2y#y&$`9{4le|O=0O%614ocu?Gn^5=UvgfVu$-u!S?wDrD2x z;8L0aXF$mB)u&01LBVHML7a&IhzIed#Q!rW-u>}sEBpMURcgZDL;lZzQ2|b80`j@o zPj{1Wnr!~BbRE?f5jl!jvLGCm~{Zb^_{k~B~7dtS8 zoEz})+nlz0An?TEPGP~FF9IZKnE5Pk3nt~jYL>u*DHrQ;z=75P)vA!5@b8b5AHmT% z+_^b8p&E{*l>?;>R%+bG)(`Z6xjbDXI1k~MeZ&7O>7X%O0|(YLO1ko?kzZaSF--*N z^M2xHP$4@&{GjE}9AHRDT)MU;j>Q?rs)8K)WPoXA)|&y&1O=xfcmbF=0xEYMaG-L> z8mBopkB?~F;EGrH)pq?Cn{5s5#E<=c1LZLs5KLi#0?y5n@l~f~11L0#aT1f?Xu$Sh zpj_WuY80G9fCQOeR3NT01q(%VN0kbsXMM{4uNslPiyD#D;t*ik7CqTGdF6YbY2pk( z)K&o-cn)_St19nQItm>3nIXdh{i}Iq{W;)h2;AWqQSvrgx=w^HhN?=Q%May&H_PHs z9r*jvV+^o&sNEM~aO^`$))@Gl%y>6~e^%ENjuvyWoN!122EOyXkWP$`cIr8>eJS_Z zJNrjKKRkW=81#|ZDAjPp9>Azg9;nCl8F=NVT93m_JmTTI83_W&J+{rsRR zECrMT?_gzpT0KfKx>6YwggAw}YTzqx^}uQ%Nr}^L3K;Vo@;t@KA%yieZ)Pi3KW>xS z37!Kb6%sz=@iK~(~1+Xy(4!vQ2EYCu89 zxrgbG3+1VqO(ah~AYkBtmT(NHubDa1cbEZIRzPc;P?(S<=mMlURX7Qha8mez6^ESw zFxnumkEHO{UME#3#}i5(B6%@>sO{b=raw}f&P#(CTO@dd6MNt)R9xWp%uV%R_St4Y zOCuqfR3$_?PS)1Bw93Lq^5!tn<5U@8fmI_n_#0@jZxP5SL^^!I@ zD0m3M2B7k>QXNQ)Df+mO;fNICRlfbK|z&w*+!CZt~Ng9b_3 zgV1Y`=_YSnwF4?qa&^20JwqJG`7DwOX_yViVb~0CP8JL(I$U&7)QWhMTa9!FeWms~ zj$Hm`&-*m94(wJ^DVTs^)*={~rx;&FP;kd&P%H?WSIHbxW<^xTD;>2_R_?}YR))|v$#Xg^W5%xo4wTkO*!P`m4oGm58*1zIJh}iCJ_IlM8X$62a*j7$ouVX#W(sW(kSVkH(gJY zA$ZlBU$U8)$R2%NG3DW%(UO3O$=zvk(Q=THKo=)wajXEDUaQPydh<<60l`$9)S`2j zotKI}a{}`8^Sev~!CfM{(kMG*oZ_Ad=x-Jx(i_M29XPl&=UM-?@5?3AcuZD|+4}B0v^V1@2C1 zl9g!gCjK@9`62U6N;_ayX_aC5d}@)Yry%uENVdfn-EOLnoWv2()osp9QYHrZP^v3i zA*e3g7;uK@Z-#1Kn-eJJ9{bwh`9E5-s*BCfhEp&5M2DWRL?{ z%6`;&rB)f)-qu#EQu@=D&xRJRi42V~cy8!G61yg?!QjKLWV*{A*2AZT)m}+dE zVg9jVS5(Y)**M$WFdt|!*O9c9Cu$siceLJlc-bA`&X3}&swWaWIh3D{UgSVW)f0p` zZK1ZLL#=D`S&9G%qI?xD(nJoYS41qxsx!AqalCFQRXTBpHygxV(TJvZ7_OLl%~X|g zz_g`~DizA2a3tVlaP(>9VJQ84(lbHTYKNgT-F!zuS~&;yH80NU8^B?RI136mV;_#- zSe zMX=4(q#4mVRD(!@R6~nyqnWa5OfksRLuogN3R$|R#jr6)8N|`ls)tsbVE-|1=Dxn? zcVFG}zxVU~e1F0to}Y+Uoy(KCehuYaNhS0>+KQh?s(9%9qDRoGb*8QJKc7cHji|^> z@o%$l_dv*_ZqLcaN&CATQKX|@QC=bK`O*+ZuIR-owPT5EW+{@MDDt7fg=a8vk7*o4 z<}Myj7L;K7N4mD7oPgTR1{EV!D~fnL`~Y%04~rpDGifLmri zu@QyFn;A)FSRa*uRGphO>Eo$n+3#+rtX8rfid!L8N6Wso=;i$|!t<`umUzzH$%{<7 zDpn29jHU|R3bk9O*j1kfwnSh@4z!NEdpl#JzW|*H%V2DF>Sp$KB>q2Dz3)IbM%=_d z(lbU&#l-K75qzV`Bj=j!$Hws9dh7LaqFeajgd>K8qRNFq8xa*e$Tlr4HmvjUX?DKS zn06~xJoPdOsRE}~wa+{AgF9_itR+Xoff!r{lBe6%3tHY}d`DPP3M*X=~Rhkgi za(uW?`9GS>Cw&ue=jR{3%8f&b2D{uT2hk3bYe^69f2`%QG8h(R&*poa;uM!QWc1}w zbTaBnTNd&8&+972#>KxcTH?ZE4sZt9UZlVI>bHcbKZ65e8G<<^hocW5`uTS4!2;xs zJQI}^=B{%I8JZxW6J&Mq-R!e+WZdXX>jL)ts3tE@H#tLE9Murt4*0F>6X{N zi~m7-Ooe0usmtm?-UIEN_kZ^OdiM?GGC`&OiCq?PM>*?V@X1isg(Yb(~JgusVV zFvS5K@l=}~wgcubyREj0yzrQ`UK8|padetU*N%mMS=<#wLXS)@fWn&}W5?N&a4YWk z@P+o>CCT86dDOo)734df`toLVOEPm|I%eZ0n-Rovha~piYz9?28I&{ox=G8tF5#h@ z24mdWE_ioFFUJ>CSEwo4<|t@!~9+_SK zO&aC=hHvJWYrZmBu*6zVeJ}Y8wL;AEFBXt)+)P~TNY0 zzh4|!RB$3wZ}>g6Vtz#@d}jU$0v?}+&)_UfC+;boBxD6r1j@&9CPl9 zK9&*>2;yp z73TY;^>K1>CD4k=Sbtb5`1w>?sut2}z#U;WXEP=gfF%m;4J;&teUGoEAUb6#7yv`| zm1>i718O40*FUfbI8alG-2oALA5PHMFlvVZO$QO=``Ls0#$fop$YmlJzNR?aD1Fzk zCFT-R22bkMM1MwQy+BVwqkP7xE`UFht{WW7uGz<(0_)S&$xtwHN|1pHu$4#=3z{v0 zUV`L(SUaY7{Jt=&_q$31WHQw9cHueDMgS9|$PH2r7EJVS9nGtsW1(uq4o(9=Ptvr+ zKf>#6Ldxug6k!SIQYsApvntq^`CLyEGAbPO)}Y%2b@f8_DEXfBw_3e5=+LfwOVJZt zf_S-61mNeNeGTjaqxQ8y%7MDkwtO&ABlm+pDtb-jUuS3ANS=PN@?_Ur@&&n6LfA(rj|*yFJ4d5?$RK>jgIe*Qpa{ KDb3`|dH)ANm}wgT literal 0 HcmV?d00001 diff --git a/docs/serverless/docs/assets/monitoring.html b/docs/serverless/docs/assets/monitoring.html new file mode 100644 index 000000000000..fcf6f154d32d --- /dev/null +++ b/docs/serverless/docs/assets/monitoring.html @@ -0,0 +1,11 @@ + + + + +Monitoring + + +
    + + + \ No newline at end of file diff --git a/docs/serverless/docs/assets/nats.html b/docs/serverless/docs/assets/nats.html new file mode 100644 index 000000000000..99a5fe87b45a --- /dev/null +++ b/docs/serverless/docs/assets/nats.html @@ -0,0 +1,11 @@ + + + + +nats + + +
    + + + \ No newline at end of file diff --git a/docs/serverless/docs/assets/serverless_general.html b/docs/serverless/docs/assets/serverless_general.html new file mode 100644 index 000000000000..968cf33c51a0 --- /dev/null +++ b/docs/serverless/docs/assets/serverless_general.html @@ -0,0 +1,11 @@ + + + + +serverless_general + + +
    + + + \ No newline at end of file diff --git a/docs/serverless/docs/assets/serverless_general.png b/docs/serverless/docs/assets/serverless_general.png new file mode 100644 index 0000000000000000000000000000000000000000..ec54ad9553c3d967d06b7aef900ab74d95cfa93a GIT binary patch literal 15448 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:** This version of the service is based on Open Service Broker for Azure, version 0.8.0-alpha, available on the [Azure](https://github.com/Azure/open-service-broker-azure/tree/v0.8.0-alpha) website. +For more information, see the [documentation](https://github.com/Azure/open-service-broker-azure/blob/v0.8.0-alpha/docs/modules/mysqldb.md). diff --git a/docs/service-brokers/azure-broker-service-classes/azure-mysql/docs/plans-details.md b/docs/service-brokers/azure-broker-service-classes/azure-mysql/docs/plans-details.md new file mode 100644 index 000000000000..de15d48458e5 --- /dev/null +++ b/docs/service-brokers/azure-broker-service-classes/azure-mysql/docs/plans-details.md @@ -0,0 +1,43 @@ +--- +title: Services and Plans +type: Details +--- + +## Service description +This service is named `azure-mysqldb` with the following plan names and descriptions: + +| Plan Name | Description | +|-----------|-------------| +| `MYSQLB50` | Basic Tier, 50 DTUs | +| `MYSQLB100` | Basic Tier, 100 DTUs | +| `MYSQLS100` | Standard Tier, 100 DTUs | +| `MYSQLS200` | Standard Tier, 200 DTUs | +| `MYSQLS400` | Standard Tier, 400 DTUs | +| `MYSQLS800` | Standard Tier, 800 DTUs | + +## Provision + +This service provisions a new MySQL DBMS and a new database upon it. The new database is named randomly. + +### Provisioning parameters + +These are the provisioning parameters: + +| Parameter Name | Type | Description | Required | Default Value | +|----------------|------|-------------|----------|---------------| +| `Location` | `string` | The Azure region in which to provision applicable resources. | Y | None. | +| `Resource group` | `string` | The (new or existing) resource group with which to associate new resources. | Y | Creates a new resource group with a UUID as its name. | +| `Firewall start IP address` | `string` | Specifies the start of the IP range that the firewall rule allows. | Y | `0.0.0.0` | +| `Firewall end IP address` | `string` | Specifies the end of the IP range that the firewall rule allows. | Y | `255.255.255.255` | + +### Credentials + +The binding returns the following connection details and credentials: + +| Parameter Name | Type | Description | +|----------------|------|-------------| +| `host` | `string` | The fully-qualified address of the SQL Server. | +| `port` | `int ` | The port number to connect to on the SQL Server. | +| `database` | `string` | The name of the database. | +| `username` | `string` | The name of the database user. | +| `password` | `string` | The password for the database user. | diff --git a/docs/service-brokers/azure-broker-service-classes/azure-redis-cache/docs.config.json b/docs/service-brokers/azure-broker-service-classes/azure-redis-cache/docs.config.json new file mode 100644 index 000000000000..9b9fad3ab092 --- /dev/null +++ b/docs/service-brokers/azure-broker-service-classes/azure-redis-cache/docs.config.json @@ -0,0 +1,8 @@ +{ + "spec": { + "displayName": "Azure Redis Cache", + "description": "Documentation for Azure Redis Cache.", + "id": "0346088a-d4b2-4478-aa32-f18e295ec1d9", + "type":"Service Class" + } +} diff --git a/docs/service-brokers/azure-broker-service-classes/azure-redis-cache/docs/overview.md b/docs/service-brokers/azure-broker-service-classes/azure-redis-cache/docs/overview.md new file mode 100644 index 000000000000..944eab117e66 --- /dev/null +++ b/docs/service-brokers/azure-broker-service-classes/azure-redis-cache/docs/overview.md @@ -0,0 +1,13 @@ +--- +title: Overview +type: Overview +--- + +The Open Service Broker for Azure contains the **Azure Redis Cache** services shown: + +| Service Name | Description | +|--------------|-------------| +| `azure-rediscache` | Provision an Azure Redis Cache instance. | + +>**NOTE:** This version of the service is based on Open Service Broker for Azure, version 0.8.0-alpha, available on the [Azure](https://github.com/Azure/open-service-broker-azure/tree/v0.8.0-alpha) website. +For more information, see the [documentation](https://github.com/Azure/open-service-broker-azure/blob/v0.8.0-alpha/docs/modules/rediscache.md). diff --git a/docs/service-brokers/azure-broker-service-classes/azure-redis-cache/docs/plans-details.md b/docs/service-brokers/azure-broker-service-classes/azure-redis-cache/docs/plans-details.md new file mode 100644 index 000000000000..9880f0b64aa1 --- /dev/null +++ b/docs/service-brokers/azure-broker-service-classes/azure-redis-cache/docs/plans-details.md @@ -0,0 +1,37 @@ +--- +title: Services and Plans +type: Details +--- + +## Service description + +The `azure-rediscache` service provides the following plan names and descriptions: + +| Plan Name | Description | +|-----------|-------------| +| `basic` | Basic Tier, 250MB Cache | +| `standard` | Standard Tier, 1GB Cache | +| `premium` | Premium Tier, 6GB Cache | + +## Provision + +This service provisions a new Redis cache. + +### Provisioning parameters +These are the provisioning parameters: + +| Parameter Name | Type | Description | Required | Default Value | +|----------------|------|-------------|----------|---------------| +| `Location` | `string` | The Azure region in which to provision applicable resources. | Y | None. | +| `Resource group` | `string` | The new or existing resource group with which to associate new resources. | Y | Creates a new resource group with a UUID as its name. | +| `Server name` | `string` | The name of the Azure Redis Cache to create. | N | | + +### Credentials + +The binding returns the following connection details and credentials: + +| Parameter Name | Type | Description | +|----------------|------|-------------| +| `host` | `string` | The fully-qualified address of the Redis cache. | +| `port` | `int ` | The port number to connect to on the Redis cache. | +| `password` | `string` | The password for the Redis cache. | diff --git a/docs/service-brokers/azure-broker-service-classes/azure-sql/docs.config.json b/docs/service-brokers/azure-broker-service-classes/azure-sql/docs.config.json new file mode 100644 index 000000000000..43235a63819c --- /dev/null +++ b/docs/service-brokers/azure-broker-service-classes/azure-sql/docs.config.json @@ -0,0 +1,8 @@ +{ + "spec": { + "displayName": "Azure SQL Database", + "description": "Documentation for Azure SQL Database.", + "id": "fb9bc99e-0aa9-11e6-8a8a-000d3a002ed5", + "type":"Service Class" + } +} diff --git a/docs/service-brokers/azure-broker-service-classes/azure-sql/docs/overview.md b/docs/service-brokers/azure-broker-service-classes/azure-sql/docs/overview.md new file mode 100644 index 000000000000..0020a7d9c7f2 --- /dev/null +++ b/docs/service-brokers/azure-broker-service-classes/azure-sql/docs/overview.md @@ -0,0 +1,15 @@ +--- +title: Overview +type: Overview +--- + +The Open Service Broker for Azure contains the **Azure SQL Database** service shown: + +| Service Name | Description | +|--------------|-------------| +| `azure-sqldb` | Provision both an Azure SQL Server and a database upon that server. | + +The `azure-sqldb` service allows you to provision both an SQL Server and a randomly named database. When the provisioning is successful, the database is ready to use. You cannot provision additional databases onto an instance provisioned through this service. + +>**NOTE:** This version of the service is based on Open Service Broker for Azure, version 0.8.0-alpha, available on the [Azure](https://github.com/Azure/open-service-broker-azure/tree/v0.8.0-alpha) website. +For more information, see the [documentation](https://github.com/Azure/open-service-broker-azure/blob/v0.8.0-alpha/docs/modules/mssqldb.md). diff --git a/docs/service-brokers/azure-broker-service-classes/azure-sql/docs/plans-details.md b/docs/service-brokers/azure-broker-service-classes/azure-sql/docs/plans-details.md new file mode 100644 index 000000000000..e0493e6bf577 --- /dev/null +++ b/docs/service-brokers/azure-broker-service-classes/azure-sql/docs/plans-details.md @@ -0,0 +1,51 @@ +--- +title: Services and Plans +type: Details +--- + +## Service description + +The `azure-sql` service provides the following plan names and descriptions: + +| Plan Name | Description | +|-----------|-------------| +| `basic` | "Basic Tier, 5 DTUs, 2GB, 7 days point-in-time restore | +| `standard-s0` | Standard Tier, 10 DTUs, 250GB, 35 days point-in-time restore | +| `standard-s1` | StandardS1 Tier, 20 DTUs, 250GB, 35 days point-in-time restore | +| `standard-s2` | StandardS2 Tier, 50 DTUs, 250GB, 35 days point-in-time restore | +| `standard-s3` | StandardS3 Tier, 100 DTUs, 250GB, 35 days point-in-time restore | +| `premium-p1` | PremiumP1 Tier, 125 DTUs, 500GB, 35 days point-in-time restore | +| `premium-p2` | PremiumP2 Tier, 250 DTUs, 500GB, 35 days point-in-time restore | +| `premium-p4` | PremiumP4 Tier, 500 DTUs, 500GB, 35 days point-in-time restore | +| `premium-p6` | PremiumP6 Tier, 1000 DTUs, 500GB, 35 days point-in-time restore | +| `premium-p11` | PremiumP11 Tier, 1750 DTUs, 1024GB, 35 days point-in-time restore | +| `data-warehouse-100` | DataWarehouse100 Tier, 100 DWUs, 1024GB | +| `data-warehouse-1200` | DataWarehouse1200 Tier, 1200 DWUs, 1024GB | + +## Provision + +This service provisions a new SQL DBMS and a new database upon that DBMS. The new +database is named randomly and is owned by a role (group) of the same name. + +### Provisioning parameters + +These are the provisioning parameters: + +| Parameter Name | Type | Description | Required | Default Value | +|----------------|------|-------------|----------|---------------| +| `Location` | `string` | The Azure region in which to provision applicable resources. | Y | None. | +| `Resource group"` | `string` | The new or existing resource group with which to associate new resources. | Y | Creates a new resource group with a UUID as its name. | +| `Firewall start IP address` | `string` | Specifies the start of the IP range that this firewall rule allows. | Y | `0.0.0.0` | +| `Firewall end IP address` | `string` | Specifies the end of the IP range that this firewall rule allows. | Y | `255.255.255.255` | + +### Credentials + +The binding returns the following connection details and credentials: + +| Parameter Name | Type | Description | +|----------------|------|-------------| +| `host` | `string` | The fully-qualified address of the MySQL Server. | +| `port` | `int ` | The port number to connect to on the MySQL Server. | +| `database` | `string` | The name of the database. | +| `username` | `string` | The name of the database user. | +| `password` | `string` | The password for the database user. | diff --git a/docs/service-brokers/docs.config.json b/docs/service-brokers/docs.config.json new file mode 100644 index 000000000000..c58fd6edc051 --- /dev/null +++ b/docs/service-brokers/docs.config.json @@ -0,0 +1,8 @@ +{ + "spec":{ + "id":"service-brokers", + "displayName":"Service Brokers", + "description":"Overal documentation for Service Brokers", + "type":"Components" + } +} \ No newline at end of file diff --git a/docs/service-brokers/docs/001-overview-service-brokers.md b/docs/service-brokers/docs/001-overview-service-brokers.md new file mode 100644 index 000000000000..56503d3b3154 --- /dev/null +++ b/docs/service-brokers/docs/001-overview-service-brokers.md @@ -0,0 +1,23 @@ +--- +title: Service Brokers +type: Overview +--- + +A Service Broker is a server compatible with the [Open Service Broker API](https://github.com/openservicebrokerapi/servicebroker/blob/master/spec.md) specification. A Service Broker manages the lifecycle of one or more services. It advertises a catalog of service offerings and service plans to a platform. + +The Service Catalog lists all services that the Service Brokers offer. Use the Service Brokers to: +* Provision and de-provision an instance of a service +* Create and delete a service binding + +Create a service binding to link a service instance to an application. During this process, credentials are delivered in Secrets to provide you with the information necessary to connect to the service instance. The process of deleting a service binding is known as unbinding. + +Each of the Service Brokers available in Kyma performs these operations in a different way. See the documentation for a given Service Broker to learn how it operates. + +Kyma provides these Service Brokers to use with the Service Catalog: + +* Azure Broker +* Remote Environment Broker +* Helm Broker + +Follow the [Open Service Broker API](https://github.com/openservicebrokerapi/servicebroker/blob/master/spec.md) specification to build your own Service Broker. +Register every new Service Broker in the Service Catalog to make the services and plans available to the users. For more information on registering Service Brokers in the Service Catalog, see the [Service Catalog Demonstration Walkthrough](https://github.com/kubernetes-incubator/service-catalog/blob/master/docs/walkthrough.md). diff --git a/docs/service-brokers/docs/002-overview-azure-broker.md b/docs/service-brokers/docs/002-overview-azure-broker.md new file mode 100644 index 000000000000..e49729f49d0d --- /dev/null +++ b/docs/service-brokers/docs/002-overview-azure-broker.md @@ -0,0 +1,14 @@ +--- +title: Azure Broker +type: Overview +--- + +The Microsoft Azure Broker is an open source, [Open Service Broker](https://www.openservicebrokerapi.org/)-compatible API server that provisions managed services in the Microsoft Azure public cloud. The Azure Broker provides these Service Classes to use with the Service Catalog: +* Azure SQL Database +* Azure Database for MySQL +* Azure Redis Cache + +See the details of each Service Class and its specification in the Service Catalog UI. +For more information about the Service Brokers, see the Service Brokers **Overview** document. + +>**NOTE:** Kyma uses the Microsoft Azure Broker open source project. To ensure the best performance and stability of the product, Kyma uses a version of the Azure Broker that precedes the newest version released by Microsoft. diff --git a/docs/service-brokers/docs/003-overview-reb.md b/docs/service-brokers/docs/003-overview-reb.md new file mode 100644 index 000000000000..fe21f6e39fb6 --- /dev/null +++ b/docs/service-brokers/docs/003-overview-reb.md @@ -0,0 +1,10 @@ +--- +title: Remote Environment Broker +type: Overview +--- + +The Remote Environment Broker (REB) provides remote environments in the Service Catalog. A remote environment represents the environment connected to the Kyma instance. The Remote Environment Broker enables the integration of independent remote environments within Kyma. It also allows you to extend the functionality of existing systems. + +The REB observes all the remote environment custom resources and exposes their APIs and/or Events as ServiceClasses to the Service Catalog. When the list of remote environments' ServiceClasses is available in the Service Catalog, you can create an EnvironmentMapping, provision those ServiceClasses, and enable them for Kyma services. + +The REB implements the Service Broker API. For more details about the Service Brokers, see the Service Brokers **Overview** documentation. diff --git a/docs/service-brokers/docs/004-overview-helm-broker.md b/docs/service-brokers/docs/004-overview-helm-broker.md new file mode 100644 index 000000000000..0e8f537a8abd --- /dev/null +++ b/docs/service-brokers/docs/004-overview-helm-broker.md @@ -0,0 +1,8 @@ +--- +title: Helm Broker +type: Overview +--- + +The Helm Broker is an implementation of a service broker which runs in the Kyma cluster and deploys Kubernetes native resources using [Helm](https://github.com/kubernetes/helm) and Kyma bundles. A bundle is an abstraction layer over a Helm chart which allows you to represent it as a ClusterServiceClass in the Service Catalog. For example, a bundle can provide plan definitions or binding details. The Helm Broker fetches bundle definitions from an HTTP server. By default, the Helm Broker contains an embedded HTTP server which serves bundles from the Kyma bundles directory. + +The Helm Broker implements the Service Broker API. For more information about the Service Brokers, see the **Service Brokers overview** document. diff --git a/docs/service-brokers/docs/011-configuration-helm-broker.md b/docs/service-brokers/docs/011-configuration-helm-broker.md new file mode 100644 index 000000000000..35c4cc463018 --- /dev/null +++ b/docs/service-brokers/docs/011-configuration-helm-broker.md @@ -0,0 +1,59 @@ +--- +title: Configure Helm Broker +type: Configuration +internal: true +--- + +The Helm Broker fetches bundle definitions from an HTTP server defined in the `values.yaml` file. The **config.repository.baseURL** attribute defines the HTTP server URL. By default, the Helm Broker contains an embedded HTTP server which serves bundles from the Kyma `bundles` directory. + + +### Configuring the Helm Broker on the embedded HTTP server + +By default, the Helm Broker contains an embedded HTTP server which serves bundles from the `bundles` directory. Deploying Kyma automatically populates the bundles. + +To add a yBundle to the Helm Broker, place your yBundle directory in the `bundles` folder. +> **NOTE:** The name of the yBundle directory in the `bundles` folder must follow this pattern: \\-\\. + + +### Configuring the Helm Broker externally + +Follow these steps to change the configuration and make the Helm Broker fetch bundles from a remote HTTP server: + +1. Create a remote bundles repository. Your remote bundle repository must include the following resources: + - An `index.yaml` file which defines available bundles. + This file must have the following structure: + + ```text + apiVersion: v1 + entries: + : + - name: + description: + version: + ``` + This is an example of an `index.yaml` file for the Redis bundle: + ```text + apiVersion: v1 + entries: + redis: + - name: redis + description: Redis service + version: 0.0.1 + ``` + + - A `-.tgz` file for each bundle version defined in the `index.yaml` file. The `.tgz` file is an archive of your bundle's directory. + +2. In the [values.yaml](../../../resources/core/charts/helm-broker/values.yaml) file, set the **embeddedRepository.provision** attribute to `false` to disable the embedded server. Provide your server's URL in the **config.repository.baseURL** attribute: + + ```yaml +embeddedRepository: + # Defines whether to provision the embedded bundle repository. + # To provision, specify this value to true + provision: true + + config: + repository: + baseURL: "http://custom.bundles-repository" + ``` + +3. Install Kyma on Minikube. See the [local Kyma installation](../../kyma/docs/031-gs-local-installation.md) document to learn how. diff --git a/docs/service-brokers/docs/012-configuration-helm-broker-bundles.md b/docs/service-brokers/docs/012-configuration-helm-broker-bundles.md new file mode 100644 index 000000000000..8d8fe312db58 --- /dev/null +++ b/docs/service-brokers/docs/012-configuration-helm-broker-bundles.md @@ -0,0 +1,104 @@ +--- +title: How to create a yBundle +type: Configuration +internal: true +--- + +[bind]: https://github.com/openservicebrokerapi/servicebroker/blob/v2.12/spec.md#binding "OSB Spec Binding" +[service-objects]: https://github.com/openservicebrokerapi/servicebroker/blob/v2.12/spec.md#service-objects "OSB Spec Service Objects" +[service-metadata]: https://github.com/openservicebrokerapi/servicebroker/blob/v2.12/profile.md#service-metadata "OSB Spec Service Metadata" +[plan-objects]: https://github.com/openservicebrokerapi/servicebroker/blob/v2.12/spec.md#plan-object "OSB Spec Plan Objects" + + +To create your own yBundle, you must create a directory with the following structure: + +``` +sample-ybundle/ + ├── meta.yaml # A file which contains the metadata information about this yBundle + ├── chart/ # A directory which contains a Helm chart that installs your Kubernetes resources + │ └── / # A Helm chart directory + │ └── .... # Helm chart files + └── plans/ # A directory which contains the possible plans for an installed chart + ├── example-enterprise # A directory of files for a specific plan + │ ├── meta.yaml # A file which contains the metadata information about this plan + │ ├── bind.yaml # A file which contains information about the values that the Helm Broker returns when it receives the bind request + │ ├── create-instance-schema.json # The JSON Schema definitions for creating a service instance + │ └── values.yaml # The default configuration values in this plan for a chart defined in chart directory + └── .... +``` + +> **NOTE:** All the file names in the yBundle directory are case-sensitive. + + +### The yBundle meta.yaml file + +The `meta.yaml` file is mandatory as it contains information about the yBundle. Set the following fields to create service objects which comply with the [Open Service Broker API][service-objects]. + +| Field Name | Required | Description | +|:-------------------:|:--------:|:------------------------------------------------------------------------------------------------------------------------------------------------------:| +| **name** | true | The yBundle name. It has the same restrictions as defined in the [Open Service Broker API][service-objects]. | +| **version** | true | The yBundle version. It is a broker service identifier. It has the same restrictions as defined in the [Open Service Broker API][service-objects]. | +| **id** | true | A broker service identifier. It has the same restrictions as defined in the [Open Service Broker API][service-objects]. | +| **description** | true | A short description of the service. It has the same restrictions as defined in the [Open Service Broker API][service-objects]. | +| **tags** | false | The keywords describing the provided service, separated by commas. | +| **bindable** | false | The bindable field described in the [Open Service Broker API][service-metadata]. | +| **displayName** | true | The **displayName** field described in the [Open Service Broker API][service-metadata]. | +| **providerDisplayName** | false | The **providerDisplayName** field described in the [Open Service Broker API][service-metadata]. | +| **longDescription** | false | The **longDescription** field described in the [Open Service Broker API][service-metadata]. | +| **documentationURL** | false | The **documentationURL** field described in the [Open Service Broker API][service-metadata]. | +| **supportURL** | false | The **supportURL** field described in the [Open Service Broker API][service-metadata]. | +| **imageURL** | false | The **imageURL** field described in the [Open Service Broker API][service-metadata]. You must provide the image as an SVG. | + +### The chart directory + +In the mandatory `chart` directory, create a folder with the same name as your chart. Put all the files related to your chart in this folder. The system supports chart version 2.6. + +If you are not familiar with the chart definitions, see the [Charts](https://github.com/kubernetes/helm/blob/release-2.6/docs/charts.md) specification. + +> **NOTE:** Helm Broker uses the [helm wait](https://github.com/kubernetes/helm/blob/release-2.6/docs/using_helm.md#helpful-options-for-installupgraderollback) option to ensure that all the resources that a chart creates are available. If you set your Deployment **replicas** to `1`, you must set **maxUnavailable** to `0` as a part of the rolling update strategy. + +### The plans directory + +The mandatory `plans` directory must contain at least one plan. +A directory for a specific plan must contain the `meta.yaml` file. Other files, such as `create-instance-schema.json`, `bind.yaml` and `values.yaml` are not mandatory. + +#### The meta.yaml file + +The `meta.yaml` file contains information about a yBundle plan. Set the following fields to create the plan objects, which comply with the [Open Service Broker API][plan-objects]. + +| Field Name | Required | Description | +|:-----------:|:--------:|:----------------------------------------------------------------------------------------------------------:| +| **name** | true | The plan name. It has the same restrictions as defined in the [Open Service Broker API][plan-objects]. | +| **id** | true | The plan ID. It has the same restrictions as defined in the [Open Service Broker API][plan-objects]. | +| **description** | true | The plan description. It has the same restrictions as defined in the [Open Service Broker API][plan-objects]. | +| **displayName** | true | The plan display name. It has the same restrictions as defined in the [Open Service Broker API][plan-objects]. | +| **bindable** | false | The plan bindable attribute. It has the same restrictions as defined in the [Open Service Broker API][plan-objects]. | + +#### The bind.yaml file + +The `bind.yaml` file contains the information required for the [binding action][bind] in a specific plan. +If you defined in the `meta.yaml` file that your plan is bindable, you must also create a `bind.yaml` file. +For more information about the content of the `bind.yaml` file, see the [Binding yBundles](013-configuration-helm-broker-bundles-binding.md) documentation. + +#### The values.yaml file + +The `values.yaml` file provides the default configuration values in a concrete plan for the chart definition located in the `chart` directory. +This file is not required. +For more information about the content of the `values.yaml` file, see the [Values Files](https://github.com/kubernetes/helm/blob/release-2.6/docs/chart_template_guide/values_files.md) specification. + +#### The create-instance-schema.json file + +The `create-instance-schema.json` file contains a schema used to define the parameters. Each input parameter is expressed as a property within a JSON object. +This file is not required. +For more information about the content of the `create-instance-schema.json` file, see the [Input parameters](https://github.com/openservicebrokerapi/servicebroker/blob/v2.12/spec.md#input-parameters-object) specification. + +### Troubleshooting + +Use the dry-run mode to check the generated manifests of the chart without installing it. +The **--debug** option prints the generated manifests. +As a prerequisite, you must install [Helm](https://github.com/kubernetes/helm) on your machine to run this command: + +``` + helm install --dry-run {path-to-chart} --debug +``` +For more details, read the Helm [official documentation](https://docs.helm.sh/chart_template_guide/#debugging-templates). diff --git a/docs/service-brokers/docs/013-configuration-helm-broker-bundles-binding.md b/docs/service-brokers/docs/013-configuration-helm-broker-bundles-binding.md new file mode 100644 index 000000000000..68345c43ffeb --- /dev/null +++ b/docs/service-brokers/docs/013-configuration-helm-broker-bundles-binding.md @@ -0,0 +1,140 @@ +--- +title: Binding yBundles +type: Configuration +internal: true +--- + + +[bind]: https://github.com/openservicebrokerapi/servicebroker/blob/v2.12/spec.md#binding "OSB Spec Binding" + +If you defined in the `meta.yaml` file that your plan is bindable, you must also create a `bind.yaml` file. +The `bind.yaml` file supports the [Service Catalog](https://github.com/kubernetes-incubator/service-catalog) binding concept. The `bind.yaml` file contains information the system uses in the [binding process][bind]. +The `bind.yaml` file is mandatory for all bindable plans. Currently, Kyma supports only the [credentials](https://github.com/openservicebrokerapi/servicebroker/blob/v2.13/spec.md#types-of-binding)-type binding. + + +>**NOTE:** Resolving the values from the `bind.yaml` file is a post-provision action. If this operation ends with an error, the provisioning also fails. + +## Details + +This section provides an example of the `bind.yaml` file. It further describes the templating, the policy concerning credential name conflicts, and the detailed `bind.yaml` file specification. + +### Example usage + +```yaml +# bind.yaml +credential: + - name: HOST + value: redis.svc.cluster.local + - name: PORT + valueFrom: + serviceRef: + name: redis-svc + jsonpath: '{ .spec.ports[?(@.name=="redis")].port }' + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: redis-secrets + key: redis-password + +credentialFrom: + - configMapRef: + name: redis-config + - secretRef: + name: redis-v2-secrets +``` + +In this example of the [binding action][bind], the Helm Broker returns the following values: +- A `HOST` key with the defined inlined value. +- A `PORT` key with the value from the field specified by the JSONPath expressions. The `redis-svc` Service runs this expression. +- A `REDIS_PASSWORD` key with a value selected by the `redis-password` key from the `redis-secrets` Secret. +- All the key-value pairs fetched from the `redis-config` ConfigMap. +- All the key-value pairs fetched from the `redis-v2-secrets` Secrets. + +### Templating + +In the `bind.yaml` file, you can use the Helm Chart templates directives. + +```yaml +# bind.yaml +credential: + - name: HOST + value: {{ template "redis.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local +{{- if .Values.usePassword }} + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "redis.fullname" . }} + key: redis-password +{{- end }} +``` +In this example, the system renders the `bind.yaml` file. The system resolves all the directives enclosed in the double curly braces in the same way as in the files located in the `templates` directory in your Helm chart. + +### Credential name conflicts policy + +The following rules apply in cases of credential name conflicts: +- When the `credential` and the `credentialFrom` sections have duplicate values, the system uses the values from the `credential` section. +- When you duplicate a key in the `credential` section, an error appears and informs you about the name of the key that the conflict refers to. +- When a key exists in the multiple sources defined by the `credentialFrom` section, the value associated with the last source takes precedence. + +### File specification + +| Field Name | Description | +|:--------------:|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:| +| [credential](#credential) | The list of the credential variables to return during the binding action. | +| [credentialFrom](#credentialfrom) | The list of the sources to populate the credential variables on the binding action. When the key exists in multiple sources, the value associated with the last source takes precedence. The variables from the `credential` section override the values if duplicate keys exist. | + +#### Credential + +| Field Name | Description | +|:----------:|:---------------------------------------------------------------------------------:| +| **name** | The name of the credential variable. | +| **value** | A variable value. You can also use the Helm Chart templating directives. | +| [valueFrom](#valuefrom) | The source of the credential variable's value. You cannot use it if the value is not empty. | + +##### ValueFrom + +| Field Name | Description | +|:---------------:|:----------------------------------------------------------------------:| +| [configMapKeyRef](#configmapkeyref) | Selects a ConfigMap key in the Helm chart release Namespace. | +| [secretKeyRef](#secretkeyref) | Selects a Secret key in the Helm Chart release Namespace. | +| [serviceRef](#serviceref) | Selects a Service resource in the Helm Chart release Namespace. | + +###### ConfigMapKeyRef + +| Field Name | Description | +|:----------:|:-----------------------------------------------------------------:| +| **name** | The name of the ConfigMap. | +| **key** | The name of the key from which the value is retrieved. | + +###### SecretKeyRef + +| Field Name | Description | +|:----------:|:-----------------------------------------------------------------:| +| **name** | The name of the Secret. | +| **key** | The name of the key from which the value is retrieved. | + +###### ServiceRef + +| Field Name | Description | +|:----------:|:-------------------------------------------------------------------------------------------------------------------------------------------------:| +| **name** | The name of the Service. | +| **jsonpath** | The JSONPath expression used to select the specified field value. For more information, see the [User Guide](https://kubernetes.io/docs/user-guide/jsonpath/). | + +#### CredentialFrom + +| Field Name | Description | +|:------------:|:-----------------------------------------------------------------------------:| +| [configMapRef](#configmapref) | The ConfigMap to retrieve the values from. It must be available in the Helm Chart release Namespace. | +| [secretRef](#secretref) | The Secret to retrieve the values from. It must be available in the Helm Chart release Namespace. | + +##### ConfigMapRef + +| Field Name | Description | +|:----------:|:--------------------------:| +| **name** | The name of the ConfigMap. | + +##### SecretRef + +| Field Name | Description | +|:----------:|:-------------------------:| +| **name** | The name of the Secret. | diff --git a/docs/service-brokers/docs/014-configuration-enable-azure-broker.md b/docs/service-brokers/docs/014-configuration-enable-azure-broker.md new file mode 100644 index 000000000000..509b7d9fc628 --- /dev/null +++ b/docs/service-brokers/docs/014-configuration-enable-azure-broker.md @@ -0,0 +1,19 @@ +--- +title: Enable the Azure Broker for local deployment +type: Configuration +internal: true +--- +>**NOTE:** To enable the Azure Broker, you need a [Microsoft Azure](https://azure.microsoft.com/en-us) subscription. + +By default, the Azure Broker is disabled for local installation and does not install along with other Kyma core components. +To enable the installation of the Azure Broker, export these Azure Broker-specific environment variables before you install Kyma: + +- `AZURE_BROKER_TENANT_ID` +- `AZURE_BROKER_SUBSCRIPTION_ID` +- `AZURE_BROKER_CLIENT_ID` +- `AZURE_BROKER_CLIENT_SECRET` + +Export these variables using the details of your [Microsoft Azure](https://azure.microsoft.com/en-us) subscription, for example: +``` +export AZURE_BROKER_TENANT_ID='{YOUR_TENANT_ID}' +``` diff --git a/docs/service-brokers/docs/020-architecture-reb.md b/docs/service-brokers/docs/020-architecture-reb.md new file mode 100644 index 000000000000..7c8691d39bc0 --- /dev/null +++ b/docs/service-brokers/docs/020-architecture-reb.md @@ -0,0 +1,55 @@ +--- +title: The Remote Environment Broker workflow +type: Architecture +--- + +The Remote Environment Broker (REB) workflow starts with the registration process, during which a remote environment (RE) is registered on the Kyma cluster. + +### Remote environment registration process + +The registration process of the remote environment consists of the following steps: + +1. Kyma administrator registers the remote environment's APIs and Events definitions to the Kyma cluster through the Application Connector which creates a remote environment custom resource inside the cluster. +2. The Remote Environment Broker observes and registers all the remote environment custom resources. +3. Whenever services (APIs and/or Events) appear in a given remote environment, the REB registers them as ServiceClasses in the Service Catalog. + +![REB registration](assets/001-REB-registration.png) + +### Enable the provisioning process + +After the registration, trigger the provisioning of a given ServiceClass by creating a ServiceInstance. Before you create a ServiceInstance of a given ServiceClass, you must create the EnvironmentMapping. The EnvironmentMapping enables the remote environment offering in a given Environment. Creating a ServiceInstance without the EnvironmentMapping ends with failure. For more details about the EnvironmentMapping, see the **Examples** document. + +![REB envmapping](assets/002-REB-envmapping.png) + +Provisioning and binding work differently for API, Event, and both the API and Event ServiceClass. Because of that, these operations are described in separate sections. In relation to the nature of remote environment ServiceClasses, you can provision them just once in a given Environment. + +### Provisioning and binding for an API ServiceClass + +This ServiceClass has a **bindable** flag set to `true` which means that you have to provision a ServiceInstance and bind it to the service or lambda to connect to the given API. The provisioning and binding workflow for an API ServiceClass consists of the following steps: +1. Select a given API ServiceClass from the Service Catalog. +2. Provision this ServiceClass by creating its ServiceInstance in the given Environment. +3. Bind your ServiceInstance to the service or lambda. During the binding process, ServiceBinding and ServiceBindingUsage resources are created. + * ServiceBinding contains a Secret with a GatewayURL needed to connect to the given API. + * ServiceBindingUsage injects the Secret, together with the label given during the registration process, to the lambda or service. +4. The service or lambda calls the API through the Application Connector. The Application Connector verifies the label to check if you have the authorization to access this API. +5. After verifying the label, the Application Connector allows you to access the remote environment API. + +![API Service Class](assets/003-REB-API-service-class.png) + +### Provisioning and binding for an Event ServiceClass + +This ServiceClass has a **bindable** flag set to `false` which means that after provisioning a ServiceClass in the Environment, given Events are ready to use for all services. The provisioning workflow for an Event ServiceClass consists of the following steps: +1. Select a given Event ServiceClass from the Service Catalog. +2. Provision this ServiceClass by creating a ServiceInstance in the given Environment. +3. During the provisioning process, the EventActivation resource is created together with the ServiceInstance. EventActivation allows you to create an Event Bus Subscription. +4. A Subscription is a custom resource by which an Event Bus triggers the lambda for a particular type of Event in this step. +5. The Remote Environment (RE2) sends an Event to the Application Connector. +6. The Application Connector sends an Event to the lambda through the Event Bus. + + +![Event Service Class](assets/004-REB-event-service-class.png) + +### Provisioning and binding for both the API and Event ServiceClass + +This ServiceClass has a **bindable** flag set to `true`. +The provisioning and binding workflow for both the API and Event ServiceClass is a combination of steps described for an [API ServiceClass](#provisioning-and-binding-for-an-api-serviceclass) and an [Event ServiceClass](#provisioning-and-binding-for-an-event-serviceclass). diff --git a/docs/service-brokers/docs/021-architecture-helm-broker.md b/docs/service-brokers/docs/021-architecture-helm-broker.md new file mode 100644 index 000000000000..165933eade5b --- /dev/null +++ b/docs/service-brokers/docs/021-architecture-helm-broker.md @@ -0,0 +1,25 @@ +--- +title: Helm Broker workflow +type: Architecture +--- + +The Helm Broker workflow starts with the registration process, during which the Helm Broker fetches yBundles from the Kyma bundles directory, or from a remote HTTP server. + +### Registration process + +The registration process in the case of the Helm Broker consists of the following steps: +1. The Helm Broker fetches yBundles from the Kyma bundles directory, or from the remote HTTP server. +2. The Helm Broker registers yBundles as ServiceClasses in the Service Catalog. + +![Helm Broker registration](assets/010-helm-registration.png) + +### yBundles provisioning and binding + +After the registration, the process of yBundle provisioning and binding takes place. It consists of the following steps: + +1. Select a given bundle ServiceClass from the Service Catalog. +2. Provision this ServiceClass by creating its ServiceInstance in the given Environment. +3. Bind your ServiceInstance to the service or lambda. During the binding process, ServiceBinding and ServiceBindingUsage resources are created. +4. The service or lambda calls yBundle. + +![Helm Broker architecture](assets/011-helm-architecture.png) diff --git a/docs/service-brokers/docs/030-examples-environment-mapping.md b/docs/service-brokers/docs/030-examples-environment-mapping.md new file mode 100644 index 000000000000..8e228ebfaaaf --- /dev/null +++ b/docs/service-brokers/docs/030-examples-environment-mapping.md @@ -0,0 +1,65 @@ +--- +title: EnvironmentMapping tutorial +type: Examples +internal: true +--- + +This tutorial shows how to perform operations on remote environments in the command line. For the custom resource definition, see the [environment-mapping.crd.yaml](../../../resources/cluster-essentials/templates/environment-mapping.crd.yaml) file. + +An instance of the EnvironmentMapping enables the RemoteEnvironment with the same name in a given Namespace. In this example, the EnvironmentMapping enables the `ec-prod` remote environment in the `production` Namespace: + +```yaml +apiVersion: remoteenvironment.kyma.cx/v1alpha1 +kind: EnvironmentMapping +metadata: + name: ec-prod + namespace: production +``` + +## Prerequisites + +To follow this tutorial, run Kyma locally. For information on how to deploy Kyma locally, see the [local installation](../../kyma/docs/031-gs-local-installation.md) getting started guide. + +## Details + +Follow these steps to complete the tutorial: +1. List all RemoteEnvironments enabled in the `production` environment: + ```bash + > kubectl get em -n production + No resources found. + ``` +2. Create a RemoteEnvironment: + ```bash + > kubectl apply -f docs/assets/crd/remote-environment-prod.yaml + remoteenvironment "ec-prod" created + ``` +3. Enable this RemoteEnvironment in the `production` environment: + ```bash + > kubectl apply -f docs/assets/crd/mapping-prod.yaml + environmentmapping "ec-prod" created + ``` +4. List all RemoteEnvironments enabled in the `production` environment again: + ```bash + > kubectl get em -n production + NAME AGE + ec-prod 40s + ``` +5. List all environments where `ec-prod` is enabled: + ```bash + > kubectl get em --all-namespaces -o jsonpath='{range .items[?(@.metadata.name=="ec-prod")]}{@.metadata.namespace}{"\n"}{end}' + production + ``` +6. Disable `ec-prod` in the `production` environment: + ```bash + > kubectl delete -f docs/assets/crd/mapping-prod.yaml + environmentmapping "ec-prod" deleted + ``` +7. List all environments where `ec-prod` is enabled: + ```bash + > kubectl get em --all-namespaces -o jsonpath='{range .items[?(@.metadata.name=="ec-prod")]}{@.metadata.namespace}{"\n"}{end}' + ``` +8. Delete all created resources: + ```bash + > kubectl delete -f docs/assets/crd/remote-environment-prod.yaml + remoteenvironment "ec-prod" deleted + ``` diff --git a/docs/service-brokers/docs/assets/001-REB-registration.html b/docs/service-brokers/docs/assets/001-REB-registration.html new file mode 100644 index 000000000000..a07578f04786 --- /dev/null +++ b/docs/service-brokers/docs/assets/001-REB-registration.html @@ -0,0 +1,11 @@ + + + + +001-REB-registration + + +
    + + + \ No newline at end of file diff --git a/docs/service-brokers/docs/assets/001-REB-registration.png b/docs/service-brokers/docs/assets/001-REB-registration.png new file mode 100644 index 0000000000000000000000000000000000000000..5abaf5d5ab657cdfe2739af6d0181d0c5d8521a3 GIT binary patch literal 57040 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 Z#qdnAYZzX>mKfllmYP2NrHXaX{{jH+{6_!) literal 0 HcmV?d00001 diff --git a/docs/service-brokers/docs/assets/002-REB-envmapping.html b/docs/service-brokers/docs/assets/002-REB-envmapping.html new file mode 100644 index 000000000000..859e624b4b60 --- /dev/null +++ b/docs/service-brokers/docs/assets/002-REB-envmapping.html @@ -0,0 +1,11 @@ + + + + +002-REB-envmapping + + +
    + + + \ No newline at end of file diff --git a/docs/service-brokers/docs/assets/002-REB-envmapping.png b/docs/service-brokers/docs/assets/002-REB-envmapping.png new file mode 100644 index 0000000000000000000000000000000000000000..5561da66e53389b03c400f00db4e9765e8737df0 GIT binary patch literal 36627 zcmeFZWl&vR+AWH+afjd=4H6(gaCZw1AvgpG?h@P`f=dYQF2UVhgS%UBcRP#s?e5ck z?|1LtQ+2AVcBQDr&RmnZ9vx#mD?~v~0tJx(5ds1NMM_ds2?7FI5CQ_S00akIu|5d! zhkyV>NQnxmIO`my!aHND;swehQjuM*AW&VdgrVT{_!dYi3+B#MoFX|1DXLh2KcnZW z3yEQ4V?+LeoRImGS*L)EE@?vhaVD&*Ah}a}vy?h-J=(Avn3JEM zkA#E}1@RxBId4G_Y!ph#Fm23#{(=SyTTlx`DG2rNpIS0ONJBzp`>cPz2>c9u75e{i z>Azq7?+N^Woq#8nVDXGqoazDl_M1n&{x!u;@BiWTasDWm^yQ`TbzF0iw5=F32Kv;CVdI8g(R zV0>VIdG zA|9uY6O)s<4to=rBqV8UX`6kKG8!5`bMo?}3zc$nY}PwHNsBijsX@6yR+~-JnNAnQA1*hkWo2hi*RRQ{H#wC~m1!@Q zZ#o=47MHiiG@Z0A?)~^Wmd5aH$u`LSX8D`ltoAplu{6HzgYq`^j}UtS(wrJmeJHUs76M^QM3BjQZM;A^Kcd=nQoPn;KFh33r$s}f z)&gIFC^ja>E*v8*L({53to3#S37_A+p5UOgZiVg?W*rR%{rRa2O`x8^8(jn5c9X_t zXEc?*ZrLgM@_1PjfgTqV42fwI1UMp3wR7n-5DC;rvQp3xVDKSZAT(Ot8{8ky`dB>f z9oWKjy`EGX9rm)GBRtq+sb!U*HoRUQ(p3r+gr0~tpxex6%TmutWPiLDGMg!(-5yF* z`vUV2yyAAk*RYqDy!D-9B}(AsQ8{3uqR!U=O5IGeLio?d}k)i3)v@s~(bdV~_jG z=*fKf;wO>Yd0$wh9P#f2>RHrn<9fQ|wU&!2U!a^#&?|R72ci-e!Kg|~N=E3K>RW0M zQpqF?-}A+g9Imuzx>)SblopGIu0VgV-}#8co$nHCL);6=oL2)c3&tjL72A^h}Cmpz_V^S9GTyt1gsh7 zZ)+|?1S4avME^4A`x+2i`uUeIx6@%kGp%F{Iw@&63`R3k+tYP2Vt&(EFCna3SfkSs zUAotE1K6dVZB{FLCp=xm+2PM`As5{>58qSoVDE3@OiGtg==(F}x&n#US0-5^NInnG z4<}4WA(L-Rpg>(4eQr%UclqCY68N|WfI;g($d>#f09cCiMC_aAd`&EY7ZL%O<}wgc2Yo z!jNPk)?J+)!QN}c^YymbX8EpH`x6C<6w{=R}dN|eh7V!+vgzzS%J(60|bg#pn^z3PFaSR^!lmj)Yq;@dr zKv9jlk~+dFPB=xQpH8Dac?CdKa$4hHq}aq9So*)YEL7^mW3cQUmjv=EAhPklO0)< zP7#i#Ls$K6H}zYQDEb1ID7x7Xitabf8Z547 zF|}n3UE$V-nE`Q5@9885MVWDvXF>v2kdl&8Oe3+lmKYaq15#={xD(G41nvHdNwc=3 zQ5;Lqb?kdWf<_y>B$AJxq|dD3R)6&QesOsbdyz^ZIaBkg?|FSjhDUJR65@m+1-@^s zF=)2hYFPsT&3`%*a6?}h18)vEGADFqnjg@fLDja6OEJ%phAchkLe zjx2?D>Wj-7>F8C^U*u$ERmtUKC?XnfR@}-F2mFj>2*Y}Piz9&?-THdQ=5C`}{!qci zK*0+B0}^~E2yTb=W4eeiE#ga35Ek}b;PAWf1Hac|Kqo$x*7s5p74=69u+MFu^ylsg z#j#(|{qiO2Tv7=6ivU`)Y6KM*mwN6}pvlDGKeIQ4IAtivwtA+KiGL#bC z5C;4qszdgo9=tC2aDC)wflI{rTKL|k1JUlqvK`|#^7V(gDr4Y(b0C<&2WAm;_EO8I zl{AyS`@7&h#>jxMi^MCh#f3x!o6pBqkwMB}3D(btV7*o8C%hi5_`A?`1`Mj7)vCk@ z1=zz(1%)vXLH}7AGhzWt;Nvh=gMlTG2;!B=UpM>L$EW$V>h_pR0=AJ_I@SPW^}pAf zivg}MjEqVd1CM`0=^tNz{ZGljhANXfxR!~4gRKYuqXz&Ew*4z`!Q!ipTq0oFe|H9d z{d@Qk*xKmK?$RFQ2EqTZkhZILJ>0)7^Dn0$0hj%2qmY;t_`vRy@lz&q;s3k*XzG6t zr-SRimh*qqtsZOOB1m4wbhWE=UxP^nkONB9kGn5D{Y zIZw8I+s+=%*#rci3OXNyzL(fulrJURe4}A7?sCAlcZ27JAb?24GR}!E=&qLxO3{vn zK=_;hagv|F#_;wlCgA?Df>p`hK!IqOr4B5H!T#4`A0Suc87y_=Mqz-^M=kgj4^jqL zPIU6r$CBK_Neqfo&lIXTU1Om+B?SS9P#?XA8KDw@=oXT<4!tNkcNhwUt_c`tftqR; zE-ow}9wtE~X~Vn&Zigk?1z#IdF^>Zz>|BIXPczdCJ3DH0V2_Y2O`y-OxBD50W`U(& zMGqW+4A$Dy)WpvA)<-a~LnuQhjEdLWeLx|dqwf5w2881T>h_1i+>xPyBtc+@F#U(* zdatF62?2kFtp7+E5Ch-IdfzC^BqzqQ`T#pbh?*B`2jVZw_=nd6N&f3Ve!dqCr_NHW`1nv%Toz-{7csCilAJ5# z2iNQHM~ps_@A?N3pQ}pi^SwFHXOE04>9k#B4SAPJ|Noq3ui!JV_Exk6$pD%6#>Pet zu&VUqp71gEsP-3+1$JVq5m*j@85HKFwHzZy!wKMdvlk9NLAr~WqM z(KkzGdLL$MO%(64bUx}K_x32BdhV0c=!iqf0al1#^??W$T~rhR51{29^=h#N=h^ci zSiU|rO(BxbG3sxKQKH+xl&w@qA^6h;Ai^%qg}?dTfrJMGWod8|?GLN%%CLZ`xj@E_Ozy$AGGC z8|cpCs*K0z^v^qynEDy&mdL|!8DeI2++?RcfHvOnV5ao^w+I@o5S!ECXP~)vPPqBH z-WgD|?Dc#n0o2ZK#{^!QkqJIXl2cIFr>5{gfi}H>rhQuL_chQ0lFjORd3kj zy%=UKIw{j`Drz%0rjbcxj8`rGBn2%0+8v)CR*eH^r$c#KYf^S-0NhsX_fIZ+ngb z02d8}UkMDFgFx$E&`r=iUo)#izv}r=dU-gn{N1tL8}j^aH@!IB2i7z2Wz}(3+YFgw z$;J=U62N3qncGp!0-*ZAQDq;1SVSM^jDi4z>XUzS7|*V#XlFdn;=@Wq8ew>ShE;if zsE$vl-4)dGX7a;Hey@!o{q<{P2tT^gy{^F}BU-!qjpr;0)~Pl`&}cbYtdprRn~wYa zXQ`p88K{~EyDp~HbO)Bdtuhp=m49k$a@dRcU_SGa#Pe3aW*3wNGwXi0od`f@WsSax zuvw;~DO{MixK8sh+E)-GDO}1|EnY94g&H+Q=bu{c_lqa$Z4Kh*GJz*4ec1ANK2(JE zNFt1!g^WO2(FcMu8Hk~>8)2J$1HG{JiN+fl?x&jlKOb%)3_qv4C}UpkKm}cViv?|c zQ6!JEBw}FTAVoYK+-19q0E)?ljg8Q22z^6gDh#(mw2_3k**qvtk2_f8ddJo37oGsI) z6Ghp8PfJ>=Y+iR&gN@pO6ef0dc#r1n2{bNQFj`OdS5-SU$4gux!NCd}#{izPe*-{1 zpU7nio0xOHTLRDNbDB$SFMO>%Z~qUad^zHLyjM7@W!vWC;-9j+QzsY>u>1Q`6-J$A znzOUBeOXya$>8|Boy3Mmn+<_G9%jXrGW?^&=cnbp(`C0DU#G^-b?xQ|eh$`>mQ56oC(m)b0yX4WDVmZJ4?Kj~&ONPQ0$xw; zt>ANGF!Gaa-wjz*uqzM+wo~u{-_^HYsGp#j6J%Yim>oEr#;18)zllm^r2zlZd!k4b zOV5c24yJNW(x^7c6g~h;S44s`okn5MTcL%Px_f)2eV||~A#!qZ3Zm+ia;1%2A^~KA zyzTKk?EJ$z+8}^R6$?KA5U9!@&1~`SQ@o0UiA?3K*&VF@Pa+XXXW^NaTJ+e0y}yR* zsQ4>VDLOTw+%tZDFwNp`j)1+pkcc4KyXvZwPv;+WeAtSW-!U)OYlKLq?4ThdBkSXW z>G%*Nh<~upYP@~q2XFNCX=qkIBYZsE_Hw0BHc+jQyfjoiX*_k&1Bj*Um0I)`+zxWC z9*t@d>9WyVVS93f-`~9<-wq8NDiLCfqxpj131so!x!>c;exGWdxBy@-4hR*JDk{Am z4)>RPgGhdckdl}g0bQn0CNm}K`#jPBj^XBAVO-$xaDBd)pI&ruMh>lS>`eAk^QXC% zUyJD6TYu*DhlEcb%2BT=_<(pb7#6_#y`m1Z-5G#H*KD=g>QU6z=y)&~N2?rnIynf0 z$r4Oz!CUv2ryFd+(S+He8bVmo)|nAPnFHK;@%LZ3;J(tk>+#fuQG2hETP;+Z>Jhy6 zmdMRrUaB&VoIGTL;kdMeGPXC;BW957~)s>WQD=rfJcUxGduQ2Nzm~!mbT9dGY zLp3&oh6?|~A?tRBdYx4&VzNkwCuMWE_}2!-Y;goplm)*EpFa4cy{Hh>PWUgV>u%`! zr`SSqrci_USO&p_lZbqRy*3VoIQ$hCc!D-~U8x5pK<*%|{L+Kyh~<3NgQt2g=57<6 zO3A<=tK+&y6`%LZ?dDir7Hp1C8LOvEz7ncZ@>$WUagRFRlmIwb-K%wRcHAY(-bm7N z{@Xc);5x7~4;0?}4J%vdNh?Z?^fxj|EJkrngk^foMnXHd^$F?D8whM)tI^Z|CZK1U zb1a1``+j`EkMN-bo>axQ?d~dsL-q%um5LrDoJ~Mgy?n+>5gX=WQGh>dxI%}FlzgIi z@$6vj48wBJ-X=kFx+{GBOqeDmKXcEfcu^b;cu{x9qu0P2QX%>^is_Y)t#$a>6Zsb% zr_<8ft$N%Pws0+Yf}Ksl!KZveFP&;g*2&BU;tBAeRW%ZiOU0To_?0(W&|~9KWFm1K zaH~|@j{9+J2%zkR8gqIyG)!FZFk_wX3Fqmd;FJmMm%U``Rz(C<1@zt^b58)hEHMZB zgDvRP%H9nO3=GD?GDUq#^Sn2mX2N7KNjj@OQS6Okuf+ij!ewWkfOKy341j%_#o8BP zmN+kAg#{lJObcedz95&o^KH7`Pi}sGpVb&tso0r?-^ix%s)x`XeTBPWva+hrT$4r68m;%qd_~mh zlwY$Mxi2n~$PK3W#) z)K|FSFnC_IUhHl^6WdnSOJ#fiq&>+`OYYJ<%f8YobcUSg`bpkzFLPT*4I6d$$Oh?; z{=wb)cV(RmHzo)pXHFaws@YIX>f^^a;Q+)!0T&#~kL@>+f4q{h|EUE4T)6mzzTt?p z_uJd%;T}#hcOs+(!G2;L7;`2EzSOY!E1=@19EEp!hU%Zkah`;X&IrL}V4y4mu-RGF zk94AF&0us%4fkT|lIo?)drFXorp8b4Cu+g&ax)I<=5;~7YJ}PMl@)8UAb&KN*qw=) z`*ux?aA;o;2P{J^u0gkt9*qVyZhhogFMJvq=mRCQ;J9&EM{oDzy&)*&8|ju?-AR-X z4xMEFMo+VOe^$*4e=1FSZ{N^mBQqqc$cj84Yft;w&Cu|4K5ub~OsNmC%#eTD^TUa|E(Do90z1S$pWv-qsYcBdA!;yk zy(1a!2OlVoGiWs441aMmv!cNqUy+_Qd|`eM{~%LB6k^yYe%WkX-7x5p%eT1Z%vEz0 z!OrTz7~KmS87$b&nG3a+#Y+<=XpU#p;yl6c>Yij>A^m@e7?L@l*mn|=`?qw&BbTFo z@whm^@@U5?8=34jBEHn-Yco#&5E2@vV>AT!q~*@#quv)e)LS*cLfzQN)MVRWxgxJE z9`8toNuqT&Dpz;?xHXG(4Z}c)L4iUyVgHk~Snz=lHp&IJ!=5;rs_pr{ctOJNgki$Q*<>vJhN(MGhZj_kNgZ>go<1XN~nlzR^v=_l}KW!~38$5gi?ECV)NRv^)D2 zHp-ZEODZU`f4=$yewxb+8oS>YJ|Di$x6_(gQRr0C7;dFK5>1PQ&Hf7{OeqiO7O=iV z>)6($yp)QWndf>CDHz=kg>JVX?~# zRey|fzM)p>;gizOQhRfV%+RNYn{S1dePPw_`}IC5AJdct9W5}V%EU!!${2SiKNJJm zndHvPiAw55s_EjR<9FN>Q)Q+5I(n*p=*p)*u=v~;dacA{V%N<<=F&|j_T7BPk({>} zOhKP{0k&d`i)RM;HE~lfM4=r%!lK8ZfXxY3I1iB6XUVb&Rz`PsAt$cS^;Xg4m}X9P z2Zo^ipDWTOhkPO@BQeUuCV$q%#{ZgU5@6m4g$HHY!J`r>SlM*La#$^W4k0$I?II+1 z&Wt)j%|fycJ3ml<@coYc;|+a`cm?O%iswUh7snT&_ziVkyNmY(Js1ZpZ$wD!7Igzv z)|pg_$wyemYTt)O^Be(xASy#NGt$O(jc&xY`d$*@;J}48y=1}hJ0ru;rj4UiyW7iL z-h?Ko5tP3sZ7I_xzsME?uq(@W7ElNT z5#!+4(2*u;4xVx0tv757QHIzK2 zKdk6yt3tXOKEeJ&I5eMZh>4v%v=fmZB=^%hXAdWB#fSD9LgKiF-CcxoYWJc$Jd`qm z$r_y?tVAaTN5{trsy*B$u>_8`$7Wi9T3{Bp(k%fG*TmNS3c;DBNM6m6iZujilzn7> z)5$y|VsRd?XZLtU;ytNo40I^Mfe%rX&<_jXS~8tzJM1(GLn!~FXm*^vrHg&E0}OR& zvcc#=c(+k>jS%dEH=6(-se-7Qqc>X#so$~;q^m(4$%Y6vq+j8e=gx`{WD&^zk}Ez> z&ZjvWun;pil_WqbqAVRpleu4~TPL)`ZU#1T%nwO!Kz9~!jC8t*yzaMN9OW#WI?G{|gT*9ixQU z-+SW`qeQmEviZ>WE%C~onvRQ@O%b1P!Y`e(PI%ZGu_gKFGCU@yC`GKyLK8G|%oY2? zzV7w#&qL1qdLx@8I*ksdHxTouW{6-yf>h;Ea-b*M__Fp2J?S{X3i({dj4BDZ&byaQ zuipu^-B#|uqcYNYB8P%6 zJ;9o|t$H}x5#YiXs3ecI(#gt)wJ?Hfly!HSV4Fiy-?@%ww%~m_)uI9>hdI1Jra%hu za0T6!Q@0dipBIXTz^dmpu_>jpO&RcYxzOX<5czeSD!`o|w?0@=4W3eAW+9rcol#+I zAd#pB(**N@D8dnv?QQu+{RC%(gV{i~AbL<1qjqBf^o;^zPOel~&4H0U{qC;Q-9%Y) zdZPr#eE@^qVfcl;*PwX7P{jBZ)w4tBl$T4f*<&gX{eYiZLiEXOYJ%tF&Gx*0?#6nh zy@PncuWKbxh&uQqwOk4f5*}k*JiXf2Qsdwop5uBEpeWDATxx#0Ui8KLgf8NoSI#aZ zj${h=D($Q*Q#g~!5e5p+SLd;D$~0J5w4}uYr2@XJPZYW4V1p(bY@e8aga5<=k(1)= zABbWDdcD4KiTmfK+NscfK7;sY3mOkckWw(7yQ1H3!2)t#0h-h2_DN_A$Vk)`aW9~6 z-j^Xl%?SYe7%L7H0?5w!K9D6GU!!6Ywrl-joTVt6O0Fk{_!+? zvj(dyc5e{V z4-LsY{zFWitY)Q>7u)@6cc;T2A46=cJ`qsAZVw5ER215U+izgdRRs3OLS3-t78v)#x1m1eHBJKa3JVlls6d<1bxg3Nq z)=B8zTHW>Q>IiI}pWryjr*z%$6N={4vSsho#pQGey7GCgp`>HOC7Q}?;%pP0$c$od zPl5YZyLQ7=hfSFJs+8_K9kXmpb`7ofN3EBCA}0~;{VfkUI)*uy42d`D0(VD{c8^d& zjF(vm`u4;;PAdAg=-8Zndkf~}s@9FEK+5-n8PDdcUxW00i?A!AuN%RCkkKlj28)hq zAt*@}4$>w6RR?n^QPP7~tU}+3=F@`zw++UI4ZQ~Qv zFb^Xsr#dpn#b|caslN(eIy)mtnO#_F>@0NF3BiQA zX4*Xtce)QQL3x;1L+A{HHSFwO5kDl=XmlvV6hyzey6}{mg(PSQFVK5vu_S4O)QG ztMJZIYsMo5(_vAi_^O|5=F|dPtUjB6UIehl5llhQ_pWDRXMtZqsBJ;Ba0l4ra1Sov z2pdntg^pHsXLodsATK7}R;{BOpJ3rij9~Ypk8FlaqMUET?14JTr-8d-EdS;5iRIg- zDb?qRI4H#UsCO7!3-zp&%8mYpq167}vZM;yolL9G`x<_@O`$-z^8*USzX%laO7MEl zu)JhcNR4?zH#7pWRnt+eR(TJ#W%G2e4nYb+PQa|-2cgTgFnkzvcC$?xl4~B5Ow-#= zxH0q<=NACn9K^~N7{+rWghPH4HO=H1`J2<(?0g?hy#kOBB~PR6|9SZiv=vIhwrdRT z06wA$$Blhx-l^&24jDl66KHLX$ZY!aJ~ug8?)}d6Lp<592kyGak-U z=RSI0DH!~CDJg4O%me?YZq-v+inY2X(2!sbE9ffRF<=aY=+D-it>NSClm7?k&!y+| zn9vZf(bv(sF@;wY+CC<+(3N0?V1Q2*fh5BMg^#R$4yY!uo$0r}m^I#~JU6gPp)iWH znyiqH^(K$Q>3sDE@$Xi_%_^juH{k(hsRr@4S=PR-prw@(KVD|CC-`bHjCu*> zYc45&8;OezmKHBZgrV&t8&CDMEE&AGxoEDjsm?#>UqfsLA7==Z8q&EPr2{(lX5Y*Z zqoVbWMH6Esu|x-;&o7_yP|E&RYTm5!b3(5iIJ7P+ML`75awfSc-w zxxDqP>_f$erKLP?GankHU#9!XRqA~h2AECN-@KcVITS*o(pK->XZ3kr3R{S<2AG8s zO{TUFW|X;z{tqPvEAq<{gl@Mf4zA|;T6TPu46AbJau+9)%u3}@9O}DB^zDop5Qy*m zz%5s7n;R@}0se4g;vwn7)Q&4TJ@QitPUF!&u68n)=RsMl{sl z$p5V3hYXmOO_jT3m4;A*{YJeHfMEi!$JODD_A6n==ysLNCG#g(j>`rEYx)9*Uo~4K z=4!5HSNBfy1YSZaGti{lWuvE10R<&C&bIoy!T>LHb@vzTSjpg%{Doua_9q(^bqry~ zQv=9}GClUK(@DbJcY=GvH^7Y|YK{v^?M~w^SbelC)xEAGiyfV{*8uJikw_D~xp5SG zUEp08o&($K>Q$YzZ$B{NQJ<%!$w6lW{=th0*oM!=U*2aV10~|rEp=jNlw%))S)1aO zA?rF&sLXOPhmb_1ym*=k#9!hGZi>3W^~tm_a46}?ExfQLVuoOaaPJ$d9~CmsEAep+ z@_ls(h%BNB{i=>xhR?r$`apMVMgQBuEn>WBv2L59(fWL!$YeaY6rFk0aNqY zw(X3dO=Mr!VuAz^aLNoo^WuM~93#{3l;v37l5$kucXzt{ameSTBmSxUtBDuzmq%Xw zvPK;U-+qw_C3N$x*xh|bMRvPLYkkoKghXv9?|lu7m0;j;jEQVQ-%+-nJsXbUB4A9! z<q`sT(4?PqhG{o3wu|DD?AL))uwv+Zs-#9Uf6R3~>*50% zNmdz!PsLK@3D@C2fW6rc{9O$i5keu!r^S)VQT7KpYVjQO(Y%*D34{J6|E<}>g1 z=Bx6#K{!DISsp~)h}M$@irI1Xop*23J?{&-kt+cx_IzA|w9sC`W_0XTgw~CRn%ymw z|5q{Z`E*~{)^;(Q7(mWldpNT$h#-gM5UD)q61<{sdK;9f@}Y zl{;SaPBe-+{Rt)w{GyQv*iqn5;(2euFVdk}EzCeSbvmXl@bk9|03~k&z_JMV>0bo!*21!`Hc|kp-S)XZ?LylI$hCNY;~Yd{YR*87&}w%6 z^-jG~w5;vfHHq0E5K|NoVW(p%7k?^^`BbWr)Tp6RqRk|e{2}JcI==00@oeAVARA$h zLZ&avXWfVOid2IjuMpk#3+P07B-snB^HsV0z?!kSowb!>-CL8u| zT|gzTk081rKyAd$R~gfv$N;40cEfI(G6|uxejo~Ap70jf8*B#%o0%FmLg`;&_3M*W zCZN~NeSEwa+uw2pWdMYSRE-&a2-F&eMuncwy`P8p>D-d@=J)+?0I@5PP~nU2y9?Sd z1~j3V7#L(&)E^aeutBs_LNE1Y0Nr*zNMBySj!MEy1CWAO#SjY(F3BYtRf@vGBGb`$ zYTwM>GS_I`hy(6<7s>hdj{#uc{BVa~E>zQ&-@T1hmPPRy;zXg*9>hk7^(epFDS*Pu zKdBo6;8xMGe_Hr04-6_4DD5YOL<9{+rNunUc1_X;(-=%5SI7^x8{I=OIPK8TkN^Yz z_LcI+rvLPD{-}@COC<{)3N(NNuKdY1bn0-l+{96=EdlBO42MQiQYPYNf~P|b%8aIv z7)<@PQs@bPg3}wJz{AQXSZBHP4h^949@s6F$x$8>70AgEW!lo}jCT2zY!UYMc?CkUb!QP##OnJI;_vf^gxP6jXvDe+4W8zj9cT>k9_k#1q*Hn8Q zbrqJKZ}^kLE=?mEePjmITrno5FRd1bTt9Y+G{vzNobBFq9$xLd4;o4`W*j0ir@LWG zWj0Fv?(j0&o2iL+|I1j%ZQj-XGkiokn!~q!hpZGiUW%SD996IL`(Y!8Y~AEW$4p&o zxt%Os>&EG{JFfl;byO&BLe4V0+0Q&BNW|bLKEW~mZ!@E_aaChlRwa&M{N`!*4?~^V z+lJ%Jn=##nt6Zs144WmH6$;Vaw%5U(c9cSpaW8bthlq&aE`>@m%zpB_}xF^xK_QsLPTnNZOIp6H!$OtCA_IZwovCQv4TtiY^%3?SJPsUkKuH{%6a zSz%D6&=-#Qz`Ll#(Niix%|m45`kDIs7Ko}-y25rufn})h!_5$0^0Gsc&r!A}dg7_! zZCsB$7ov`(mZ0*(jo(3Cdj|_O_yVJ4`7d>!!~{@}Xhfcz9j94)Q}~D-_ua0SuB!UE zT8U=$MdwqEM|rEtP;;t&HH~UG=DaN?1Z3p)qx50Y;+l!f(~-->Gu)^PWHH=WJVvR0 zE~CGU=EMo*BYG=mle>s26XkUNdUQU*9LCFSRm^)98KiDIHLfZ12D2U+MEsNu6n^UR3g(h`ZM7dcGTPj+th5^uzq(e$51fE8D+?R~8cOhkgn*lptM?t9WTo z2qB*+R%a`)#w8S}SMU>OPPe$wAA;uh0*|MD(h3kvk*&zY+$u&Me)*CzHKk7^vz5U>h*@51R<=V{Ha)3GddBa*Vs2#INB(rlhu z&3ES9m+@eu$`KA0cK?#E4JN<@9tOv*Y!6EAV*0pLERJVO30(4?*A* zbar@VZ-HFtc%|#f=8vmhFD*b2lo(CpD^@aT(u{IMg-xTtrW6t^veG4)2=z=z;ih7Z zOpO{~9wn_H{8Bg2Wd>BYD2h82%g`^$Ts7EPTwk zYt#X9d9gLl{de*%#9g?(6$&fNsLIk|IE;nJTHf3UOY2wJkdSatC|8#GRM6Dy>l_DS10knP#)9(YcA~Dd^H*-OZwvO@3&aDvTOrF$ zMxGS28$)Qs5s8EGboCR}je$`8Mk>uw0PFM@ z4ZGEUT@!roLJ6=UQ2(%uYZ!JassR8LGLq}MuQk@E_Fm+$jvr?$lG-E}b6;RM$>;AG z>a_%vG&DBh%_fWZ4M*90NzlSXDaVhrB0)*Z*JF`Wam5B%$d-IR2(p2KT;rcuQ}8=U zPTC7BOv5in8lIazelofM3!lX8STuMa_+j?m?=}>hmK=!PYF-L*DV$`$G+8V6rZ_z( z6O?1or&2m-IOKl*7ctRS;0=?~B&dKN5P?kqb2*N`3+K1T>$UBa3za5nRHV|sjXV^N zVN^kf0n$*!chGgjO(I{=(r=D3SZ?sy$_dE4(8@O#TcMgAfu0Ga`ycqBA?EFtWHV(> zPXDCp$e084_(YwxZo_&Y;TACXrMi2)W~2Pb^WlTacN<`+YyXAwu-fb8nT}QS>Au0O z^tkQCvqo34*zM16viK>8U8EZGS-Bd&D$gifH$WZ7zqlE@xIy5ymJx@Dme_%#EpNCs zLcaYpMd(^014Ry05Fu#(%8Z?~%6z?8$YHJ*_Kt=ZbpGr+)pJ=xm zsg{1pJ2CcCMPMNv6)uqyTwOko0fx=$npckX;Q$9!Xq$uvu{C?(=$ph z<_OFqyw2^hd>ww{_yi2qsC?qUK5{1qG00-yxlZU3^Dr*P!~zh(e(Nz^vB!$MtjI{j zJ89rSpCUgjMFNl+RUhl91nUPX_AJnZO0v9;nwpG z9pu5|ep~T6f4AQO0G$4vraDB(aC-%|-*q6!_-b)yb~yDO8L6qoQxO~v=YA0ZV?PnZ zJfGHxg=aW)69wc_6UHKlV`-Up0+oS|$9$;LqxkjF@*?R@EUq#9Tbp zZ(hsr4}Wr}9QK~}bnc9X@R#QcNT|gn$9Q#UL~~@Gvd%}=2$Z}xB=FM!p!@3Yk`)Jm zlkB(6m4^CaRucB$lHVr1f(8x2^|i^mDY_uKXhS^UK=kDiJIez{1QMCq+R4cfW`HOW zQxJ#lmU?PkSmS5)=O!KYI?<%u{Ove$ic+`{q`)!Qvt4h%h$iItbtz#{@t}o5MoH*a*fAoK=J>lRgDIFJrdlqo~e0O;>Rdn!Uk>|1&17S}5k zfz}21DbHoNq{RZCI0l9Ge4;e(4; zP||m;9$s>h5SXJ)DWL?p96=7sLu4{`{&C-i-2Y{i+^kR<`XTIm6(6%X{oZ1)Qa||> zhT#=S|0ox4)D*LIqAgWvrdvtG>oRU|mOWA?JpiTDq9SCf&Y;9SO-G(;CLwhOYV-wK zjelY7g8VQ$Pl;d|xzrfj8h3k4_R1zQWZ5=&&|V=L5hoa(``+F&>K!B?(V#F4pS*^^ zVM=4w7Xfs0IbvalQLM(mj2O!P+%JQ)q%B@;aF^3j!YGw=e`&6a%Yr}Im9qdF39ck#_Dh0Z(D=-&U z+4mcgsiMQMQyapZXw#;p#Yytm(b7X_Hpa#l+_Cx8@=sYjBl-`A+u#&gnb>tzx+a^N zhs0M6Fi;cFzlv9j!bPNSju!JPTlcEkg>HFI+o4~@SMdyt+}zx=qh<9Qi1D;?Sn{XT z>kB|vNn;)R>2=6AiPyz0!hD(ZX`5|@-&rzCSY*3H8t1#oXfeC9z!f4XeJsQn(ZM91Nr>K~8d1Y@pMovJM=`KVACuKR~WDJu#P+m^7dbpJ@OsJ(fKGs|C zCciVt8sRy3Ti$WuY4*c(K1voCOV}dqI+3rZq}U}^d~jn?mH%4H!K$n*f|)BN8JH{? z-=q;2rPid1?LR!9`6|*kD(0e-ZZ4l{DTXVncI%Iu}h)l$Hgf`axyzROFUHfw)Of&HA zjx7|vn*;Ky#o~)iqos|_m@Hm?kQ&m+6zR|Mz9*-a(#)(sza@huooP^|8R*Bpm4fwg zSE)rIw~hvFvs+b3<~}6_5$07*=Q`D#{7VWoH^=IWiG`)wzX)_oc83pV(^>aG=?t4` z$7VM5J}1m$cWmg1XT#7I>tgJFz#Pg+J3sJE{P!qtkjUCcEm)v(nb?fwtF(II!U<2n zi20h=*H<&}^%xw(J|2C3^~9DV{L5yR4sKl)s5S^j=+G>Rhr2{4;Z@a~YOR-5%?)LW zaWV6V)6i8CSUoEW_x2|At~BOX8gtLSyUk~%&K4&A`J4)a4e9DVtPb+(p&D3Lz8>@% zC{!On9NwGn(qXAl-|K&W_f@jGIxOO{BD1|r97voVCFkw6gS;OZQ|)RKLA#0vZWPIE8wDXTZxZn8PkJ&gRgJb*ZS?27fP}kR2?I@e zd3iN;DSBMA#Obk_cub5L=|rFgICLGTVgu}{lm!h-mQ+Q1yp>!ag)yfz{Fm629!0c=%e?9sR65zashtjxMbeQto%O(n3Z8*6=*fzx6w6XEP!B zt;>|H8B!I!CNu3P4klad9xXyCbsf?|=qmMs_i7R`&MHqL{Qi9rt&x%?Z`z>fPUdf3!*hc!RZ1E_#|qB-B6o3mUULsz$CZ z(^@8|>7?A@m|BI3kRaIsZDOj0Hu{#Ufzog_Zk)<#@xo0 zPd@LXJuPJhx^+~w%Dd^IU&k1&hoQzy4>+9qM-A}YCw&G=&DPxjn5ExGleUL9jtbxpq%|`AyKRm|%*I|)~ zyzknj8#f2_a(o;Get zVP!>FR2M-rmnG2<#bSW}2>qM?h&}9BF@pTP#!?Xex|35+=-{rSfmRDIxNMXAY8V+> zV3mngE&aU2j>nHXSK6XPAKqtJZe!}gk__MiD*2qI-^gwU zQ(`*NEEn+&+SnE@KuyC~{f9{v3>z(W*rhgFyHfJjZ=|ML9o-F-;~gSsu#<}usJu&A z&3*Lnew8_`fKM3t8JR};zR;dTic1Y4-k8;evK%f{_I9y0h`o{{n*aX5Q0VOEohr2Q zkCRB|3kV`s7#}bhcht}32?vMR9J9BUazEUCldo*P9vXM&31!q)_#!bgbi=KK{uyW% z!|VD+NDmWoc}7EVS-!fXJ)1Mz6Gl0d5mH@{pgz9`lvG%=|A+`7d1ystCaW1NiI(s~ zj{9kQFZ1z~%WscHS*Nf1zNy8#EyN9vpX%C5*==@fyEp}6&)*79F)nb^+AU`jrl=`H z;G5SW(e5d&7k+N|Q3TtLzu4NQIms&s3t;@uBjQRly!FtJuSJI9e|Uo5pjIvMyV*jw zJklz#j?TmOk4b5x>`tSL0#{5$u!_z-t&Y;<#CA_IU4cTHob^$ZrNo8Y-h=S$`T61Z z?PWsj@@oJj1{<6)ExC$YrC0aOi9=V{q>~iV&UTB!{`sRthzlEKo`T#k_;(mb&(^5G zSeW${uUU($ms@5v`&&Vk^7ib{c$WXi6J6O;s^f@A*4AzY&#c<&5?r#Q-W?|I#wjK` zdy;S`YlY^8^4EjyiL@DKMmk5sXRB3<)f@`<>l%tSwxTh%+wFCdfHZtJ#;aNN@Aglq zvQv4a*8tqn;}S`8Yc8pI?8zm|UR@pFLkQF&qHw3lbFB|z?Dn;as!5wfP}5uEjDzAs zgN8=OQI-hfg4EtpQx5_NdydP+4oPjh+3OJAA@?5+k97C8S~{^;|9n&D86f5>o~?<}&4*VfB~o(n%lfa_!Rc1ZjZzjCCI0>}H~cWH&Rhq6z$nOlsm>~+ z<|YOm!DCQ5H$^tUR($?dQQ}TrA0cOxf(0A(>p);^=G7-Vd0Ste-Cp@DFl)@G|99u? z9}r-q?H9zbuTrX^`pThit~Y%hAt(f1yaIsO!`-Sz?rNVZ%Fk5hzGPsmaXXHYmpOZ1 zGSn8}lAT-^yelo17~~0y80dF7b8-6f2a?-vE138BcJutGZgmiaV+jnr3McW;ACuP! z`)y#9EiqFhXs|@RDhC*qdqwU039XySfW|bwZpBrGfZd|t{vO~ui~yV;V0x@xxu9@D z4!Mbm2^F_`FM+detn*QxlYZcpDO#04|Fn6{O1NDRlrLJ&?eKT zayu$@zP=2m(q>&)y}>T`^+h)!iIM#GlCL8(dz1N*qN1Yx04l@)Km*EOOkPEhV#)y0 zwgtRN=Mz&dI0HbLjewyIY0mFJdHvJ|usceB>C&v)o}8}imm(A2sgtj-uOACl&I8J{ zUVQfxFTC9KKbCa7@4BE3*JHSP7_#@Ju9RT?oU(vw>*pxoru4&`zxqgGUk43=p|T&_ zJqE?7ct3dw!nR^LkznA0=D1;O%j} zv6%I5G-pNhSxd8W^m+FS-K;u}Kc6QZXBo6{*b$L&LDJfg1W2paWaq_EHIK=(SVZUn z$t~pnc5`R`mkQFQo$am0?1ZSOhxzOOa3;M6EH_7}K$JfR*Pbr7Y|mOVf3p2G{vnnlX|BPopbe znSI&Loa=257NbgT5QK;)w*P1UDAE!Du2sCxK(6r_B9PJ0O_D*V8pYnTMvqod!iKr= zAP5x(u6Q%;s{#qMK!4Et!r_7eHRnDRk3>>2Z-RJfw({*9i_cyk`|o6=&9|3V$VZZ$ zz*sx=T}dDf*yJrg8op}iehW8vYv{9bq}z_mC~Drb%4h2mU>^Ly^@6pMeU;XW=(#k( z4nYUnfTKuiU+j~@nkL@!dDL)-Vw-^(Hy2l|_ai{F{Un(6P#Dk*qMpc3s%H zOV3&c`@wBC8@DkY5;z$BR8$Y1a9J6u>Jld+UqwS$$gORAVNttlIf~+QeTgt<&MYEAl#ui`ltJST zqxdL*wK|(Y_k~QeFfG-7q#Z;1ID%r&OwcB9^e3{1;f@(TH($6lUSet5oSTkX3_E9E zB=_S>K-k*$^W6i5o)@SD>m5w)Hj0jh3=i1A$oXh-l zAG3lmqR>wJ19mN?=2 zk$O<1=4h&2VF&O=pJWNUW2c8idK*YxhFkVMH7*4uLuSwgh^2jA<6WQV_Ux#0w#KGc z{GB86-@Y`L<~wwO-&FdpAtoEFC6)d5vKQ}iImVG>+SFcH9Ef1d9j2(afyF5nia3f7 z>Oj*gg{oPxm}8b1f0zuLNY3g`IFn=aWY9=jBgOMAyq-z>0aE5S&`e`KRXTKvU<^tP@J;l77J#^=u!DiGQ8xgE=%|U`BC9e@Y?)LrP zO}UxABs04n+$%wiHT)necd{dU>HHk4f;byh=R=G;n`mb)_=FAfea(%H6%GEjPM)^==w>MZUhFwIpU!Zf*gmLJCmDR`CFCk` zIR55BzpktcPmCy$-Q*G%x3mqrzayV{=*{at^izq{*h2eOC1VMP!yn$+#y+kS>gK>` zYWQpC-C#fa-NcklTRcN;H~yMrwpk7bbIu=L`DHI+F+#iOFyr+k`Zop?A2>X(SS)gs zXHt=533ZVC<)Fs7v1+yXqXLW)978Wh-BAVeUSh@Mzw+iD{=yM{dJRHDq=ix%Sc~Z| z1}vrIyqSBhw!U7JyQEX=Y@CibJXEgF7+$L0xDX z_2iJ`dn&#R9mkcAke+|ob((QneQP9CI930tu5;UI2@l)L%#DVSaFEwa7?;c=>1p$S zBH2t5%N|fuTl;`a=)fN9Z$~`6*a{?BHSb~;Aw4`=m?Ks=bG!eFRAltkY*_-58Jl1T z>&}d;HxTqd>29pyWNsf&m!X5cu86AxojMF(>Px%6Lx|A`WZcGkT5^0~_1ydLNJ0ao z6}%tVmyYdsjama7?6{d3A~Xjk&(kBG#+6$u2rz#kys#&s`0~k$A2UtYi0_}1Y}Vf; z_)LpcZTyg>=za|Tx|2ZC;?YNA?NVM8NxG31&cn;M-YJNca(n)9B1(KGE{-htYTIXVh)2Qj*eLwV_+K^{!BUdNVqj;y?L_h z`@71McLQf5iAUjZG-2WzW`Wf;zcy(T>&t)UqnRqLpaRiJklak8mpN1=pz*T=BiSIz z6bU_qCWV!}hjr!fHUHYUrgcw2+DvbeM&o_KM}hQ%NmCu!O<+^!`rIN_E2_H7ETv14 zf+U)Qz#u35_o6+^KqJ9>*mU`b~wYD?Jla)H4ILITCAh*Be{2L_|~<%8~QlKF8Y_;&UCLz`S3`Pv69D;vwx9XH^#<_F74uM5#HgM9SN zukFdolyNfGbE~fftiU111*f~Ah0EF|*zQ@qOyXCgY3@J@oAZw~r{z_!53)DFsLWmT z@z5>L&*Rz3rvhO+;&eOh)f`2V9>o7Uw>K@JpNl~^#|L#)(@c5yt;YP9yu^Mp~-PMX>w%sD!a@u$!B(c8cE1n9!3oosH|L{|cHe_eh;IJu* zEL?K#z`l6#1|&C3^&e$|Z(z`IC_wYnsW6&DDtB28yi?+3z0TZDXE1YDc#ed|{89c4 z;5Di3Jk0NrN>yw|jrCcT=+OrL{knD@1Aqo;IGuB`(?7hN5 zv7Rd5DkLb^nB4)=fZ`uP1>{K6JHhi0w& zQM}oww6BpT@y3W-_v4o<;xnYRw^Sh@1WT9o@1@JYR;;7z%=*aEKH%y=Ilhp=1Y>=#ZvnFltYz-!Tq z@AgGjI*_h;saY3=(LjrXX;#8ZSh)Hss zZ6SSv4Cin}3T)z)OpreJlH(;NMdvq=0qIcX!`AviUu` zR}Q}J^Osc*Cs7@ez~C^HH61vipi7hs?+_Kx52P0M3;Gr}j4J?kY4}Oa+QsgGbHp99 z0vBveMzr~f)d&R#`N2gW$bdfVR0ym*ah|2nEduTimJ|aX)NtA&S9XHL%3k7F1ABJ> zc<871e6ZdKLxBXqlRKUlQc};Eu3q}wh$C1$;kdnoDy<5JZNzdvuQ;^aPk0u|Y-vET z-dIpr4rJl506CSZ@dXzGp75r+&0mRhAqT%iImkBS>qJMuD&OpaWUB>ttJ_$yZ~m9a zaR<9&Nh1qAm#@;h-I68X1P+kX&_MgyZ9&(-^2~i{L$VeSb{&heOwc*;%z3Ple@y(H zUnzUQ-p%W{ja%MQ2S_(=9k^+(Si(~CMTGpc_9R)v{S!=}U1+)>Y4H&eaFLd$7-v?P zASk&_cb;}4Na9yy{IdVcny>#7tMcqXmuFHDgm_|2XQey7 zf6voL`$%qi%k(DTx40t97^~{u`C#cCC^wX%Zo*!z7RjOPES9kxnD}KiVLz8^^@G-M zAI%9S`L5oRBY}{XFYr2O#sySDn_?8~zYuXJx|yb?C{K`mX0Q2NQ2NcqRr6$bS4X8g z3x&Hn98-GEIVvOve;!j5D>Q`QWy*aw1Z6bPquU(GDCfkpN$tZ7cM16c4(}8;H*N`> z6Tc5F$v@una0{_XEw6!PVtcMiW&2MqfW6v$E9vM8KU|EQZE!L1NTAlP6Pp_t0VeSW zaPezGPyFo9ix-bjhBJ0OkP%GFP#bOd5M@&EL^as!;Q4{ccRfYT*99Gvj`~>Nw0?g2 z&A-Cuy`Y|6q5!YHhh(zuN~eAIVSz6Dh+~2e)kD+wZb?QBPd^0c?9o3$8h;|>#p1F& z_Bb$hZW1=~Z7Qe7d6(hsyvG=qf+pDX`lVVe*X-4{Slw|t*zG1N6CQ|8d$X>__((ys;nqw ztd{S5V=d|Y{aGik(Ruppv?lS#saMBB4&CKnD?=DkSN;{ew>#Z@n@I_v0(^vzJxHYb zu%2vj)dJtT;U6)XT`{G$GDf+2eGe08C6G0vfZyuh19AboAax?_kxJ38Umq7u-pk)w zc}%2$EWO_KDw@-Iw-4CzqlQ0jxoVo07GK9AofM8F3RJI%CA<3*1YDl%~3++PfKZ%w8v}k=w_G{9v_7w zt|C8!bf)H#{QZCEoPydZMiB0jByJk+r}yVw{^e^>vOuT90HZh_;r~)4^)rWTZn{&Q z&T?lwiN{}X!btTSh5lI=fr#-3ok8uEhvBSv(o=wJp_GpOCd>L7<^%789A)Z=)=toz zc*xtw`S*=cAag)Byvy0i9n9r@(Y2Rhq?|4aB07t|{>2)@7%JG5F_UJX7T<^ehp(k~ z|Cc9^-N;$`Z!MN3?0OJRQjZF@_5ZC&2>VdvY$z)}H-gZD%@L zq3!VuL3KDu1_ddTldxY&bb=`8A2Zu833f8N!t|yij<8T?RR6=*uoD0ClRws}Q~#G1 zw~}5WPQqXj>ks`l#t-0dCc9SzP3kqKBb=nY?1eN5&!KX6ho>%lM`b_q=WsNkv>+|* zM$ac7cCgRjh`Qd0lWdOodrIK7NN@z+?G0ze@5tVA3|)>_Tm2&IPs01WD=0f91?3#0 z|8e@ab@8i)$uN+;@ONADlfz-xw`}tJDdr5?rGJ(2VFi-p;sF)`cv8hKunJGs|I|z# zb|%^5_azHDuO1Ziaa}?CFcav-Fpj@>qKRLs%L|AKWZi^OBr86z3;wqY+w*g#wEQY^ zGW-i*{5I8(Fr#`Jk@Mz{+44u#ls{hprtvb+1s56FnSKu0AL`BhkUu%fDr|O|IwH~r z4m#C`wm&yQQ3??Xl2|B>K`?`9Q6d+HuZ9le{YQs^YBv6lmR3Rn?dU;^zxQa6r$%bI zxSs+a=>MJmxwOKF_>S?%J#=eoYEq$qh;E6a#fzZf7uzCvZ{8?TP*6kyOrUm~hWkb* z1Jeo{jO0%KOWkHcod zy@V~LJ1BbcK8@s9r9pq4Q=XkW2>rmY$A zQ8z)pekSbb$LA1GYf>)XEdYrEg@8|W1K9RVJ%&HfKthWMq97L`Jx#Dsj$T(0-fwT6 zZPjjgKeFH6FZ!$abGyD{G7$JSRquv~tsO*ZojDgqE#~@5j{yLX%DT~ZeCfn1rXv>u zOx-^RYL2jaPCPMb)p;`=r6H6La78PU0U5;Xtoo$OxWw^EkzHRW06Y=;N_)`!bk@A| zxFqf8R|b^#C7J8NG6uRkmVhF`YCr5ECX3kh7Ul!UH?Y0YRrPh!`~8C>ZXkf!4I+T? zcr|9aX9zppwk`d%CdblsEj$z(umm=(TPw42G#48UxpEAYVz}=$%ah<<19fF4lTU%9 zNl}x~tM^k~dGonD&A!`hLKRE8hK4$i08TsyV)Fw#Hg`V(N<8Cp=BX|>69A&ia|3;| zlMUyK?FX)cqFNyYWVKcxl0UDjqT2;}otwZAoPBUH-06!B<1owh{?9@k&(2B2ZfCTY zfBRC`2LZv02$bkLKRD2#(+3K-Ew}a@9N{Ju~t{qWq9@#0p?nVtHZjXQ$2MGNJp9 z`v0^0J`F@4I^@xskf^66+d=W6a&zvHC07)!Y`OeO_eHlpyO(D{n>HRlf%XP1AWic@ z_b+=_hvynXmC^yn(j*=)jk40uGaEoeZLU|mp_9D*7E<{pbA|cZw&UXCnRzhM(Fz=? zh2;>|d!=fSXI3!-;7l<9oFDnmfEJI&Y~4n|X}U%k2F4 zKKtdayqkRN((OKQp{H~C@*CT+CJ$o^rm4^&{+{2a!CpC*fz=kVFZ4%Y@I;jI^Ii83 zpS^$}3VqaH=#`D=JhTJGl5SEJKwzyb^X)`y$}_<6avy3M$V_FX%*0>&=oE?5A|l$1 z)7|+g*~zIc zX7v%cUS=ei^HgM(o~9HxwNzTMS|g$o$aC0>KPm>oUTw<)Pj%vCy^2Gtd6Uz4wYTX? z05++MzOcI3Bs`wJH;YaA`Wu~nyetbzvIL-FxUL@ZgXx<1sxtQ0Du`)F`?alc%aj>D zuFPmxD$?7*YA>?w#_)QaY44XsV|cr#2t+X(cZKo&G-5tj|x;!jw{i%{&SkUfMs+#@$d*E3=rgNQYZ@3-7 zLy>^u^G_)hRlAd{Vb(fnlco{t)|z0M=-o*Xl`Kz#oz>IbZJKQ4K3m={M^QJ2Q6X{JG5MLtZ4I%4R;zG)h2T7{UF{>7H26S(w_bZulf}N|C->07Pi<#W! zGzXN^>29kZPC&7^bz5kv0yrEuz8W$y;X}`^tE3Tmh@Ry^(zMzFT}97hln0+(me-)i zxYX;`1TN(}irTD2&bhx=K4qy0 z@(4R!;tN^V=*eJ1p`&m8*3LiZ!*06zqFL^m4yW>ka)v_wt0}b7;a*PVPIPY)>=)K_l-whprh$)mOc~P(|$%xBZ|vcW}s$39*Vd*a#lNy52IE zJll;0KN%$j9S-Gg7yXylD}dM1tNb|_#a#uJH%8CuTwBH9lFYM7(YxQ>IRob${3Vu$ zo)vHX>NStU$OZ@)C(FtYZrX66>G|~?H|>_pcfLMyD679q#i`Au1OsN*_ZTH;TGl@6 zr=xbtP=gMytjmRt+GnZ~#57TjN=xPw)bb*_M8#iHkv9xD$#*g8#T##h+iTIEGPY!- z>j^w{)_GB@tVs}f7)bjryD^b+%%TSKa#bax+FBUwPWpA-)(Z6>#}DeindraP>?*Gp zt>&CTC(H6MX$TURKB|T4qAFm=3kzp7Y>b~f&F8bBx8>y(ef(_OfpSF}y0|Ymj4vA& zQ_m)RmEjZ$y}MWx+S_kMhK9on9b&$B!reN}L2VaJ=%>yl^E@$e`U+B&$qUjg)dDnh zX~d@!Pt0^jSlov@bW&BRH`OiLZuts#e3apv4P-ELOy;nNB=YGdkHFhtLqb+2R13>F zq_i>07m8mc^F~owwM@F+Rwftby^!J}trn>}#Kmb=C7pI!_*93ifT`wF_oxE{k2sE% z@yt>zhJ5Oxl3mh%X{oXqwXr%sk5T36S)S=>Q-0xM9B)=VMgMpyT~?n)(^Shl8)Btayb>t$;U>mO$KXsdT4?0#67$%AdK5gf5Kf8SE<9nMf3$<7XipMLncY#nV9) z^(|zl8JhYJf{TfdhR7h%vRsb-_Ag731X za%;B#yJtSGo{xO;eCTI&eR~sAIVhqE`54oazvR0P0B$^1pv%VCH#gYF={BY4%;{sM zGpex}R~V}bGzMVZ1os)HO_*%Uo(ddrDBfm8H+IHp&3bbZUb}PrZD74&tXC(IemVz% zziX|+Y4ww7*`D$dgR!Htl#S{lat@5>MT)Q~G8))51^4{5_6S5H(z9v=uP~R0fE57` zWu7^{)0#m-+EJWD6nEWiW3NL*6xliU-;SF6T?G$`;Y@~CAL|agT#|gthW-ze3U3LD`HHs~x$|8JUx?w-}M)h(vi3Y=pd7yU+s^ z7z_e`ug_pq{w_SquNoF=^@NowCbQlKh2|GMAq00M5V1HL*pL=fQ0T?F&|W2?Zs{&u zlK!VrV`OP>D-soPM!Rvu>I5vFkaJfbhliNC@?U-Va~~5m;Yj+J6Gi)JaJ)x^q#)d-9OXd|Y7h$V2hs zW=S{VXwIbQ@-cZ_Hsw)%TMC^HMKAu^pYUupcqUdW@8KkVl1tc#s=Eq_hOynjYT0(v zE2DiegH1Cd#Rjwa->Fn9b#8UQPJQ~oUkd_Hkn2Q|jFRp*5JVmy{o{oEXfgAq$3XEY z(`dFwHJf_hj0_XGJ=^##toHLBg})Ez`Z~t?Tja)Ul9}q5V61jjqJMJZ-LBKKjcIms z5hFYb@DU~7dGWvcl0dwX0~3qD*S(PJI|ZWw(A?RRjZcW zrJH>nb^0$J%Y0B@qf6?I(dg-CKmCay^+O0IbQrA2KTi;t)Hpob^UM%bn#JBXXNinM zQf)pa727j={Bu?O;|5W;Q69nNsu78F zl1MYnkIa*xL)I}zj$;Y8ZYS3q zl;X*F<75fS`|yyJ_uZEeRU(VwrBK!PgMDc@7DW;rYjo^*%Z4du`;e@kt&uRcWo~?^ zna@#ygAh+rS0(pr1Azj~7#tqM8*n*BJXVQVlQ<=*LWJdSZn;-$PL|+9mU&~6X2opi z<}<;0^lsnCw~%~r?OuslqJcAstk60#UE}_v)O~6+jIFtradrKb4~>ARL1y#_j{(; z_G~ujxp(?B;*u6Jw}m>T1z;dcav;*mr{g7CspQtoH^C)*2HG5*z^rI$Xy^%i zFm(fz$a5@Zw)@%_Lk6=W4UC4a znOCS?`{WyX<8=1I9-#HlJ>c!K-4E>F{|}zZ?dSqeANrFFFh6QWqwmfWaVjw;#yMQih&XGBYBp ztDtv37MZGaB5Mfpk_8O1qP+o3!D@eGw&_e&kYxM#z$6Cz9$FpWg;VE>kCkBP4cE`U zvah(aJxu#5bMtINZE8B4)cH8XTnPY+tWgNfDC3r+;nxRo+P?28R+@KNWo^OjwHMX` ztHlm#rgWBjTsJ&RRGkVHBBfG)eyZ zL0q+B=4nQPsBqa)z&1>?#cyei!g`=R}AkfjOxBbh`_&^xEsE} z0>I2(7U}jY?;m`olH|3ej!>aQpCHz(Ggx>n)Dg|B2V7Es~De*SKFiGBC;Cw*Nht6POYxMgvjMRK}d)zZ4?7w_JO=O9dO zSc>1{BurkQ8~1tV{Vm2|=w+6;oxc3X+OXMfTvE5@UQ%!oMdOeglN18o28y#J(6Tg~ zzQCoDQRX_^+kw#SWB>*+731}N3gtW|O;3Q*7|0nFO%GL7frWhZo;-;%ql_0ZBtZD- zJFK?ug9~S^SIXCq*}b!!?p$s+3Kyo`6^OcAbNI+f*h9a7>C?kbIQUZJ=%s3`_8M&< zDzI5HjFoQvY`(MjqMlL-R(l`%6ElE*1O;Rh4Awg72q>4MTSKuDF^ZesRJLN=@uh@HTcRB{Gj#d&vgB^yKM`N()5xsr!)thYLFO)-LcU7ST*&{w< zu6AWvS8Wx)-qtRSG8oK=+Iq4m7r%19MoO8ei@m3TxRVR;hFDzUtzis=NG)S+H8=aKQmpo9sK`xnA}Ubc1KJTb$zBPQckSa|PNYX4fW0b@{-mLf z2v@uULZ0pRk9D_?QsdoVU)$8NTsKiS>;+lt8E^;-Y;sHAfx`5@wO-FBfMHc$MGEu) z+pE9AQkAE1>)T!XsR$|*iYiM@NkQMYFp68F7ukVAg)4cT<^9MHTu{XDDwXV%ijmOC zUGxkV26wpfT5UbN=pL|f-)a$&WRh2)iFVo-(Aj>Z!UJHk@fDDL;|56*8MZ}84IQ^; z*;SANZnK4ov%6hP8=AReO)dymt|i|qyr(JMFnHtpX)pNo;{~f35P5!HwX-$90x-+2 zIi8l`Pc2|Gv5Efs^EHr~#lxxNfd77sVNniPN=N|aNSXx2xHez0+UrNoI$;*J0qQWlh!$b)I_P7Z11g(IqWUYZ zh1cY>2s#E@Hv_CtQ5QK`kY%Cly0oZQR618S-QQ#&4+%rZIhKLvXAI1dS->XpK^<*; zA6_|ldnrS{)J3YJwHvggW;a-*oG<0BL8p9R_5I=0reWlI9vt$D3J^@o9R!ZmSgOdB zKTI^zI3WT^!W98GuCzHzPTnU}Zkn9gkuawbyj7gxa|PTXQLpU-vT$tZL4TCP_7(@{ z_TcS?#GN0OvAUf}3Z$#3(HM}OGXnbNDV_Zkp~(X3NA(IXL-nED%0{R@^$c#F8@@uL z=I*n-Zi!%c-Z1JbaGS5`>3p)Mz4>BJiy~SoaJ&x1cLO)C3DMAv?M>&6=8&U4zChG) zvgs>+X6o8upjqf<$R%ionE;OL^?gF7)4`nKjwimqtlIjp6v3;5s7-_(unwb105)i! z#L$h4K*K8lytn>HYeMpikJ3$rK@GGSnQ~^Zz(zxFqFg6hUSIj}kY4q|(s~7GbaS4c z{@g4kO9y-E1yk_da9G4o6h?ot;yOaZ*buCwsyxJ81@)UXF?6ng{GT^rp}(yT=(Xu+ z2FS!|64Ot`jR2Ceq!N|9C8e%&&ry|QNn~ZE&tW{|HWwlScI;9;?)nEPOkJYdtju6~ z3B;nFL)oofC_+BzlR?V3L^$U2!+rHbF@v@GNurqj?Qkj=L{A&BOCUGr^ShL)L_jT9 zVD6Vqbb%3KH~A?|sK9lpRpk{qVIXBxwWbh&;9Z_cr^f2AXm#!n{(1Bg=ufdE#xFw5 zsUWK2y&vK#^Jb_ltFPXx1D3!=&f1JZAXfWlhjvF`c0Oqc7lBD=6pDC@VGC{T#gWBaHYN%vSQ-ab6Mz)>KH&<#ZU_S9LNW@EvfO9FkyIa{BWW{?9VcB9*U@7 zjqVtKPbd#pR&Q16JZ7S#muS!c<-xA?GcXQX>L&7dvp>OFfk`qjJ`RB*$EcLV?1auD zxjZ(XwI`dO6obMhswjm@@Xnv9eTygA;gkp#|9M_u{#Y$+vJ1kX^W9tG*hy&)(HFJ7tVPUQWg^`N9sYjUV5Y;fIu(1Mg1~sP0uA2#MNWNG__Gz=o+Z0N zE@iLVRPsL@c`SOVQ)QLZHS{i>q_&Zs+d$A2(dd#Yg-ORk8Gi zivSw-E>BZ;Ao63fq<11!HI3uhMl!)VZ6n9yX%3BRn!P7U>Vyqc>AJ@GOkK7kb|2(Q zI-l2k|45?QJG?MRGlOu|A<-wVxm@=oUk^YRjg?a4;p`|sF&u2K+=M}i_tGdr5Kce) z%!k>&)|(Nm_qj;n6Y3plI)zQ^Xwh^~eJI z;mL>Uf%l9iEao99;?#AISArP$$h-CKlrxh0%$d#AN?p< zd&gkN&5IQD$zbL|f3j4wIcsgh572wD%majHO?FcKyUJzCkU@TuEUUa z)yQL%Dux7q#H9!MBd81|f&sDrYB}Q_P47<>mLdM`%GC0`wCzydo*_`@;hjqQl@Qhh zP5CVs9zpfAhN{oT&QEITxfnJoLRgCvyym1<$HD6`F^oWH(IZA0;rF}*eRl4%~k`}tons~uLOCJrRgmV-s zWNZJx)H6lGxsa!$9`=%l*QrqbUgRDq+wl7KN&b-QAAW4v4OI@LCGsgFoK-Tnv7!Mz z7IFRFgsCZM$c1y=5k|KS8AQ*09f;*QyHUELJE5S{D&TFy8ibr-luk5wWpnz0_J)8c z{A^;|Hrp)4)laFnWh9kqa)Z;UxEk1EI*!asGh09sqWzXYRCcpJhq7^7$k!>#TR^|B zr;9Pz1k_`;EN-9L(VA)JGkVlOXvO{+NJ@h z%L|KlZcMwyK^{D5^t1K{}iei2cyo^Sp%Z+8g~K@A=lF*5ko|7za;-&_1`>i_Th zLX~(w&nV{g_39SbR-aR}XPSMudM0nbn*^B|8#)9d^Y~o=VU&c9mTC_vLUuqxlGsZu zew`{}FFI9@HJ5FEz4hV|O!I;7%>#-sSCF4XyoCvVIN`_P%H?_tZ)n`L=|8VT3?U*x z3BC@lZWH`@v!5R!7eRSJ?TxrL&VPK0+Xu6WW5I>;CjkEEZBi5Ai9TPKu>bf}32dzp z>~UnZ{rN+<`6Lg-8xy5u|M}^@eFP#0iPp>Ceh4qN0kdn;QuFRVKmEV2iwyezPkML3 Z_gh?1>9LPk1r+?FrmU%yd&1oRe*oR;V#NRe literal 0 HcmV?d00001 diff --git a/docs/service-brokers/docs/assets/003-REB-API-service-class.html b/docs/service-brokers/docs/assets/003-REB-API-service-class.html new file mode 100644 index 000000000000..3052a1abb7ad --- /dev/null +++ b/docs/service-brokers/docs/assets/003-REB-API-service-class.html @@ -0,0 +1,11 @@ + + + + +003-REB-API-service-class + + +
    + + + \ No newline at end of file diff --git a/docs/service-brokers/docs/assets/003-REB-API-service-class.png b/docs/service-brokers/docs/assets/003-REB-API-service-class.png new file mode 100644 index 0000000000000000000000000000000000000000..4fda18eb6659360f8c4926577932178628c6db3d GIT binary patch literal 58209 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 G`u;zK+W@Bk literal 0 HcmV?d00001 diff --git a/docs/service-brokers/docs/assets/004-REB-event-service-class.html b/docs/service-brokers/docs/assets/004-REB-event-service-class.html new file mode 100644 index 000000000000..1c46c6173ab0 --- /dev/null +++ b/docs/service-brokers/docs/assets/004-REB-event-service-class.html @@ -0,0 +1,11 @@ + + + + +004-REB-event-service-class + + +
    + + + \ No newline at end of file diff --git a/docs/service-brokers/docs/assets/004-REB-event-service-class.png b/docs/service-brokers/docs/assets/004-REB-event-service-class.png new file mode 100644 index 0000000000000000000000000000000000000000..2ebeee3a0a011bed5878ddf357f0afa478f2a680 GIT binary patch literal 67432 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≥TcJBhF{+x_R?t@2V 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 JFMa#*{{f)m_uBvf literal 0 HcmV?d00001 diff --git a/docs/service-brokers/docs/assets/010-helm-registration.html b/docs/service-brokers/docs/assets/010-helm-registration.html new file mode 100644 index 000000000000..6e46159b0387 --- /dev/null +++ b/docs/service-brokers/docs/assets/010-helm-registration.html @@ -0,0 +1,11 @@ + + + + +010-helm-registration + + +
    + + + \ No newline at end of file diff --git a/docs/service-brokers/docs/assets/010-helm-registration.png b/docs/service-brokers/docs/assets/010-helm-registration.png new file mode 100644 index 0000000000000000000000000000000000000000..ea1887c7a40df0194483d6ab2701c5d4c518a522 GIT binary patch literal 48987 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 + + + +011-helm-architecture + + +
    + + + \ No newline at end of file diff --git a/docs/service-brokers/docs/assets/011-helm-architecture.png b/docs/service-brokers/docs/assets/011-helm-architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..e872fe888272d9352cd505dd58af581f392bc9c0 GIT binary patch literal 52945 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 B5mW#G literal 0 HcmV?d00001 diff --git a/docs/service-catalog/docs.config.json b/docs/service-catalog/docs.config.json new file mode 100644 index 000000000000..1f9d5e472bd9 --- /dev/null +++ b/docs/service-catalog/docs.config.json @@ -0,0 +1,8 @@ +{ + "spec":{ + "id":"service-catalog", + "displayName":"Service Catalog", + "description":"Overal documentation for Service Catalog", + "type":"Components" + } +} \ No newline at end of file diff --git a/docs/service-catalog/docs/001-overview-service-catalog.md b/docs/service-catalog/docs/001-overview-service-catalog.md new file mode 100644 index 000000000000..2d59ee136cc4 --- /dev/null +++ b/docs/service-catalog/docs/001-overview-service-catalog.md @@ -0,0 +1,15 @@ +--- +title: Overview +type: Overview +--- + +The Service Catalog is a grouping of reusable, integrated services from all Service Brokers registered in Kyma. Its purpose is to provide an easy way for Kyma users to access services that the Service Brokers manage and use them in their applications. + +Due to the fact that Kyma runs on Kubernetes, you can easily run, in Kyma, a service that a third party provides and maintains, such as a database. Without extensive knowledge about the clustering of such a datastore service and the responsibility for its upgrades and maintenance, you can easily provision an instance of the software offering that a Service Broker registered in Kyma exposes, and bind it with an application running in the Kyma cluster. + +## Operations + +You can perform the following cluster-wide operations in the Service Catalog: +- Expose the consumable services by listing them with all the details, including the documentation and the consumption plans. +- Consume the services by provisioning them in a given Environment, which is Kyma's representation of the Kubernetes Namespace. +- Bind the services to the applications through Secrets. diff --git a/docs/service-catalog/docs/010-details-resources.md b/docs/service-catalog/docs/010-details-resources.md new file mode 100644 index 000000000000..422e315bca00 --- /dev/null +++ b/docs/service-catalog/docs/010-details-resources.md @@ -0,0 +1,22 @@ +--- +title: Resources +type: Details +--- + +This document includes an overview of resources that the Kyma Service Catalog provides. + +>**NOTE:** The "Cluster" prefix in front of resources means they are cluster-wide. The corresponding resources without the prefix refer to the Environment scope. + +* **ClusterServiceBroker** is an endpoint for a set of managed services that a third party offers and maintains. + +* **ClusterServiceClass** is a managed service exposed by a given ClusterServiceBroker. When a cluster administrator registers a new Service Broker in the Service Catalog, the Service Catalog controller obtains new services exposed by the Service Broker and renders them in the cluster as ClusterServiceClasses. A ClusterServiceClass is synonymous with a service in the Service Catalog. + +* **ClusterServicePlan** is a variation of a ClusterServiceClass that offers different levels of quality, configuration options, and the cost of a given service. Contrary to the ClusterServiceClass, which is purely descriptive, the ClusterServicePlan provides technical information to the ClusterServiceBroker on this part of the service that the ClusterServiceBroker can expose. + +* **Secret** is a basic resource to transfer logins and passwords to the Deployment. The service binding process leads to the creation of a Secret. + +* **ServiceBinding** is a link between a ServiceInstance and an application that cluster users create to obtain access credentials for their applications. + +* **ServiceBindingUsage** is a Kyma custom resource that allows a ServiceBindingUsage controller to inject ServiceBindings into a given application or function. + +* **ServiceInstance** is a provisioned instance of a ClusterServiceClass to use in one or more cluster applications. diff --git a/docs/service-catalog/docs/011-details-add-service-to-the-catalog.md b/docs/service-catalog/docs/011-details-add-service-to-the-catalog.md new file mode 100644 index 000000000000..2a67bd345e53 --- /dev/null +++ b/docs/service-catalog/docs/011-details-add-service-to-the-catalog.md @@ -0,0 +1,15 @@ +--- +title: Add a service to the Catalog +type: Details +--- + +In general, the Service Catalog can expose a service from any Service Broker that is registered in Kyma in accordance with the [Open Service Broker API](https://github.com/openservicebrokerapi/servicebroker/blob/master/spec.md) specification. + +The Kyma Service Catalog is currently integrated with the following Service Brokers: +* Azure Broker +* Remote Environment Broker +* Helm Broker (experimental) + +For details on how to build and register your own Service Broker to expose more services and plans to the cluster users, see the Service Brokers **Overview** document. + +>**NOTE:** The Service Catalog has the Istio sidecar injected. To enable the communication between the Service Catalog and Service Brokers, either inject Istio sidecar into all brokers or disable mutual TLS authentication. diff --git a/docs/service-catalog/docs/012-details-provisioning-and-binding.md b/docs/service-catalog/docs/012-details-provisioning-and-binding.md new file mode 100644 index 000000000000..c53f0cf694fe --- /dev/null +++ b/docs/service-catalog/docs/012-details-provisioning-and-binding.md @@ -0,0 +1,67 @@ +--- +title: Provisioning and binding +type: Details +--- + +## Overview + +Provisioning a service means creating an instance of a service. When you consume a specific ClusterServiceClass and the system provisions a ServiceInstance, you need credentials for this service. To obtain credentials, create a ServiceBinding resource using the API of the Service Catalog. One instance can have numerous bindings to use in the Deployment or Function. When you raise a binding request, the system returns the credentials in the form of a Secret. The system creates a Secret in a given Environment. + +> **NOTE:** The security in Kyma relies on the Kubernetes concept of a [Namespace](https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/). Kyma Environment is a security boundary. If the Secret exists in the Environment, the administrator can inject it to any Deployment. The Service Broker cannot prevent other applications from consuming a created Secret. Therefore, to ensure a stronger level of isolation and security, use a dedicated Environment and request separate bindings for each Deployment. + +The Secret allows you to run the service successfully. However, a problem appears each time you need to change the definition of the `yaml` file in the Deployment to specify the Secrets' usage. The manual process of editing the file is tedious and time-consuming. Kyma handles it by offering a custom resource called ServiceBindingUsage. This custom resource applies the Kubernetes [PodPreset](https://kubernetes.io/docs/concepts/workloads/pods/podpreset/) resource and allows you to enforce an automated flow in which the ServiceBindingUsage controller injects ServiceBindings into a given Application or Function. + +## Details + +This section provides a simplified, graphic representation of the basic operations in the Service Catalog. + +### Provisioning and binding flow + +The diagram shows an overview of interactions between all resources related to Kyma provisioning and binding, and the reverting, deprovisioning, and unbinding operations. + +![Kyma provisioning and binding](assets/provisioning-and-binding.png) + +The process of provisioning and binding invokes the creation of three custom resources: +- ServiceInstance +- ServiceBinding +- ServiceBindingUsage + +The system allows you to create these custom resources in any order, but within a timeout period. + +When you invoke the deprovisioning and unbinding actions, the system deletes all three custom resources. Similar to the creation process dependencies, the system allows you to delete ServiceInstance and ServiceBinding in any order, but within a timeout period. However, before you delete the ServiceBinding, make sure you remove the ServiceBindingUsage first. For more details, see the [section](#delete-a-servicebinding) on deleting a ServiceBinding. + +### Provision a service + +To provision a service, create a ServiceInstance custom resource. Generally speaking, provisioning is a process in which the Service Broker creates a new instance of a service. The form and scope of this instance depends on the Service Broker. + +![Kyma provisioning](assets/provisioning.png) + +### Deprovision a service + +To deprovision a given service, delete the ServiceInstance custom resource. As part of this operation, the Service Broker deletes any resources created during the provisioning. When the process completes, the service becomes unavailable. + +![Kyma deprovisioning](assets/deprovisioning.png) + +> **NOTE:** You can deprovision a service only if no corresponding ServiceBinding for a given ServiceInstance exists. + +### Create a ServiceBinding + +Kyma binding operation consists of two phases: +- The system gathers the information necessary to connect to the ServiceInstance and authenticate it. The Service Catalog handles this phase directly, without the use of any additional Kyma custom resources. +- The system must make the information it collected available to the application. Since the Service Catalog does not provide this functionality, you must create a ServiceBindingUsage custom resource. + +![Kyma binding](assets/binding.png) + +> **NOTE:** The system allows you to create the ServiceBinding and ServiceBindingUsage resources at the same time. + +### Delete a ServiceBinding + +Kyma unbinding operation consists of two phases: +1. Delete the ServiceBindingUsage. +2. Delete the ServiceBinding. + +![Kyma unbinding](assets/unbinding.png) + +>**NOTE:** The order in which you delete the two resources is important because the ServiceBindingUsage depends on the ServiceBinding. As long as the System Catalog does not automatically block deletions of the ServiceBinding with the ServiceBindingUsage connected to it, follow the recommended deletion order. + +See the [Corner Case](013-details-unbinding-corner-case.md) document that explains the consequences of deleting a ServiceBinding for an existing ServieBindingUsage. diff --git a/docs/service-catalog/docs/013-details-unbinding-corner-case.md b/docs/service-catalog/docs/013-details-unbinding-corner-case.md new file mode 100644 index 000000000000..4e9741064c61 --- /dev/null +++ b/docs/service-catalog/docs/013-details-unbinding-corner-case.md @@ -0,0 +1,18 @@ +--- +title: Unbinding corner case +type: Details +--- + +As mentioned in the **Provisioning and binding** document, it is crucial that you follow the order in which you delete the ServiceBindingUsage and the ServiceBinding resources from the Service Catalog during the unbinding operation. According to the deletion process, you must delete the ServiceBindingUsage first and the ServiceBinding afterward, due to dependencies between the two resources. + +This diagram shows the consequences of deleting the ServiceBinding for the existing ServiceBindingUsage: + +![Unbinding Corner case](assets/unbinding-corner-case.png) + +When you delete a ServiceBinding, the Service Catalog does not populate this information to the ServiceBindingUsage. It is possible that after you delete the ServiceBinding, the Service Catalog does not clearly show that the ServiceBindingUsage no longer works properly. + +After you remove the resources in an incorrect order, the application which consumes the resources that are linked using ServiceBindingUsage can be in one of the following states: + +- If you do not restart the application, it still works correctly if the Service Broker does not discredit the injected information. +- If you do not restart the application, it can stop working correctly if the Service Broker discredits the injected information. +- If you restart the application, it does not start again because of the missing Secrets. diff --git a/docs/service-catalog/docs/020-architecture-service-catalog.md b/docs/service-catalog/docs/020-architecture-service-catalog.md new file mode 100644 index 000000000000..352587fc8b9e --- /dev/null +++ b/docs/service-catalog/docs/020-architecture-service-catalog.md @@ -0,0 +1,18 @@ +--- +title: Architecture +type: Architecture +--- + +The diagram and steps describe the basic Service Catalog workflow: + +![Service Catalog flow](assets/service-catalog-flow.png) + +1. During the Kyma installation, the system registers the default Service Brokers in the Kyma cluster. The cluster administrator can manually register other Service Brokers in the Kyma cluster. + +2. Each ClusterServiceBroker specifies ClusterServiceClasses and service variations called ClusterServicePlans that are available to Kyma users. + +3. The user gets a list of the available services in the Kyma web console or CLI. + +4. The user chooses a ClusterServiceClass and requests to create its new instance in a given Environment. + +5. The user creates a binding to the ServiceInstance to allow the user's application to access the provisioned service. diff --git a/docs/service-catalog/docs/030-cli-reference.md b/docs/service-catalog/docs/030-cli-reference.md new file mode 100644 index 000000000000..692131ee245d --- /dev/null +++ b/docs/service-catalog/docs/030-cli-reference.md @@ -0,0 +1,74 @@ +--- +title: CLI reference +type: CLI reference +--- + +## Overview + +Management of the Service Catalog is based on Kubernetes resources and the custom resources specifically defined for Kyma. Manage all of these resources through [kubectl](https://kubernetes.io/docs/reference/kubectl/overview/). + +## Details + +This section describes the resource names to use in the kubectl command line, the command syntax, and examples of use. + +### Resource types + +Service Catalog operations use the following resources: + +| Singular name | Plural name | +| ------------------ |---------------------| +|clusterserviceclass |clusterserviceclasses| +|clusterserviceplan |clusterserviceplans | +|clusterservicebroker|clusterservicebrokers| +|serviceinstance |serviceinstances | +|servicebinding |servicebindings | +|servicebindingusage |servicebindingusages | +|secret |secrets | + +### Syntax + +Follow the `kubectl` syntax, `kubectl {command} {type} {name} {flags}`, where: + +* {command} is any command, such as `describe`. +* {type} is a resource type, such as `clusterserviceclass`. +* {name} is the name of a given resource type. Use {name} to make the command return the details of a given resource. +* {flags} specifies the scope of the information. For example, use flags to define the Namespace from which to get the information. + +### Examples +The following examples show how to create a ServiceInstance, how to get a list of ClusterServiceClasses and a list of ClusterServiceClasses with human-readable names, a list of ClusterServicePlans, and a list of all ServiceInstances. + +* Create a ServiceInstance using the example of the Redis ServiceInstance for the 0.1.3 version of the Service Catalog: + +``` +cat < + + +Draw.io Diagram + + + + +
    + + + diff --git a/docs/service-catalog/docs/assets/binding.png b/docs/service-catalog/docs/assets/binding.png new file mode 100755 index 0000000000000000000000000000000000000000..5c892687055fa1846a95336bdd230a4f3411c02c GIT binary patch literal 41283 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 uLcpvFSLo2iRp9B+)nsT56y3@7$Nst7iSTPjO0 + + +Draw.io Diagram + + + + +
    + + + diff --git a/docs/service-catalog/docs/assets/deprovisioning.png b/docs/service-catalog/docs/assets/deprovisioning.png new file mode 100755 index 0000000000000000000000000000000000000000..9f9cad7c9d43c886513f4482e3cb0b1f97f65b7c GIT binary patch literal 20668 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 + + +Draw.io Diagram + + + + +
    + + + diff --git a/docs/service-catalog/docs/assets/provisioning-and-binding.png b/docs/service-catalog/docs/assets/provisioning-and-binding.png new file mode 100755 index 0000000000000000000000000000000000000000..400bc74ae549ae13a32ba6ef3e8fc161a0768b80 GIT binary patch literal 61528 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 DjeQfD literal 0 HcmV?d00001 diff --git a/docs/service-catalog/docs/assets/provisioning.html b/docs/service-catalog/docs/assets/provisioning.html new file mode 100755 index 000000000000..4ad19451b408 --- /dev/null +++ b/docs/service-catalog/docs/assets/provisioning.html @@ -0,0 +1,12 @@ + + + +Draw.io Diagram + + + + +
      + + + diff --git a/docs/service-catalog/docs/assets/provisioning.png b/docs/service-catalog/docs/assets/provisioning.png new file mode 100755 index 0000000000000000000000000000000000000000..ae7b1e4183742bd961c04bddb5715256cd943ff8 GIT binary patch literal 20617 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 + + +Draw.io Diagram + + + + +
      + + + diff --git a/docs/service-catalog/docs/assets/service-catalog-flow.png b/docs/service-catalog/docs/assets/service-catalog-flow.png new file mode 100755 index 0000000000000000000000000000000000000000..8c7297284a7930d0245a05071222021f75db4f92 GIT binary patch literal 96454 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°q2bApg#feOb42`V;Bm( zUV_ipHhA}!O%SN#kfm=w|AdIL42WpH3UXioq}v{TMmDv~T!(3HPZtHNo9wlR`=wBM z_Q|jFMRt(gz6A_Zr_Cx@hmrDUPvD$0xAmAT_m|zNs@$<6Rfz?l6PT_t3l##NJJRgs zmcs9tlkiAWKEvlsx2EF~h$<4`)+Rs@(N_n6>V!1=rk+|+(AH!HOg}F69ap;K`crun z)3m(cZlXZAG{TbvG$v`szi+M_K(lYm$^N1d7pxodd@v(X+sH>V1c%}gAavd?b%Z_z zRqk3_01}t)wfo}?xQ+>kR{F^)eNI$I*Vp^g%ojd+So{E9rrax&H}_ue^e|*{KPeIg z?i(N_iUu9`Urr{0o9FRqURS;k9Q@vAhBp`9A^|3B-?t=QBrA8kU&5F7>g? zp7GjF8N2ELRgJC)Fz^&`KgfD7+`J$N*LMjxt#Tl|$Pn_#1M5h?x(A#vg^s#u;k{pQ zQi{c>We&OPquYKGI5eGPgM*^KS<-mz;z3oHcA(p_4%}j%g0BKVDf%O@-Dx`nuXljs z3=h<&Gva=Kogf42`EZMm*A__4b5=YLJ0J89-OUej$^Dl;(A!nwF2Z2LZpH+L}K)_T|Pr6OEi0tM}T_8ehY-)Wc6Bu&^_rJ z;GP1Pu2{&_PUN#`m%qEv4YHb7Eu%gFG8H-?Pm2fkd%XZMQ>dK#9;_D_6-5EDlKLwbN6V(03EH5hdlaVVnn_A^8=&1zbIe%x~}Zoept4`wDOWA65SB zxTOPLoaKf)LI0#0+%NIS6?y-1CjYw-+W>-hnGHQJa4Hh_KP)`Oepy(BT1ooTx)@9N zL=f8rF5VzGv%ZV>9-H-w6Bx9pTXYC=uVw(Z~2vYS%NK1FfTYfG_ z(tXWe69qzIf(kP1h0}-OO$0(qA4(sHR;vpN`WbOD|NZ8mJ0mDl1$KCo8e+$&%rLVl z{oNNFzyW&taNfK^Ki~insX^T87szz_CZoh9Ot1}khzEc$oe7wlM4x)Inq{-gDiF)t zq+4KDPL}CEmpAkVX0}J4G1s~vv)G#M1Hy41=KjodJXow7!!3} zE^2^=t3`%u7yImg012eF%XxjsPI&ZFH#M7PEAg#6i$mO@e_v;GcN%b?D;eyhz5&5X zT(w#h!QWN$9B5d`J@!2~@t6ZyQ9h6Tr$cFiPkLYN8s;s4$bBA|z|`^9-jr@w7vuOq zmw+CU6=*KP9w6?#wwzBa4X6T)>ao+tfSs%C&Qq*H#WZ^0Clr0xokw!QSpO+ZXLm~p zwlm~JjVpRx$yAlyege#}6osuQf|9O@g*|JFAD5XlPUK8b43P?b+@G7MueEpyY>N~s z%Yk==(B8)yuyOF8B)N0$lA-tHqw~gq6R@9DZ`HHWV<3OKM?UHQ4)9q2zxPwQq@Wf@ z#!b-e$JB6eTT`U28|qs|)z87B0K(&Z$j{wv=-F3p{0}Watz{fjN;b4Eqy-I!oTGIC z_-Lez`y(Lr;bO-R%WKh6m!+a<4*aOEGKi*E2%@lRBNheG86 zWg%U9zO+l(z^`dYPfw!N5BSE^1i_)ob&Ywu($sdgZUo(|AhRPpp7_VmfysBEmJb)Qtc*b01SnjpG~(^wtC~j2{F5t z-FnF{`Zo2|{12(G*zeLh=0E?)k5A{I&RL7;CU|$7{sCU+-=}}5&eZRSp3%mZ`lkaA z0GH}(e2>7}&&~c1pW8NI%oaSFV*?rmDvuW!K(Bx(jq;1z?2qt2Wq%}nuU_c6M2l4; zx-MJ=kMtbLtlquNbYK0aO!r9;$aEvi@nyBz2H-Xax;J4$?YGIF%704!o^w3-Yz30P z9*iju(Avw;OBKg(%b5@XlIY(jx>)Pt{H#sV5M6{P`@kgtc0qfDe4EsZnZEFFLFvf5nwh$$;!#79(g zr7F`-{Rn-g&iQ#ZBit5yH0sM{WK_#uwGGp{!8Q+quJV$>blHgxcZ zzd2r|DPCV*t$7lkass|FGPn5mz2+-IJUlUIfSf4>^r`7Unw7Bofj<$8p7_(GGQ4?0 zHjP+8H(O=UN{;vY_vyK+p!+7D^&)T7^4FOCVHKc;&C-@!-QU1 zLm5y^3)SZZ>lkIueo&rzT@6sAwi@H3prVcdvu`OX z@LJ@$eM=w`1$kjEs2x>RR3JV&7f3N(Vef|liM82n>tBJNMT{Wr5B^{Y^1mNsOrOtM^T{IztU(q&qL-hLSmD6C z!XLViRj1WDP3a2j=+=eOCw6BWilHm(CkMl{){_N1=HC+H3KTP?AljigB$9PD<5}Z2 zlcRjh+M_d?y*-0SY9x~I;SfdcRXJl-KKc!c%`XmV#vR6@?lCAS_;~q=gW&%dGd5e3srjVb*4{Dz7M?p+0CDd_W8ljK9%C%8%|E(jpv&p zy8X?!KfMwQQ41@uOj4fNxvnp8Jv@~(An?K|WyY78Bu3%8K%_Q4x{jZp=(kjX6|&XZ zq%ZFZbpXH>u2p9EwC1@&J^+N&%d$oqP9g;j0l&zC`GeFV<`3Q7lcf}8gNh| zx?}Nk$eFH?talLUa-!FhGWAPl;`;gfSX%4F^NS$A$u2lr30T~k5-mjLjLwN2U0Y<5 z3%~b}F8AWZ3Pv|xI_h7d{a(J=%QYrsZBZs>c*um-YUz5q_V*PPGE=j4r&{V0KWfln z5MVuC#I{Yv?;hs5KP`iFb)F9(hoM@M$5x%+#+RFi))kANZoICw?UYaDw0Z4rchGjD z0UG?pL(VGW%r@T|;i}BkT3H_W`<^U(G)JMn={!)?_~fpd%I6msJ0kmro`==2T1c;e zcl(rzJ6N#ZL8j4pjquC<+y$oP(sF(v|Ne9{Gmq7r3SQsSw|)|X8N#`}+T}x3t-s~0 zBu3N*Dd&sCZoEGiIByJ&9>ke#^&aNF@|LkYw#ZS2@w@E~M*^R920DqB4E_u_C4T+! z*4KCThZr4PikybD{u5Hdzl+2o^<)* z5H>F^7E|YWda2mtzV&E6CM*jLm+G!5u4u7(eirkj{ReY)ZQp4B*49>t*6!2zFX4=( z9!MQ=BUSiCuUqCSt!knZP~~jcJ51PjtK7Go^nh6iL^7^yHs4Fxdbsqi=C4YO(;vT> z)_UJ+%WCDxrvz^ESW9~xuM*{*E_KLQE>Egk&a74xsk8@b6hh6Q$b9xXnbwmfa+3~o zc#`%_v3MAE!%-MS-O4GC&F|51V8||0HeZe`(fZxpiUvORYMH)BMT$E~rb4lkUT1rS zT=`G$B6520qH7JJQinX@Fod}Jh08b0S}iv)f3+cUx5>be7VGU zuIKW~VB6Kj0+ACqO#FY!C2(Zl5S#_z`-vBv9FCp8^s8+Kj5_=bPr?75bqJ;VVf$0Vh~tT7&)FKoN<{lfVSc)hE36Gva0p z3VHox6KWBwJyN{R6(YZoEplaWa)Fybqy(0Jovn%feaO4k`48cM)i&`eoBL>PH5O*# zva%k{7dAC+GckOUQe)LyaLR2uMhx`qEwPtkM5px>i^E~6W>y>ZiU|-XVu@}8_374l z&?8MoWzF}K1r@ESVnbCKTrW{~=UQ~ChTe@{sfe8%(FLxXg_>R`%9m%Hh>xhV?uQ@uwu^2}A8&~7^FtB+ z5=+Pl9aCY_Vz$> z=|->fjG+WdUMKFCds7(;6&lpiG{jC1AT6QTRQDA-1Zb8KG|N==VVVpI$tvVRMR;S7 z{5YoQKFe+&f7yD`Is8!SiQ+{aa^x*QFr>(}w?jWC#dUJlvDQPJBRZOEKc=Vt))pC4j=l)(8O+Xg6kba={}an_D3hj_Vs@y8MJ>T z8J$k(987isZ;yjX5IU$Kv~9fP=O~@^r8t^1?g3~ zGwn`Q2e|IfKZs7KXOO2}tg=2OrDk$m{~;j%TT2jn0*vvLy44ImxQ9=a8qlLpAfg

      PSJq{;QVS+%xV=V>Q9`aW-0uBoaQ4;}kx1 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 + + +Draw.io Diagram + + + + +

      + + + diff --git a/docs/service-catalog/docs/assets/unbinding-corner-case.png b/docs/service-catalog/docs/assets/unbinding-corner-case.png new file mode 100755 index 0000000000000000000000000000000000000000..3b5fe6c54229b12d20390968219c062e8257d34f GIT binary patch literal 46129 zcmeFZXHb+~vo0(_L6I;ZpkxM#3aI3aGB5cv){eX*}KlEIzPX+il-h*oqN{m?$xXNx~{&1RFvR^*Ql;tx^#(9P8Ozi z=@M?lrAt^o_*cN65a0;kx^#)*k{s-zhMVDP^5xI8Ek}V8NRlSfXi0y(tLy9QbYWT<;zkw#p5&a27y1bo7uFN}i)+n!YA z&^O6NGattA0()$oq|8~*Hjy%7&I{Eo&kkeY#fA}XUtc*Q?0S%=47n;R%8@>(VFqEl zhxPh`a>OmK8b2oHWm0%F)p_#?XG7IKa@la{ByXi zF`o)?vi-PjxBrsv8k^O**IuYdh0NbU! zOd{I5jBh@vCPCsiL;6F|P#>MUFb#=xyOE-(!304jyrh^`P1Ft}^UJ~izJzQ~LfC{Z z6!2&Che0Dnu)=1m5+%uz$d}C-$k5y@q$C929Ab!jCpR2CnRX7F$e^ewSVv=7kmYS`Rm?gpck=9^B;;5^w5spRy25&E2VXc~w%*Kv8n7j3XL8K4-GJjhCbVoNi8kamN7 z`Y0Uy5?@kMDexEzm)ACz9w}*m)trXAa7|-p6JJ*JS`SNJ6h2H*GGsHSE59{JQUiC+ z31-^OFCep{mDsx^1Nol(vUO`*@xwJ33@ioKs-X`1fR9^_&+Q+p0f&%a0TZf7ynMO9 zxrYN`;{xVusYte@qr(b?*P! zf+;muHT;lZDZ$_0Sq9_Z@B`kFE{E(1fh)Y&EMgvtS8*oV)+e3Zj3dG2nwx9MJ+kqye?rn3O^4J>HR!WcM! zXVM>-B~oX&=Xd>b%D|sf0^V6?QMnAo1T54fj~%Ll3%o=5RX#5RA9)FwFo#}JawqQC z5C29yFyG9W5+88{h^S5LbgCf90gUtLa>f;Ed%F42CBGEjuqF=&Nh52*D9yP531`=)@x8-unC@| zihGCp_6W{~Rt?LMzl+A4xobi2WgMuAHRf0#BnHLrVva>=pd}M_FIl9U1`Qcg^WDmE zhyCM~BDB%Pu{PQ))jKq`pl*L`e?VQi>dmJaPAGZ9#knVC8uDjS?G<7=>xR?q5XE=*zP@;G8mFNeD(+DJ zAf4BeDa(BPWTRojFq{N|Zt(R@be(ipACFaxzHibxvN_$9{autd(R-&$fa_Y7c9G#j zX$Ix}(CbIt2(h*Pn)to7i96J<*tH>SS3v|Z-!D&K$4(>p?g^p076=_+VsF-Wn|_TN zHuM|?MzNwaaEi)52RYov_ppiH-%`G`8wOAIIbQ0{tv=gFulW;j**Q-9G$SGV3yHS6@mvPhLCkTo^P871SKp3}a?>D{s_g5mT`Zust?dOL zt6_(dbJrglqdhn34A!)DOvydxqTp&_Mm1}wL<9R4^0(44Z(b}3Ou980y3dm*IuGd} zy7-1W%RP*IjxrJDy)x_4a@%7L9h_^nz9)%$tD`*q+xezGXM4kFB9T>2Z)Q)MTuAI3g9?xu;fX?Krl%^f6OP18!XAN0aDD(*J|HBk~o4d z`do>wSuG98pUllpv4qpPe>@SerBxQ3#$~OyF6-h)bw23$oGNkrD`Wo)4)K$X$+|W0 ze_o2Fjk<%Bf|6D$7x2rN`(pAU#p-Dn#pl^@k!})ul8^i2j>;=P(q47or7adW5jk2= z7&xge!p_bQ8eEUG4BLc_Tt-dbyq)7-_8L%y3*oI+3>%icH%l3<>$>PLIyYWS^*#US zB&b>LxSX5KKY0$7+_G)Xd8;fwO6R$He|Y~DvG@pQLQ(Cu{K|M$8Lj7PNo#sa$O%b) zj=-?TO2OiYifi9CD(-|N0{M$d*eNF;XOW)p=DjcbXj_Tl$qCn~2_@mBkL>A$kCvE9 zhNYC?s}xGZ^}ua( z7e_rVGeNAvjCjvjy5^IqCXY$CG~5Su1+~$ZiyLQ~O~kASDG=9`*7ZjVW#n2<((?Kx z0|qb7rY=U6CC&!YO;mNbw+$?FGg&I?wh!h)WMKwuQzEaXd`=2PaB|0_g_9_WhKC0A z!P!sf5EF{Mmv) zgMGS4l=hxsEmG3XQqigUXdw}iUx0EL5Kkd|=Sn%TN_b=~J}0sqTOIIy?qMYYc$v} zy5>TraKXm;mb57xJR&DF`qW)^An|gE#KG0+knfDk4*R2KH79gln-5(__>TItVBG0( zTKe|VD?QprwlH6Xj^USXfFM5KCc2hsYTZoP5)+TzysE>t!$wEea+lsgyC zx2&tGf=#~LAiWUg<8Z(JVoBN80qu4H?uVNDJKZ81P7NXlJKSmXGpq!?T?=qp&PN3wOU5r&H_cZnbhPCdg1h= zqC1_lHe2+e3}W28$m@HQ&O#t-AgY(L93}kuP)8FRK8xMBb4-NRfhHnO1^ZenE0YVz zSFqIwsdmrE0`A&!(_>vPqjep(k1mFl-*I7xjt$|TbnFoezjgLX{7ZxT`>!-I8eB$~ z=4UB)2^usvK;CwVY8f#ev&gP6aGe<3A2A^oXmFfx>Q`*lQ3WBGP(yj5Vzl50jZhX@ z;}yW`i+DG@{TUQp4-HIPm zmsgmH;*L0hLlHx0|OpIfsr+SXa29?~%VhB_%jvx+bP zVS$9z#!6q`whe|v*qz%w&I^LeQPkIyMr?bE&izNESR$TnH`bcPj{CRV{Pv`EK12NM z^V+w3%h1t;kjPEoO2OWS?QJN*tn<7JcdKXjvu#z?*n?v>d8n|Qo_j0FQc%{jRYjRx zHI}`H1Dj^+%sVojX4Lm*IUil7oVg0o@mW1vE$guc(VhB9zLOFb{NUpDB39}zkZkQ7 ze_ZG3{JFqx=%w$yH>iH^;ed(HuilJrHy|jxbVGdoiZQ)gVlb>h6vwfD-Pvge{(^i! zhGqN)Qvu?Y!sN=1v|wZFAQvuEb6b1;pjyCvJ0a|4nj%nM+Y8elbwMKxNE?36cinqL zMzHhoWg}X-;mckWJO&;>LTT&SM!9%9%uS<;83)qdaP)k-Lq|anD#v(t>9IyI+>5cG zG#vk-^^`&|9A8OLS&&nZD3HbhNfv2Gqe~VMy_VO?I#5q)RX69u*|Ei% z@E!j0NBZ2m*6ct!kMBXSP;}^fpX<={KzvnPvR1inl*uX7|8m;v?=1AO zLB)@nulCAVo7iv$M_0X)Wm=zoeF;y3r-5WzjU7qC*|NXbBXQxSRzEH^8rlFV!}s|- z(5t-yFr_Wu0OE9Kb38m_9trnnZF;F|T0Scd(b^T0UlkzfF`tn+Iv@!rv%v<)O~lsV zU*t;XGf1uBGKVwPAL7RJs_F`vx4zTIbn8oLQ1t2!U_rv%ql?PdYInNdJdrBD+pByp zKXs1mpqI@|d&CeDzbs5*o*ZbUs4cb(R3`Il^6w{Lszoq+AXf&p zjhsWC_?BEUwO^CqT%&yN6E&IO*6f!Xu(+c&GS!s$JRqBQ24b5lRO~U+9Mt`A%U1LZ z>(kvhq3ekc&i9IZM|b;`H)x0^VUDUS(8vf;`8#w46+^n+&pw1BHRd|@`A`Oj$UNOB zI4EB|%;%f}Oe1P7-vXy8h;F9`No?d8Zm}(Ovi1a+4 zn`hfNk(~QptxEK&RGVx;yJHx~NrD7*TZo?F>efzz1VLnk6hk#L)BETs!-}zvkf6E2 zXt!o9z6Gx3MlGoYcKYP`%EYfB1&&DR&Mthz&7COIw))16>Dd!W2f9a*p5VeC*I%!M z93Pc1Z?PS1vuxmu@r5t)IpmGs1RZ*CZ=U5_eB8&FYVM1ff~s!8aUez54Z!o~aDdBd&L=H}{Yoibz%Ze4G|lzqdTtG5FfkoM~1# zolG?YR(G2lMg3NLTYh^+l(ln3xG~%igohGeT1lUabuSY% zjDXP+bZDu(cqf#?70`%it1Cfpov_F4+PDeIAVKZC&xb+S_%LN#<{KlkSsne-VzVgO z!#ZCRdH(ZD=G>7<_N}+LTv53tUl#LDNkfe!9eg0YV&{@-lPWv=!d1+j4Tu*}-RXly zG?&`xDVl1J7JiP*#eQ-$b<#Rj+$5wV!Xrw}EpAP5&jPObaz$L|Y`>gu=ZUy!dvhvCYb%OR z-2b&y)addYaYz;o(aVJ-k0X4#p6ZO$t5h=>_I)erR>f1y7+m5j}s7OBQ5r4_$C1fK7hD zNV7GAWB-Og2F9mq)Vi>k;@K@7znz8yReayTG0IZ;_2_$l+}Mgy=^>njoepyKje+1t znuj3>bA6aX7pOMgru&vqg8U^$(qvvnIVJdtc&lTWC(NxG?3DJc8MVA<@N+wL9@qyZ zP}nZxX3YUGXpJfI09xx^F+5QK`5B5^U+S;!FWXmPH9TZvv3CGlx8uKTtTc}v)v{HJ zddb~19q`j_Yaww*fXbdxaLXm>6nX83CI&hRHY>YpnFR6+f`AMg%cMZ!RW|`%8>d>* zEqHTKL#^FUrl`CUsBUyT);U4K$7viqe99!(X6XQyC}jkB1PSA*ix~{E4QH6yo@X_J zF)%P1lh7_R=|S2GK}+KrA%RTf)Ioxc&P597eJAmG`pye7jSj!rDkNSTnd73$@9Za# z9>Xos7Uv+?L7dj`pfOJLt*QaIgmql3@bpt1+S%VWOErXri6Vb6Q+3kcSnpxkJpxC; z_la9U!Q6;HGS@Utr?o^Xbo*$CiB40d-gGMz6_ee%^@2>>{XQAKyeRZ~{KJ$==7}0t z*KwUrM|@h_Tkr5spZDL**leMhlvL+`>)+spwp(~_R$V+Hw3t+;GpH&ARdK`I-UP(= zeq8zar1orpL3xyuk8i`~S4#ZaqAmnzi zbAecy2hgJ&*UI~q7Fw=A-ANAX*a8@&p0h%QDWhb(nD4r8Rk=dV^Q29qyWw;d6w+l@7I0;gVXDYc3dP;vih+Qlz79?nBLLHqaqKja~cZR17-=&h~k!8 zvgK(B1&8k`Bk~Y>`KQR&8)k>5=aP2Y3<+`duox-`YR(yS7J@eQwnzIX7moDk8(Z%= zB#e7M^-9mWmC5EOJs<&AHEw)(nRRY}p61&ZMdS-Z)?FU=`-~bsuhWKlI(}FT$Uw}A zG1+A;w`RRS@ulNUx7i@FSN&gx%G+@yTaVKr zQ{Ibx4sTTE`CL|?REmZ0tRd5P8`UyonTQ~1a$;uhp1ol&o&UtV+=?}N{-~J|8k?#a zazpz1%mI?4oE_23any(%6(sfDYT{dn>F3BoqXwV1-Fpg6A1n5=+A_4LmX*;}RHk=q zeOK3sXKj>ecRp*+y3q}2zW9XePZohsyXTp5md&=~^=fVE1E{VleCVv;sx`0=s<^?Q z@ohEGCOv`D_j}W8Zxx6(Fz%!Bz3oW>_Lpt2f{0xx@CahB!w--G_?#e0MKM^JL3RsS z_cfrUv`4TioAT=$oS$5EyJ`nAFGDy<1JDi(bkhmcHlNc7(&lCq2x=dW*wn1)3v%Nv zlt?6oC_gEkzit+|IZ380+Pk(?P}>&_rzu?|w%Nn$JL*w){COvpeBM}rE=q&OMYb;Y z;w2W&YEDLY!tDg|2EE+NP?bagWiHc#Od-w4ke$I6*N#d_s4Sh&AD3oO03cb}$Nbt3 z83w^EB*Mxo`2AVQxs`~Qv|or_rymYpwbYKF-m4k(>MGO<93U{aX*SGnC=Cbd3!)2N1wz9lOP2($AQ`{N>AV$Gzh=B^% z9rI{PD*>iLWh5kdd+b>tt-kp>Q-tV@ctP-w=1Jpyv$CS`jeIUWxQ1tk6u^VGM|3&K z{nE)I`&FF?__NLvDyf=OtB{g(7_9ks+XI%4kHzM`b*ml2UGzl=?yh4RDdX#nM2nEE zDzklSNA5+0T@LD^6suwOl<-`60HQY|I0s;!Nk_b`lAdGG9aJDHCs3Ai%2ZqC-y<1O zdqaBEfoN6j{ntD5bVPkJ*F}DPcx7EV;o^w+`9(C~j(9~oqaRK4V&ziHSla*>M3(di zRR8QDATZbf?0Ta`ZFER|M+@Z@TZ8{x4unCRQ>^PKJ}x`m7C8>ofq%-&QA%{iv8T@S zzy&ou6>_%}d2*c@n>0!+Li{*~EKHEQ4Nkn}T5-WNVE2+PO0lho?~q$agKl%Vh3SGB zd+K3ydf!Y4w@r46M_!2C%*+b8SJcxmz9BpR!Ih#0ap?xAL;z=c&S7X)L?MSB9xx>w zW6Du|P71$t5T~jd9ek)sV9E~lsvt=o0L9y~AY#cy{C+kw;nJhx>}4R>y~ZJ&3%EB? z@3u5?0#ZS_JAu%gear+)P~;zN4w7kWLg^h}*ngv4Y|hFrtZold!!+z6H}fd4<{B89 z7nz6k9lPYt{P3aTMr~prrxi%7b(oC0t%Q%}-V*vDe`SG2y=O2=8M!@(4^1A@wc@M+ zf5J@HavqTDtqve^{p3ggXLB-3Ct=!^AssVX5zwrz{d`62vd=hXezI2SYIYeiC{?Z^ zHq#pgr?Y4ZBzB#F_?Bf}PYaZodtWQ49fZ%;%~pMJeiFvUgi1uzZng4n)b3F2)d1&* zi9%if2awrM?r6L6NRjG3loV|eE+S<^iTpD!&c$VrspWEis*$ykrg_^FmJ!9wtj~UQVR~*a|USWYC zF%-)EC#nb%KL?C)h(s!fea?sUpl$OBDMxS%lyTQZ_9`%(J@Ssjzvq zTe1=#mjK{YIuO1UhPc5na6v?UiZw9cvo-yuB@`k#1dhI$;*+EJAj#=wCX~B<9ElNs zKE;_j%WQ4|R0W3NKzc3n%J5^}0;@;0~(b`Y@AhJKoNgs=I-Gze}$)2R|kZe5h1Mf2}JT0E~B+IBdbj z*zudo@rg&5G>6GRsNx?9MfZcoFa{xJ0fH1438YpI^ud zE}P%$%3!NwLwmID_A%+HwHx3zG6ADtShE(up5|b9H0ckKxW>8xfvdX|rH(m$nYaIZ zEXLr$ay;Gtw4EHvX-1K{We=#zjX$dj?h7d`Fni1`_P=c=lQKe_PI$=1*ZH&9VRyv- z;HEvld9#~Y*Zb@h^E0zw&sXl=D?pUYUFV1QbhlKLT5@57ZCGHoK^@tqNe(fOlKkEi zl|+m;Sh;RmMbcwt{5pn#o89$|Q~Y$h^2`OxJ;k4bH7;@Rk}wa~5;^sYg+ zQmkPRAhq+z{yZ-K{C~9yudS(qd{u~~8wlt?!1v{R#+7HMGuTZ-y0@>3fVeNrwSeCKMy|7ZaQr!e=h-)^D6p1ecZJ|b zfLOq=edO_yOU)S#5*KxA?q`$;)JS>%W5mI_$d|Z;I;+H;A7N0H7k?kzG5&2}-x%^I zMn2WYXd*B%V*Jrdnn43>T&f~;SrdkxNg5&8W8O$vsbg;M>%UHoG{~Ed4Y!!I6r?d_!}bHTE8S;5jk-q=wph3F4!%@$7dNpj z(hsHgLZ-$#aFi-K{sF}A!C!CaWr=5^C=!1=Bi#6)KxR-p%ROK9aZCjMjn`nC=}+YM z?qd7(A>kX>sZ117j}4ow9}H@|*f=xAI*{haNE()|{y7DTwvfyleqL7tPHEbfzE|1? zsCj@R$M#s07-CKV(smcLz@c681Mf_)kASy@wz$}*G=4FsWO0|%RvrOfWm6XHlD|Hs z_LNXNnfVjyOesD?DRCf1Dgs}vllwt2oIfl-w>yoLEzGTafPG`MW-8nwhn8YxswG9- ztNzVGZYPYv<*(UIHsF>m;hll29cLZ?{tJfw9C+f_qT zOb|PhBtje8kY%}f-m&>U0*gfU9O|{rY zWwcZJHIn>^U%hukX~A(Re;m-~s*J|~o){+G!SdfeZ&894VU=|9jO7!(kR6?#O`*;E zxq`>i3^M=hcU8nvJ?Za$M;Dh7n!p7KsCY$v*{0iByryMEty}LripHV^{M3-}BJaJj z0yi1{lS-i-WLbn$&`bP}OeFnK(dXF$$$T;LxZAA1^9Iw!Iph(caYAL^=H0~4k#FQ4 zTeQD03Y)#ZXIQ~(i!oaL(X-kv$5r(E;`7ab%ZRuokr&aG56Jy){*8^Wl8qcid(z+G z4>3#4uhax-)nd$$-=2~1?Yq91u6n9(Cd+k}3X{0-#Amn{)CkHKcC@J-1m`|e$3?jmM_qj7rb zU)cJbz7D&2^j2}WE}v8I;1lY6J7n~Fzl*YF*Kcp-*`&cRG8*<~CRuCG7BiRO6^W1j z(fqw*IN22<2MKo&4w+;id#(QLjZ8Ll=7+Fd`x5}tOVXeqolyk^si!b@PyLZ!vfWX^ ze%Fr;gkRM$`fznb z^Jau=_=||qDHo$EZK)j%uu+k}8!b6ts^F%>Y?L%&X31>LLJqT0qTd^B6X`0m^)bgw z^3Lpf&>!m33#rj*c>f|vgSq*mcTaV@_~9va&E}>=IR`^5#>zYEbsQ2b*qM+BmUCm& z_n?ij?5QvW^6a?hN86;R%LnK64`Nn?Sb2}-rT*Si7Q!)9x^92Wd~K?Zs_!M{3`w?Q zA~f18;pyNd%rMEIaS_YRMj6oWul<>Eb7QoYkrSdqViQwP3!)YB@WtY zFw-jQ&VT-1X1Zs!XEw)ug z%s1dav83tAKfu7TF-oGwCu26l-ZlbtytRt^`k##`Ydklq3VO`-!qJ*X7cL`g5TVVH|p&6evVAG+hCv;n2sNC znXukkS}Q_#TJkupMIO^J-@t;rOZt=W4EIF-Yq;}Ztj`_A-UhDzr|HoyAOa>sT3LJ7G-BtGYSkk zPcfP334mFtyGPIpG^_vfrO#=Zz0kM`Es*p%%r zv2THoI|}E?vlIHMg}@~uPFg?-(<)BQKXkq~6tTZAAc6LrI$sTyFq+~*{kUsjefB!5 zVp2?4w`cuy>1n^~(o^*%9IH%s$$=lOf8B(3>tKgNzQ4ivsX4>i+OS+7T~~uZOryA?2`J{H7=i*-01Lj0)`-Ylu24 z6$8b}V1s3(+}Cyu+-v_Fr-wCxc?OUE94Gk^#(Z9(e_e9X+6fg0Vr@fuKKDhe0~J{` z{iMn_JZ6|{7Te;-H+*Ww9=uJqb?W83K%wb8C)WjcRD$4Rs5tFm&8jF;q?+hVh~==D zKxcT>3iXN2KtR^J@B+j)glO4XsaKE2vrNU@m}?NfOMmYn!XLYN(fAs_tNSh4WC~1m zZ*iAni;XaI;bSffo&CLizePh6SBb5BH>Ykt5uK@&e&xK#=8QH(hJCG&w`OL{D@msr zNUw~Z;c<}IQE!g^-Ot-E*Ijn#9NDk2Qyz1aQmmQ`b}tex5q8p|C9#O(xwB%_5Epk)Vb{R5 zKk{{l3eS24v~h>@f3KjfUyf707j?jd}@ll06e+}WxlFn*j+T6*!*svOiPB5`uTN>%w{vzU`mvA}lkz62e=?6E-_|dHN|kt~ zc#ZCht#F7qZy*q8XYC~G$!NFRTRB2?>X|W7lAzQy6!3aBEPmjv!awfv{?^(y?F)iD zrMyu5ImN%3e#k|J_o18n!Gvq_)4nQ4GmKi!dh_SkeV~pS1;Vb8NDhX>&imhJTmbrk z4!%=3k|hfNtKxB99qEnJvk^K0wEt@A#c6iON-MQ(IneC27B-xFh|I^RukH;SRT9w( zf8+|R1u`<#6Tp8w>sJyf;4J=v&2K$&FA)$y>WixT0l#ne_DK<6gfHf<36cNfM#~^j zrft5TI2pKu+Aj?mc|g#KP**-{h}f3}0W**^nXA?=%W!S$*V$JS>o)btG9#a>JpshD z_r7So)3g21R2FI(L;2TO9@;A*H$0xE{t^;EzexFlZYJq3|&dVcTg|%uo zBZ05j^3QWX6Lf9M{N#>AnTX*DqpTH|h?#)r?gw}OPl+7N*zSKT<-`hv#NkgIJ)loo z&4>^$oviaz<_KrI4Smgzw&r2|p>Ox~vdhS`z^WV?R5*qiP|B$~-R?lFj+E>JO(fbR zLhJ=lswL7ocHZX-4qO5H4JYUTbG-fxs$dN$@RU9o=hGZvq;^~r&cg;~Xhdal$4;2Q zq@60vw&4r~;#Z3++WgB)ESnnLOtbn~SZ-Y^irAn<7HUe;|k5)1*^qV@ttPltig(4+k0c2e;R z0BJ%2HOnw!Eu(PGyYVXP=bQqd7VD{?jkqlu$ejN+ctS5uBR8msI{NEZ z$9%_tmejf`lcKDJ^1Y~<#*C_Zy1Um?|<9k|M{-+RjBax?i8p8y9V|INVEQuddvWFhP9r*6folo%nzN*t6b0Zt zk*N9Q{2~`)%z0Z1NTn0=7=eb|HO3%%7>to?10elh4L#TR5}&=fmGq;<{js<(GF2U~ zodG-O7ZWT4PVYT%Rdn39PmXo;3bpl9-}9Z+HK1E74W#!Oq4Pbp*8?KQwMGJNCwJ(6 z@gU(AQrIq>R8>bRkf;JE)q1v`g^U9s${LKT50X2Fekg?0!k107E)7DbX!oG1KCO+1 zo=Rb$0^SU~03(^lJ8{`(>nHT1z9~M_!&_sWae9`$a!@r;zeRffOj|TEi>N(}BTULj zb^f*4P+(E9Eb7w;JJ}3*h&qMtjJ`iX*cu2cA8n=l?ohsq={w({@@ou%vrb(UByShl zzFP1(toC009QJ~!Iy^54pEpEOm13In^{%HDXpFjw7lAr#8tQw#adF<0?A-kNQh<3= z3)V0Yn5L8Id_D*2N;C#!#!bfP*RI}85`x|C2HGgonvMDzNN@9z+_+S8FNojS^fxxu zODu^%>pIah$^rjKN;yLSB|Pv2$?$DEosh#v5vHrtfoyEu!pC!Om04)3&$c_bYfdhX zha1L#JLXa4QIyyaf0f}v1A~t@0?it>+wUkn^;k@H)z?iAK(fpj=TF`(>zsixE?0uT zQf8q$LU#H!j(ZX(+!I8`Dj1SRf+PqES?(q-|Fvqkp36VLHeqXtq={!GW zTax-M)V{K0uPsDYU6HSX6SY|s6_;j&e-5)J{n{RK1@+jrq=mwU&SI#AfUxjxz(rA@ zkFEC9&O2{@^Blz_!ID9i`(|BZ+3{XOM!qi~$A}w=MHb9*JV@ zhEsNP9By6(O3(A}NB)Hw9IV(~P?Z>IHwDJC6ioo>4eg(9eWOn_SP?ShA;G`j4i zm>?yGC-WafTqR?zmaQaGcYQZg;JY@{H=L0oJlt6!BW;;oB$Mm0?6G~=B1f!QxGVT+ zQq(f5NGt4^ibsbiX7;}J1(gY*k8^pQ7QVN)&cNH_VPr2+5JHLmI?~?it}xgwI-}2wdk2~Z{887|obh}D@DC$WH^{>M zDeDAlkPi9MbVqErxt)&qi>jCNYzXM1R3@sO9i2@R>ChX3zPUO?%E=KnFWM;G7lclF zun8VVU1-D_BJS|ztPc!pueFN;rMV}yu=VG?WxkBA0mnCO5AqEfcFBvfyZIaoRHMe0 z^6%%SRHuk$B|Lg_Xe+d2|D?`+;IUC=kn?DMdiSfq>t&2m4Q1kWo;s=TJ+wONEaCb2 z#KTtK@(_=S6YB*`=X$wOt7;*m1DAtDjUOw@1o;OPbadhj8a@~hp!%rX6@rC=olp`j zZFI6`HfMN!&es5J1I7s_Q&i9N^p7nVSM3Is%FtR0iuId!VJ(_+5c7L}=}0(ljh5^M zlz_0DNL?8LT#@2)Y@r8~MdMh#zNfSF1?t&KHYN3dhpI^>b8k+3g0f(|RuXo78n5Ka z*Fb5POt%2RS0hcY-!%;tesqJu$a){ss3^`6>CKQBlNbm_CU_J(`youNO-?Yts~7sO9F<{+FxehypAUrtj&pJ6gKq>zCVjxE&;uLk=JD>drLanNdpX zZ_=yD`b@knXzmn(F+gtVtw>$7GrsdcZ~V{>d$AvK)Lt8V`T@t0R&VwVA@zdz%olf0 z>jXtNY-JSBtGCpgI#-44g?yv9kFeEg--rO*3Ws_UG{ zXn05hr=@L955V7UASDYT&yN<~cMLsoKLUN~yC}9>Q2S^~^koutBn{L!aLo)~MBkeG zsePA!3gQK*zArvX3p6Z>1_A}{K(*_}dN+U+4Z|(zl-X=5AyAl6j?j z#&-sptzX+#7Z$Uup_TJ-hF;O;b77h{?EQXewAvZ!Zl?QlJ9Gmz$hzs3cwa`6&zT5di;oQSQ&vh7nmTA==?q|_{Lymn`#w1aC!{P0VMKSsoR^}_pP-&rq1 zPRbD0_{N94r9NyX$G%qeb}Rs{^+m^~^LkeIwSCzFXax+MJZiRx9dD!;f~4_W?t^T| zrY_@I2$x_38UG^}kFxS-4{T9L!dnlT5$v=1FV3&uPJOdujNJC4^jBlzYQG(AkzkQ! z|44n?nEICU^x~;_eh5yZh|B6P)TcpDd+`CFAegTIyii$S|CGO1woTyI-h$r1E-23c zD>#-F>pSx=FoW57 z)14ky^z9LpOB-{%;9^E-d~?PF-MS|TuJ+nNwmfMPO62uNf-{{WD4dg!wVovSHwuf^ zL0x(^NfM~>5=Mp{1+z9`GQRroS2LM%(&OvZppRJ_F^O>2OC2SJd!RTRp9tv1=X+mD zS4Dk?rb0(5qt@cw24oZ3^H*`NQM>c{q>t|6Df;+G|7xZ2>4pyFN*!}Q3z(!ERK^{9 z^w*}$bQLYz)o?&Y0U9#}BJ7vmsTVf$1lQh=)5@!2`|M!Km0tE|Z3cu%Mz9wd!LiwU zSv*e#q!E+WQ2WKj2{07Jm5NczB1~pGnYy{;Wk~I1XrT}zvUz>+?yA{YJerg`&@EPR zS|@oopW(}=8A%u0GTo9FwY;r3ujzvQn*28pM7bQkH%zRbYsKtFYMNzvn6W27&G_^2D-XH!mV=e0x#&G8kDpXqPQL-Hd zT70Q`^BHV2`Y<}Nv1+F!V<8xW`VWW}6g?`LRcilp11)h>#%yM1|H1g`- zX6F4(<@dpoSNNyQoIpc$ zdxK59ko68T)lTkm^))TP?IHgf{N>}qJfvjEpJ(RmJo=v*oo6i>)P{$XE*B@1?mPMU zrwWy+T__`$*6MXtV+b)Hbn{-Gr()HgKswpmo3Z2kI#hljR5 zxZa*(s*}lv^NR|9S68p4wFHg) zO9Ae>gbJBr03ear|Aw>!=F1klH=vP-ibf9@ZwwNOTD8mfZ# zCtRFwKA8E4!8kiL{u`?8+8sJfGr2Z*($Iz3tOBrFJSqI2Cw-iNcLhiOu^Om3s1t5r z;_m3bi93MBlW_v+!C5iT`Y6@R1m9T=7g;X`&_=c88+xxzzk;fTgnckmhX$~WUfB=K z6yu9zXDtl)g*LiBANfzN8n~kR2naO5cdA+|eFL?PwE|~F%mf&(LxIVI!Sd7dlMP!1 zuBwk~pc>%o4+Y4A`0+0$J>wu|hxLb}l=PU)ruDBtZsiaEHb6NTHv`792VjYSi;%Ex z&HxYuz>-%>C|WXNwGBrnK^mrbod%Ge5wL1O3>q;87UkZgpZ|{*;D1;kxT~#!{I1ff zu&OoA&|@hfZVD*gl0KJ%k-5Rguk6Z~vJ%HYCu;$^Qq69l*_gr51Cf6Kc?^jUbF;t7wAdrZ56^OP9(@>`veU&!)DWOCzG{}yN(NBr>Uei>G;R<9Jgfca!J81wtt zoT0lzHv@r418rCYNYoy|RhYlS6uUjrhtUGu&X!$m66c2*m~l%CqFW_&0DNCbuB~6f zHqk%$n?pMM*Wn9$22$)rE@uno^yQ3|P>riCD?`_Vj=LDdx@H?F1b>w)s#IV5o6-ii{)#_>*PV(hu0za0 zQ|QdCcB%mpEy#0K0*!;_(J9I$cMQw4kb}cwR7DZ8TWr0_I6w zxSb5W_cTwygsD}Q=jKt`lR=Vu~;|5y&jP$>EskUFrFHfHwxd=3dv4Q?+)k>j;i~JlKbsA5wbm0S2ijibm@(L*Y7$-O1Dw zu@5@9F>d_$Kf4h@&>DKn1T*lfH%{u%)3VQV?hEWSMrHMBdh)3fh!GprPIM0z&K1na zI%^O-V7#cf@mlK?jFpg@zf=5dSKW0ITkl7$8!SM*!inZ?yN(>t#kq#eV#J}ZC&<1w zyUSKDv;NU+RQh!MB7%I!u*P7IMCoS4!oL_O5$F;VIHk}Eapyu(y6#TK6za`p6Ty41 zMf8`^dYR|y1x`%x@N&#d^;K<5CxG}o{bnDe5Xxl}^4~t&{+~DKOfX{8s>O$!2wq}P z%|pKM&V-0{=I-a8{_8)jU?1WQJ=$o`+pwzu0`Rt(>Cujm*);f``$UJg$9{nsX;(ZK zhoN3upB-w~3xLC`@zL5^v%lS;B8e2dM#mH|*kG>N*%FU;kp=Etbb8feG?;E2|{J??Y>rj-1>G{xNeVfrbYgS_a$TdQUSv@gTA*+VK>4W(hWqx z?GgM03G%__wTWLVG-nt&pvXlD^Rc6l#-c{fN&f1eA!@5<#a5)?4 zGtqauP%k!1GsuG5Yt-DHicv?J0+cO`x z^Y}J0-&-9SQ|mRJiRxM@y(ly-e6gZmAslf29B8&OpBenC>?jEZf}w{MnV$h)Ne*7f zQH6ga`fr8%$c0F=I_6IS8B?mD)cXq~F`?l6Z^TAm)D`s-=%=Fp5wMloWG+y=1M7-P z3deT+bVW-Hb138f6|?_Q+X1zwEkTYKgzeVf;-7g%%hJa0k6?|>?wjS)K};|_eW?F~ zT)iRSz&A|7k4$SSk z>D&lxnnRcg-UCAp6o-SHun+dY6MW&B4uI&DeF#EQP-)}*>?G5?A{v8xL+#GBjnH@< zcgb%v2+=-F$XtpAA!aEoO3sEYgn7A38J32Z2Ih?srb>J=xQ&=BM3b!@4_1A0HGl&H z1T`Q`zB2-MT=C2SG6iBF0{krrHh+&91tkHN%mNDk+T72Y@4$ydz}sfb@oJGagSF(~ z4LUJ>5Fqsvew+XG7MM+oVbnL}#D?Bu1`6iEJ3#*#{0In`Rqw_k#DIXgLmTKl<1hkd zIc@j&FmP}&QeAStB=CM3%ra}i<1K+oTP-74I1N|jL(zismGIQjS4`FB3~caHu4-`#L1?RH>ePyj!$5gL|h33&-FP6Rd+JPf6p z;aiH~7N;ib0`fkBTR(PS)6(Sz=K6mo!v`e+_yzX;fILQQQ^uc^)20?hW9-;a6)()# zi*3_>Ik;mze=ULXBpzJOUPt-6pE2`09VwP!__)e1D-UbChQZ4WG1HWiF}Hi8tJ^U( zkkDG8*0+b?7aA9hJBJ_qHO!pdd3Kjl>KHB4Gg1 zIdlj}gEZ19ARSUe4j`Z+4bm+oA}B50(%mK9!nenB&hI_%b$!?G`~LG%=Xv(C_u6Z( zz1Dr-i;U2DB0Uoha*5g9N!C-JU%d5=O?XEHG#vl@`VjN$9!*9RrdIRk*QT?MOCSFF z^~DnH>DFY8Hn_g6RX>xFNaemdg$zHnLymI}I8|PK*?sq_%pJ+U4IYagY!#?@4kf%7 z2P-W^Y8NqXfSi03A~NNj(Uif*mqAMC{7t2Q5ZkjJpiu#sz2N1tBDX>JXrZx`jiDTY zG9D07#Oxh91eW%tDYiPecoRbc?$R8rPIjCgZVd&*=cCB+R8J!YW}a|6EQf-Jy2t(J zp$WM^!?}%`q^bCA6R|S~_hXg*9#|P;>Vf`0{M|oyoL8Ul&U@@OED+x3fI)D_tm}3Z z1I>`30~^hn@5jtsJccv19*XQLwD5@-mWrjjEKrX8v;9YjdQUlY>Hztzi~)SCTuu9z zQS6vKl2n& zu(rQBeZO81=Om(A&?&vDS7qju?~wkSOS`2hn~)$<;mvsU9+waX8V|(~Q|0l@f4{w_ zXUdNC9&Vq8^&ODkM+uEob>-R)JW&$}J9l>Cx=eX!Jctv2qT8APUfVV7T6(F#wp zVYy*m&Eyp~X^zF#B^8I&KSAO7RR=Tfp?}70^_%4$o%34LGC3pN$DsUxza3|$h&50)sly}&q(4YgWbOm zK4U>s@e{!S^aKl8A{W)ZF?$Crb*%7M2I&cSjR=yDtX*<>%l z)c@3-Vo|`R#JGHf1xs-)-r!?eQ+hg3^~t~;G%nyPQ|pf*&Z*Lu5n*u)R^I!|niFJG zr`&3C;{d&s4vP)UUFIz!Vn6rg49T0AMO#rgpv;dfh%0Zx9_ud-Wu=Swd&f7CY7BrU zgi5A@Q*rpK&d+o;oddwXt?V2iV9_20<0^jx*3t!Qa8nd!-r+eY{l|DF)7Zclcrdcv z^Q%0}Pro5u_mz>WDgj(#l|-<-+>grYzIu?9Eaf>OAmz@0#2X;ow9W+ zOhgBa4DZj#2yM93??^CP;Ng{L>ys#p>~1cyc>_-%Ywp0M^@q+4bOmjYaUSjv!u{COPX=0o*6&CC`*Amvs1Bnl#yb{=xWf`htX zDX8%R=A0H#A`(A$)KtHNyZ{6(!aQF+Tt9md0Uyr!`a9?ApN-|N;1QuFr7Wur$BJ0d zkRXbp?3*HTCgf5BK0^vCjpq(f_~`pfsk9ecjH0#Tg3am4jjY{>6{$@aW{3BYP#PBS zg+A+#ftstS|W^AsJ}rF~I;5ND?< zY?<#1Al{Khz0Af#%9%0oz9NXTq1#yfiJ!Ey6_Z}4kF*`)3_VZ-&XA4|Gn76*Hcfv{ zJCaU=ydnq#B;Zk`6}57eHMXDRVTSBgi>=QuJ}KVeuhdikW~W&i-IAA~$rzC+l5sY) zfTbcu^ro&b0(6-<{ghf93Vm))M!e4TB>kO3^T&{$ z)87OiS{oxNOPB9WlII(%=7HFxv5W@?P9(ON1D_)@HJyXb@5lfR9f(F?k+AJZdx>D*P@s!- z&|RMvdzE3pl42?>i}h>8+}Oam-R0=2@j{Iq@;1 ziN3!yI5VF9(tBU^0tk>D{@#Xh8#Z9CkCgg%J^r}?G<&lXyCTK} zH?4zg?U=51>1<->j7wm4NccRZl;Z|AjAYWYeuwPL`b2`7FIps6BXJLu; zLzM17fj}N*FjR$?9@mtCL#dzzAYz<>7z2DpnJ;Du4#B5M;y_Uvvu^X!4rfZzTs`A< zv0%h@z2f?~tc(f@27@ap)(bg(xdTsF=ocd?xyB>_Da`V=8o+75p~RR4eD{vDlui4R zG0>b%Tr)C4&L^*oGva0w738(m6z zrRRb9e~VTpvep3mZd0T&N$_I@D3IA)`LLAc0}7M{0JeSzbRI`Q+0~9v=s30OK_mVf zSxg0W1K?mQaQa*( zNEuIbyZK~E&6dw?nrz&nKu5)tZDC5CHejM%u!g}Bn!VL^ z4Cw+KA?_EQ4z~Q8QYMe(Ge;n@d2VTFz!Z!Z3zx zfV^{&JDHwq+9m+77S&mdeHZ-s{8bW$NfHFzZ6?#N_6s?LEyGEVfcH{@6K@9|fjCA&9vvrnSK(j4g=j8X0f^oX zY>1D~0sNEu_x=FC-|CoEvGWCUJ%&G!WT&;c%kO-#`?h{Wk$-dHTrSiFW2APh z3M=B}jy_DEw+KGK&?|J0@0jkgq?#~ap!a!=Sk|fHlzy%NNVN}FSZN%IyyOrg z^1r3bms5k1VQ#)ilV|BEH_QDPUxtAUG?4Wf3Y3hXzC#tugFTdz0WwhIY@YUVrK!BQ z{$ecT*^fdiAz_>ZfRGpoe6C_!aa!7i-*+PoE$RX+{Uuf}@JiMgdF)Tk7`_Qd?e^<4 zVWT^9+7*r#zkp8p%oZbUK(=Kqc_xU1XGMRq0e1u6*2g&7N78lJYg(6!I?v+RK@cqi zJWj3jytGQx&Rq17C`=s^Br-S(uq_iEF6VSjbm?(Wo9*1~n;Jmt{vIC#M;)HJE8w;1 zUb*Vy$w_?T5RT_f!SQqFY#Z&Wj3x~J>bRvffhM{HP3#b;ZF;8_nq|WzJE;q(RM~{n- zUmXt~JIZl<-8b1h^z1=VKlqQVtp^e>}XCIcX9|w^OngDm(q37N4OHU430kpeEwrjiyb;mb-d)J4CT_3=`f5qXx=W~RyH3|yMSd96# zHTVbkHc7ZLMDtDv_XzB%7SggDfwKQEF%~=-YCz{CE{x=vgJdfbdo}sCG(u8~a|%szS6-%@eb8a%Bm0&Y_(4PMfn?`O`7nglD6wDB$Ia-2{bg%jj{~8j*)d5jVsK*wfi9T^buJ^#Yz7qN%YphOMu)8|hTKjB zzLnkH@1_r_&oW??z3UV><~@$eG=75P&E`#A_uvcr_WhHj>Cx9cL7GU4V-@7_%vls+ z0};noh|}&I?5H$9(45{AnI1l((CEV7#I_cQ4kwf>C%kOA%s8KI4E|z(gMByP{&^Wj zsT|NviW7w3(K5$|7v{uk^?T$SU6Ol>c5uz-f!`@ z@{NrsmV{bY@|bA3^P*kDyRsjLy#$~T0o?^>AfsJ_8yS4>b5bPkTij$^B8Q9w*MNp~ z+D^a~vUv0zp|u>pHjgN&U1{*0@7dVdG&-Y#Ne-<^{m#F$Ac=xsi!hV(LY2=MKHYkz z5NfDLZQMb1Akwshb?b=9rZJVvyf^jUEfhj{ODgRJbazhK^k=#^^FETYPtgZ+I@bS6D8_v=EBnqbBqt$gm6-D_c!2{%eWo*K5fU@3zlBg{{aVb%x0dzgyFr zVwu4^POZ7`z8sVJtP9EAUoGHh|0q92#Mpcy`BqMqQ8|ItF^0@k#f6znAcrZe5P(8> zV=b>Mqd{0mXAoOW1j0gVPp+@qn7d;bXQV1I8dXH!-zxRvn6uVI-!PCQ(dR?$d1TCj zR60u5C`@xPuqTu-)TgnMgZA^Y)eNGcd9**h?`sJCFk9Rl>9CIU_a6Yxt>>yToQzINGt_6a3LU9Esr+1>S~+DtDJqVsz$fE03gx&pxG z+?qMb!FBPj3;c|PDvt<~(75ROkQ{p1z_!fxfw}v%FZg^a&Xfd&&o&;vYJp*4M@wvw zn1)gJDN^59s&YDMLo{Ho)+X*X{<3FAb@jyx`Yg(S$%! zA~|kt+=YjPp*XN<;b>G)0}b*fW3lWGL=pc1unBX7i>b~XWi#ulc{=CQYyQms_YPWr zG~LQswRXr?XLsKAhn`u7wB)sfxm4_{)w`d*q@sD7P_>|Dix`hm)2lr&C|h*PtA#im zEZdo?)m`>Wa?RHCnG#vFjoQU&>{6Evw=cXmGW_5)e=ZDmo}?+Tbc zL9Uv)oIbnXLmfMcn|pQS_eXAY9REyMNqqJE!*g%2u|JI$?%rhrt#Jg!{<_FuoI2M| z&hE#`r~WBT-6WnodMrCUc8 zDm;-*@U;|m0r-mVr-(D4PDeiL|8hbWHmpa49(#Z@yx05lK22IAoE1vbN@dYKmL@(g z%{YB=M!dK>0=X8jr}hPn`|imxXujZ+DE@8StFn|nsDzNDFW8)^dre^y6qKQVIeR-p#3ls0X#KOh8RXR^1yhS z#fVf{g~vx|BkI|X!xdOK)Q?A%>S`D_5Q;EI^hrRh@qLlk?N5cT(apvW5{riVWjuIc2KUK4Dk({O2eF$Qv!mAWk5PZI(PF7JE2Qc+o2{74XnyH~KZ_TXYn|Y4Pr+ zQo`9lRxU}zyINs4C6h5vswBlZWt((xR7X^P-pxmg=(W;y=S%0>_W`U55#1xuFkERJz_V=_`Kb->6 zjOot^sih>3TkGTelO091tlDT-r0><`8u*qyKBR%5jv9vC*w`lHIEMyqbBxw3ak$i# z1QO83<=jmVJErivN^R6t%1G_}TE9iB&RW>GD_Flxqg#v0gLcSHXV2{B>`(d&dv7*= zO-+5cYN_qQ;%|&{vbQ2l?Keh%{Lh?0+wLqgs_nskcbsRsHhINe#TB~q1v28?!21;< zjkGCUj4;mz%GJw5gfWYNoZ(NhrX|48^H`SV-6lzw$pY*ajxxU!XC;rrEVjdn=HdsZ zh9hVYRI9DgN3>wf3t4jfIHZmE>b26>xCGz*o=ts*h8;JOGU42W(^ik{wloHaheSoQ zlKBNWIk~grK)R`nK1e3Gq+UTBLZ3G_&xq|`QN-_7Ej*1*{@$QZ&C*5n5?PQrFFnbF z3e zy0tF4b#Suhq1W)qTMW7@lt<_-(n%jmtK4zmC*(B|oWb*pZ2H~4ZuxZ|^a0@onU(DM zqw~i;v0=jNRS9U6X52(&i#lJs;^}nk{6Mtum+;!@?q3O|pHg8@Gr_r~WZg37$4tXs z$vyOPs0PpzVeTwFBO(iic1-@7rdkvJ&*GQlZ_&8!&J=_%#8uvbX!2OZMQY0(G?t3p zkt9(J6^B@yC;G8$9>&SG=qVXT$>_y~XufTU&Q}xO_~xd(-Eyy0=3q1HEAzoQZE&AH zTb!JS*I2wOIC#2NdWPC<F4eGb!_O zCJA+5OEnP%UpUYUJeqwRO-$4A_8K1XQm^Of13MR2mlbgcg}`1d$!sEm^yYaaiuz)S zf~h}BSQk|wU+FFEZsnX?PGXmHu4OHME?;wzE{J3&;tF}^w;ibz$rDj63!E;ty0gVCCggNG!1FOd=qzUp3sTGy-&XuJ5|1g7D?mX zIvei!?n)`aS;N(b$%Rcw4i#FSQ+tPv&JT}y9Q(L8Aad#VR_&;#+N5xJ3NA5H=!?Cy z=E7kjW8tVDIyLP(4_lV|szCa)b68=zva(ZW^`(aFpK6J~*xuU9*8;a6A0WAz9NZRy+taCGk_CRuDekkxFWe4aSoNq0f*Y(VVc}&KTch|i4r|TF zRP5cgo(PtsA7=Vxac}kWQunh9TpsPBLPAP(;n5@AGEVQ&4Wb32IR-wj78jezFL{R+ ze*ONwecpMK!cz?@8WdJoD4$H7_p3)>ZW)CXVqt$zOU!d%RmdLayziXvQbYtNHSJR; zy#-;S_SAQyPo|4JsX3eeL=cgarM(O}VKV~3oLwq3GVi%H|Dr-18oMAqOFdInMIi6o z$%+wotkY$DL5EA9G#rM?@u@)LsOG;}mYbgKx?{J*!m%sCF!w5RXESxGqCL$Ay4Wn| z|40`cR;C7b9I2?d>;fkwhZL}l82@AxGY@E5C?q$6as~F@k`+J%Jts1zu7nNCsfBWa z3eCqf)o96oWhklCw%(N*Z>F^-mqiReM`*SjsE0>s!lhX_0oQ#fW{@V(lNhG6Pa!YF zAfm1!0TzYq0hf0Bf?LS&{gO(s*L`%1K>coq;(>fkHtyvrEHcWIZMfCm#p85JLMiqpyQi> z11m|gyGh^aSrmVXP%ZTeFZA#Xiof{0cBlwzu)p%9EI!Yc2%_1?ju7A zrVm&Mt{%BqtpO3EQEQ5@BoaFQLHgOr0V_uVRL0-cFQ*CFrT*yF_MTBN83bwrrHonL zRT6HW?-j1psZf90E?)54aMYrk3qC=fwgm3gPQmMlc!!fyWn3Rc)kkH)Z06Pv#ieJx zebk82=1;J0x;f6&`(y-~zb-+>5{F9%OUJRcN)c8_2+T!!H`T^#zTARE>qAr%XC+OD z4xidO?}?Gm5qoUB8K?Go&g)C-2caqNIJ&2A=dG?FFlQB6PEi{A@?5hQR5p)7)KAGF zoQ_kWe!*jOSY34A+}^Gies=uQj1?l)RTAHhN&;0#ZcWXw;QyH&kgP8OS^>>aS!j4Q zYHP<%-F_eD0L8UG3>l%v`jN8Om~7vD3@>Z)Nr>fu!EscR?=|t7T};q+^TGLo9~j(f zS^fnoGh}yjK1>sKbKg%_=1DC1SVPCdzyX42!amN#{CN&+_^lgem%L^ieK^=G?C{sF zdrlZm6f@0Fn* zQVnL>^ce|M&#%p&!vks-$Yx^1lvD&{C2iDfzsuSBWKLOoEIcmXN4$HfM{=MjDL+Jn zjuBHfo!(9q^C=qBsRwt*!%Fd^DzxuX;o;7}BwUHZ4=sGwFy(P+JZ_LQwD#3GbpKD` zWz0s_GzxJQB+Pw_gX;)-o#aX|p3Nta4C@&3W(W)j|r?X#0hGQ~F0wV8lWxNPq2xY%5oM{%-9#vi?O;$jFm0TJV) z%-d<4y{YC8Ztwog5%m}2AoDXVOI#9HTI8|=|py-01oeB+@@qJSP3%`f|hYprjw2 zPg^?Nu9_?ZZ~+Mc!nj?4csamkTi7bx&AMK5{U#RH5iw(f zp5Uyxl6On}Xit!-9E3#|#fx0E59|up%Hxt1nESjI)Ne>^kxY9Vt$9Jp{c*`IJ)mKi zCq4=`^jJ+R$fiKv4P-KZMr&$jwImD25Cak%>V%K0(cRoFm#0 zP47$Otuz;4M$h8-PbNSUv75=FvBnWzBN~>PY3nGd#$@qocmtkaAxqwML$(obkeuE3 zP;}6qN_FLAjwg_$xxRzqoXY?i}Z_X}^bI~IGN$aR$!={$hzDGDB_ z`V@E`G=wk_F;P1_jU^Jh0uz~q-w0yVN5%&*Y78yDm=!wg6ZJKsrG-D$lq6Mjyg3Jx ztI$-b1gR^&Lqa3F%(dpyE!LyCggn-aHN_g%`uwfcfEoXirbqXSN?2Jl9VQ%Fc`xXg z>2Et^-p}sGlqoJq1K{!JJ4~4!eY;5W(rMz?B=Uz-9(J_K%__gLs1HOQk&D9%n-E!AwXT51WCoiz(YralQAIY^{IE&MePgDnd;lZ zJNDnMdY|Yx_hgh1-iA{vYs6CA(jAF+Cvt0&NZvRI5DA9L@EJB*#}C5#0w1(mcv|;% z?I8^;Mztraw2u}=Qa2s$O-jUfpg3fjLlw<`zpy+;$0S;UMztO&S2aq-lnD24 zjDE@(O8Nb6sXJ+Xz|4m+T??o9uBqWAL;8mtCb?eCI&N2GVeZwer&RGAM~z{eGY^bg z@GpIiVSSH(L>CSE)mG1FJ$DQqX;!INjMwRw(s7_yFAY}W+4X)GahL0cyk<~ao%F(V z{Bxi`=F)nhnTd;degM&Q;9HARf7J}fm`Q%3tOm9t{kIpRI`s=Dg{rNx!bRShBrk^* zO)kz)yuVz3z6lyh4*DAFa0qte>AQg9((`)xfZ|zJ8H4(mte-Pv?(plH2_=AG$EDzy zwp=;kDEA6b_|Ck9?|yB_6&qU4^GJdU`h9AuV|OktiDp$M-td34x~H%^%kxW_>@0FS zFooGqSz~A`Hl_IZUj0nfQ1W^i{!w$=QGZ9VV~^LN&rwhxBi2L62I%j(1(f9WWj?tb zfjs?EMrC!7gljXifB#0ZFG+UBS;GBR7C-X&cnClxh2P!IeWv)zWAijY9jdk3TDXr; zI4@Gi?-PR`>tMPx0(rK{mQx*Msy^7ck&UuE+X027W2kpUrz!L24VJS!tMNITxx2+4 z`Zvj`80o?OsjSp<56A$0AICVz4DMZ_nkeH;UDa4G;e7ojVZ@pw`gQRvb#qt$8}7PC zpB(O=B?S<8F@0|(0=+rvCSL9wd?4@}wF-PR4amqBX4Y%kNAPyv-|Ct!WkCGw>AB|{ z&yh^m!DiT~6Pzb5qbP4Ognh>3-bQmB+r6U7vAs;wr*XeB7YBdCl^8`-`$2WHLPX?7 zz47}y=?()IIuqO^TNDK-foV9;m|Aiw^9wQf*dp^&+a0IrzVNelqJ^2yWfZi z(qEhRZoK+lM~ta42|uDDAu%P2W`x|slrCS%Yd&%W%6TV?FhidhrR&^|izcagN=-q< z6Fqne0UHsr!GbC%MNE^4Ns#j1TA307DY{9XZH52JVy?v$~-}dj{2baXk>)my~k(4Gw(2!Pj3nc{s=RvY2tj- z=y)LC8;(v?{}`ypp3^YKhLtqc^fbO_E)>7T+az^gIp}*vlhmR()5B2294y)-^GWzM zIomuJ;W-q7G%=@HCDC$$9DgQqNJ|J~YNgiZPKT~p~eyEkQP6fklI5&cH~ZX)i( zbjpfY#f4oAn3#}Xv5J(kDoakOC+wRE_EzGPX|_*J{Bn^6`}69j(HICp{IqHXxekOH zR1v3+jA)zYL1&4tOI*b}FX=r=55FVnzSl3#5O~(CHG3UPgQ|78Pu=O|{i5mym~&&# z_P~h$4q$EXk7I8?KD1sSJh3tPX8WJ$-WeXO)xL>7jw>Q41E)mrf zr+W=M7y~u%T3<=66%m7`=*ee-J#x~YVZP8h6rD)vaP~!5cRe}k zyM>j4D_Cl%pDT6&fMrP0hQ?)>=%CE&JyH|j!e0GgzyKlQIucI8NYCY#7<(bWnKkvk~dbGIlcK-GePg}$T zqt<`00N47ir|=%X!RDPIB;2b4O+P7L0fzE1-PKz_7dxFDB9*Z#8Y^ZncZKwptSD;( zhq#GxTnBS~lzVX4>3kzSUFp+%xHb0Gcn6?4Y0^3UeT*jJQFO3FlUeapt6*chKW*X~ zQ2F%r1m3%qEu#(I57m~M7r$a;tZ^ZkI`eV<*qr+b0TUiQ`}QMP;j_uMxI8+=l2#Z@ zXm;*49VMLJJEMQLc>#F``<|*sdU(U7456X_TfzgE%il*w`lG84n4h#i)StDOsGX=a z0Q!bs^`|5UiH_(g>EG?_LdDu7_DS+sjbPCdwRBEMl=2|QLeJOpl#FUOEgp*-4{sO- znIj?xvmU2qyjycaK5V$Ux`LBZ>=I>98%LNl#1rYn4zY(kZ{+FqyZ)syz|^4Pm@gO9XT{b)KGg~cZXrxZwB`p^>m4bW!$@H@`2!7=x7>2dgZxaso( zN&4-y8YZX_?2^!fQ0)?(<3H0RR8qic*nC5Xb+M@mS)vWf>Yf?M*zZhv z#qqxJfF7~GFQj#kr>MmOefZls3Qp1VQT_uctz6<;U%xZmr3{f~IB!y>mbpLCx`udn z>ptQAP?hJv+eX=~e`HDzW`=XSzo4bY4%}|o=1+HrJ~W>}9;$LQOyPhIAk^3}Y{qnB z*Ibw<)E25EZLG=%;W7M7ass{MKZ0a{g|eqPgRbDEK3XueG`josN5>(8x<**Pza~r< zMJF~W7C~_KI^Yb}3w07>Y6%nPRM8}(gu6p+P$sV_PJ#$JM)q=8Xd>K`ds{6(U+$oN zj_upNw;>MADB}oc{hvADCyhS1WsmQ&%*o8B%qQ=yw`r1Z<13wID1eeq*cwvzNG_*- zxEtrnnM>kS50a#Ju+$SH`AmldcOXsVRdaFb_rgRj@jNdIia#e+>AS8z8_@pifL7Zv zkDcaRF{5*b^1cLwdiDp`gl^CEr7}0gGal=zIhZE8-cElHfzR9+Pok5aB`n)^$Xeq;9nxeg>UQJ8z|?Lp|{QP{Tr@EP+awsris*+9Hkc6 z2`5WoRH7FP$(sZkquwE-_p%e`fKCAUr3$`Wc8vyMPoSwz08>y!K9W2TCt`)@kj3aJ z&6XK2rR5&5tu2Xca54=)u@0bbpnzrKG-s5|k}vv;LeY5~C20w>rCt5Dgc!q9*l9}x%j}> z5YvWO14#ttEExgqN>V3sv>)3b^oxS?<3Z*T0FD;gh z_wDk2w9cv&cqemx?wVNu;o-4V0a6R7aKxb%?eSYKTAn-RM=^Dht{hGoJJqqPJ) zK!f#D|7cCFfR!MQV132YMjoKIcSx5N+1UR0$E=D?{U)$~sLlGvWeL#-20w_5I+=GM zL-7jPpV2FKxPLC08Y9Zqeh3v@XUvRrPT1fNc+en7Z6>W(dpo1w@cr1#JwsM;<3eUx zs}cQsPV6Y2+WkzKJ0mk;3SBGsD*?VhD)AyWR{lE*XN;AV7!9-oa$!C+r}CW?sNZuft?H2QE$U2SGL zk{CVm1;wVsJ?ZqUUgE~pE7pDucSj(PGS1l!2@{Y44d}ZucvnLjEBtXy z3@;)WvDCx6Oic?DcT(iqML{wd<24(8v7&n$z}*gl{PdzrJ7?cN&G9_OoLyEIe0dm#mE47KWkN_G-wMI&}_ijI`m0n}I4%5cB;taBWzNsGkcZJK*D zQy%Lo@$1&0p8@x81wB>oXjS2Dxbj)^sqAf1XJlgDo@a&UAexNY`JG^V-p?G*l}yRe z4DD`Z>O2=S;+O5dMBS%)v7U-J5SOKC)_khg3{hgem1s<$yeR#5po}4U>`2>O0rNe0 z3z@2e#b?7t^Q*>NqXIkef;v8KHmvmdjr5!O@=)zO4~m_u=K%1bY2JhAH{zvifRN2W z@;RaCZG=B<>#q^9fE+4Mf!FvznNHt<0y%_TIQA;)} zxLcfXGZAhmAjQS$64s19Q<70xqQCG+Mw^;27a?)gGTyir+44P$3!WxUmLf-3DdnGR z$CL?#wIzI4_`06SKO{I9Er4^qJU1C(6b4~RcQ=U4>%9A7$hmxovt{vF@I3Ra`Hk+o zbGr1LFZ1{mj*e_9mu1?C5c)}#+#%PUih+}U4w!apG3{q{?dPV-R6cNl!)G#RUXAkVCjv3p$HHhwGCxO%=4$H@I(A^mu^<<~nbrT~ zb<(6p+QJdHM-d^;nE}tU#!9EK;#D_!B|%ej)^g@JQS4m=cu*VKy591(@x&V0)i`zR z8phOt;hM$538VoF`0gw?=fY8f0H<%3R40-2P_pqt{=jf2_hS-yY&h!-Lfggti7Z2pl}Lpa7zoGnKdcr9`r4zFwG?oF(I;LU1vF9_0J1K`m+M)2YX zH*=z1TTHMt_2y+!fOmDi6T1$(`V>6XKPdHw`2X!!bUSSY7JO{)S_%~%?mvdv^Vujw zh+tb|2r}Pj{(pxFZorHn%+U}|4;nKA@1@jy`mZrc)`1A=1J}D7cLBtl7n}=H%On~> zH(_9|@k)U;Ji!Kcm^G=I(kjOgaWK|vMeeIq|KyG^XS+$|`&rPKiS2| zXuA)yT?+Uv-F7L%3l9YWLjHk>!55~BiQB|r$oH79liqEqR-=fCz7oU%Q%gt#XZTt3H5{i(Y{Zk9n9huPSB8$kDY_$5l!K z=9)EeuZ30RW=4M;s7fne`(U-(`%jt^3}kI!n3eDA3#w$W#(+tK<1Pt)IP@5sr3m6A zo7#nbtuBO>L%Ujzckfd?Pmj}{0bPbB`(|`3^N0Vz5iL_M3(7yK_x5uEd|B>q<-YstaYI0kdbDMkHr#??Lp5C z76+NXFUtF!>Uq`}1MLsxjgy9B7S8wQe@*4TzW@Qz^hAT>(~{Ey4+N3@!_q*pgZD2h zZMS&II}Ft7ujT*O3|4h*FWtrYw6f2g8=MUDi^YruSZl0{@>F*-c~uk0j;2y!sh>I= zASOq2KL4E~{ks1zJCGH4G~NjoAP3_GY*cGXjj@28N9Lz)AX7E4oyN3_J3ls%Vw7F3 zA^vOjixqVq|1ZA;4kV{7L+`y_AQwhGSr1RslZ8UeN^ z{*>$|AwTNY!qbYMN$_4lF#nT}H6lUE5wilD;%|a+diPOUSh-^_^E{0y8`1};KGn(U zU>iY8jzJ5ZP{Y-WkMHNJ)qdU>e6BHt{i66mhwgRfu>{^XIU<1=BTu~=E8u4CD~Bd$ z`eT0@*@YB#)-1_DzZ~R_aTq3J0z|?c?#oC}nw;iOpHCH3 zi|Rh@RoN}6GFyW{RRH96r1NwJA2t zws8Hg$7sw&kG{xy9RgV8L}<&5{)juoz-Kw@2DXgG(Xn#|z4`91z6redBXiw6G4(%N z+w2FJoa%j)17>oU=1)~a_5aPOoRmXtFU>DgHSzb;S83@e)7UAt`gEjXRsq!Hs{ndQ^>ha+~zQm zr{j@7CpNjeUbll?Fa`#!T?82ckw}i8plq@L=eLhi;l$vOc?M)usY#Qj-W{lo!J+=X z%}1cW5Wd_IM;BP-8ubzu_CdD#t@Uiz%z@!SM~QEkd>+Au@-EnG?1Dy#|Km<;OLJY} zO8&qMD5IR@h%g4qKw=SvIUsx8_oeX;D|TV23*MPh#Q~vwuQAr5ApWnN{^ulk^6;HR zc9o3aemEqP;jhohEDzfqwy**r({vRu(N~ob4=*>1_Sw<7B7f5G{&)W|^KPPRQNnU&&z2CW3VDJ#1k>W0}N2(iG&x? zd>*yf4r!0y;XE$0oKAeI^M=-9TS6&1ivfDxA^~u(ez;k+I!|1&40q|M;Zek(t1lv{ zGX7bZ>T_94k4n{!@#3nc46aQo8Afa5w>;ND%+UeM+(l4vDGQo}gU`gn7mIDFJ$a?D z9bijx^$a^c`E%_2eQ6Di$T#+3igp@@szPv(B>l^|v9-u%Q((4N4MRcbP3Ml{vlikK z%W3Ln#7VR})k{pa5BGlFEqE^ii&wFMjqj_VB@s&=IBvl11fDPvidlCnt-sgZJ5-7{ zxiYH~=p2EQDb}Av4Hvco^CN#_pHw-Vfs>jZ`V9i+W011|6WnMOt{Z%2paDRPu6C{0 zUI(yLt?}Mx@CV={5EVB;K1va<>0Lk-#nc3L(0{E`%s_OR#K;f+mTJ>%mw7pr*VVmb zI!(-Gc!z4jG#QJ`T`CymuA$w$DhLA{YD~nw!yNrEt`Jg=A?$`dh*QnSHUdEn=>sDp zM$}E%&ikihd0sCCY`Q#+m99#p;sAr=qFW^eBRGVB5v-S29ByR9BI!uLKjgOEpU%Z> zoUHbN_FsjeH`R~#zf!Ajj)QfOpgH&&mv59Uo)-`Ci64g0PtZEA6%4kk#stL&-W-dof@moU(-FxT@}I-51bj9 zubM-e?TNF#a5J!utoZv_+~AALEXd+yvCA027Z(i-m)WrAX^H|zKw`3yti}b7Kr(D1 z^r=zqi$&J&b*GTt0-Ke4Fmg#up)3$YKH1nt@(ttocD7iJ=>y}LH13kvDn!7C{#cbB zg>Nj}0ZF81U%Vx=_5)K69AyQ{Z0X~k{)x>@I%rrTASO@gS{QWaln=WkNmzY>8DfP` z46wA2SDn622L{hkiBeX~KOC~KdRS1%DPeF52BwgbrUH-*?|enVWvUf*K76}UqlO(0 zh_@z#4_M;E9@-vU&j;VhabECtAkS+Y932pq^+Kh^bz-m%US;v^ELar4{)lY*R=ZMj zb8ZB}{tTD702Y~J8#%B3yhEFWY%N~@D1ymg2t>sgXL1+(lzTH>B}AkW+c7af$I1PZ zjYytmXc>bz>1UpDxU$Nm@D5KW-qK z0FBEbRn4S2^8306FZz!GNusuQ@2P78@kEFIg<};Ecm14lzQD8v8Z6n{xpnAFfbK4j zl!X9Gsm!C(m}VZ+;^);uv{VfsG))O!?4VPaBi6cN7pbO?gZPhU{3k)Xn9nmB!e^sB z6B>29B(H!6s(Js|j9XE78JDdNXt6umIvlK%8c*b0^`BL z=d(AxUtw5SK)UuwRM;+2IfzhJpuIQez3M&87U8_t90(pX+-?2CfDPKoZcfF z6OeYQC-Y=*-7uydZ^ymXGJr`9ye*7CGY%9j1mIR_g3yxuRQ)4-)pr|gG&d1&}L?&oSh&%*9TNu*RLnr z!x)`_eYkyxMB`)|04+>nG7r!MO9(?CC_yW?8#RFikAGD|93FG zxJMJqLa#)9rdwKLipC{m&m!}!{=@-;&iN|dydX@9{p76n1ol9hu2rU5BKuY1>icb> zd5Ru;pVNVqi5vF|N@mp4qfm8jqaAunK8F>>UN+zBr_ZL2+?6dxP2V~TpZ1|Ap46Wx zRDqU6U;Jf2a0zve)+o|W34sWpn=ksG40tqwC0?cfcx;LW$agv#?eVL9-kZid=%3G#P0Hz?s$8yM^l|G2&?_Gay|?`@UfH=1 ze4vICEi!T>gU-ovn^OlxuTGe=LD#G?j!hlV59*HG#Vx^oCE5y!=k}7&WN8fh43KG( z0Q+xisMx!LgLp#ds+hW}B%X+XM+|5GQb21_w>raP@ zCx8^?syd*Il;RV(Tqk6S0ilqh6s@-6`6F;?82T75t7}kepqpm5eA>+6J$p8`stbsa zXpjCk$qOq!Av{T2xc>F$iyYq8Mp|6+++r!W z+SRZ_8UH$r_>26y-al>)gP}-J!ad%HpC&P?suIcefgE+L^I{B`ipoSI&EbP1fcu;d zlZDWr-3K#ja~iNukLS@xV%_32yU}6) z#Lz`qmwd7U!W}ubxax1m-=%!PCv~C`;bf>&-SCIYDL!>%r?BJI{ME;xAmnxQv;Jbphn!BE z%hO(j)kr=sRBC^5*8ggBwWp?Q^L@S9Gu8f^a_^+@PB)5=UX#^+o4%YQs~?ei3^Sw@ zipQ|woPK#9eVJqu+QHL*0XL<1rK#QJ>ITSmCxkFShepxLaa7M>O&7oN`dIc$^e-)W_5|RFv#7qW0b`E;`(Ul*rC0X^eY;*^@ML7h)$d$CnGq6e!QYI0!JQ2XgOD|m z#NT?LRdj;^CAC4j171DB;Wbae{o_S(XvsqO?~vm;4-Cn7S@`ngCiRuNyNJ}1Q7(&5 z*aU|6+}LCclX&X{maw0fS`M^bO*Oz^j(8R!V|mXWr^6^k@%6g1<+0`+;(5r z0D`txw;N@i5T(>gC|u%&NDY|f+@8afH=FNSoaJT_k*(yfqO}q_rEpKhTEYNnc`S_6 zlYuTY_97Xjj*SR#J9R!?X%*aZ@U|9-%|>U}OL!7SOF<;TO8#c?y63UC`7OhC&C{kD=%@kwagkMOwk zdh{xwRUaCY&+EU{U^#QqlJde{iK*Oe#~>0h7_SiDhgc8q&VroPlkcW|t9LZez@rbO z$hI&fhFv0V(T_k)o5)ntHQ$vJ>H)rHocCCD3An*UQ zcje(wwrxLBjXa()B(j9DWo&6tWEmP{FqVvcY&~PkPRS$NqcY7T6k`pM7c;UI#x{0} zNu5 zt&a-Wa>Xgf2jj}Swa&ho;OxEbXT@cw zX5Hf83PY918v>vNvhyKM>Rj8#8O%|{d+Rsd0f7_WR9+kIU42=Si*ux(HeMv57r}x5 zLZ6cc8Ql*h8b#N2<9F;3Xf(#?S*td!p%z)5y3z-1DY)+^g-Pn5C*fV)w^5Mz@h@J1 z(N|5=7dAdAak$QUa!JY!NLgJY?lh%l#Mtf<`8jT)maL}K;BFkXAfw=Vc z9Bo!2Ut8Zux=1qY?jPXx@`888T7s8U4@N}ns;T`5M({mJTY516d&R5!k#C? zl`%3v|MRH8=h*#@f~#ZKHd)clr#?UMtk-^9aZa)P`pS&WVH5(0$z=|fNCB}&D0cgR zhbpi!x8hm+iBEg1A>iL3jwGDKCs3Y8s4K|+Nh)VxWO=9iTK>p-taE#{Vp)AvuN2gJ zk4lV(j?O#=rh|2SX99PSD%{+~ahQoP42X_*!byh1p~q5Ed9lm4o>c8*w|#uImJo;7 zAP{Q0oN*-GlW;c)d5jwMtCUA&BZatoEsGdN@`Cw%ZSCkhs$~8yK;5QOP0|PE7O$N@ z9GI=0!=N?8Ck=evLjrra96OL0Mqs6NQe+YzbvRq=u&2VVdCxvLRnx+g=@2rrXr%KK z;qv=Jwn%p%ZNc^ZB7iS6#P>TGt{nLQowP=)FLi7L%$1NnxJ7IA^f-roHNi-jA5)T$ z9zJ(dJ8o)~Zwe7&=iYV8Bj0`m&W*?kiiiJpL(Q$k;lhYWa-I@pqMcLXxZR5B*s0V! zb1sxV0;!Wf=z33i`N3vCB-xw&I01`zd39A-2p}HHED_Lk&3SP?AiB?Qvr3t*K2W(> zA`!5wfd3amk}UGquY`OnLH#0}I^YZmCDQ_=(ywSI(fWc#erq=Ak_vB~|drGIkRSt7USm00mZuEmjb|R>iwuirn0MK!;rd+jq&f+Ivr)uk0 z^VS*b761HLzSndic|5IC}6Zp58tETLQDc~@51MuJfi9R)u3 ztO%>7i_Efbjzx`7)rL3X(VOv(L09cB_~-sSI6rX~`2tkN&I0er(c;hoU@lQ;TM5XC zphtj)XAIdiyZi(sE*k(xVdL1TSO+dUZ0oIuP^*$tCohY`O`Zp0-JO*N&b8y2Lgf-9RwSxW{hgGSvG)WYJaI z4(4$D&Z6s$Bh7KLe_>8dN{Y2U8;<}TJulLyjaC5o88yh#pp0{_vimpJLBW=J%hDmr zMg|FqjBTr$U{>HPag+tWthkk#n(L^hTM$AvvO;Ts$w-2Uim4As90N1UltXJDOhA_gDLTYDnhs|&p1TjFq&yI^zxbGFTi*>j zjMlOXZ2h0=@FzN)F1fNT_9mfgw=tcDYFe;B>}P>rcu+c%5OPKefI4LaP(Mv}sBcwi zWbs<_vmlB}O|c`faF4`cHSv0OchPC{K5|PN*F#@3Wqm-AoaL;Pe}7^unBMShp}C$}=f6{1Uk>_Rfbn}>U%~ZZ64FFmR zR+h3TP6Rg~e?Ej_j$KN;HSNx-B1=~@TvJU{ChO0{mWi{6dghEH!J-Wc}TkKJFH!skl?aFq8xze*AW&LxzgC}=Dv!L04m z)8^jF$s%=_G= zGN&0tOo6t&m48I_g&y~}myw@&l#d@(94Jh4sYYu#gcpwRP;W?yskw}kXJ(_+QqT#)JQCgbrL<>HUtn51Ah!^DG*L$Nh7NjX+b{ykt6d_|K`RAmU+&c5lJ^%^7C3%<#d z2jE8)ir9C>RwlbU#J7%i7NWkbl5Lqti$mMr)Xz$zy*US=tQz9l{uavnwIL8Nhri=$ zy;5*vIoB#pqOWq2hARPR7uLXenT)=>JzX9@XWGTYt#z7X=vCXzLD23cET0g&>e0-v z>1uUT>6(Rr2stDeKB>rgm46GfRDKvNfky-}Yu%i%Rs6Slh`w@s+hfK>mfvPYE}s^NdAqfdP7#iht=@sq zIh2=D3=@{gi(-N5)c4*3)P2x#pyVC$x%oCBvhFL6y6_mn>w`71{>huo(}0_=Dut!! zqt`DfP;ImuAl{az2dWC{%CQyS&c`(nQ=t9>v@tNN^G{BKErom9bp73Gyj5gAoK7!Gn{;v4)vk+3>dH)G;=VRQq zzYZrn92(-0mDa3a9~QaSwuFK<1v5GK0zX;^bV$~jop=|-8B_G_A04|b7Jw+Gn)}h` z;sn$eQ7~^mXS_-X7=83a1?`mraHu=uHXdM1<`p_GNUp#=D&T&0D1VLP{+*NbK?6xS zU=y{^sBbSI7U}6YtsEvJmuGy*ednPFV4hK~521sGsq+aUwN>ins5A{QIz!ZM=t~MP z$uETzABS1n&~7mK6{x9;i;`e%wn**xPdppRcROZfCOtIuqBltGQayr(Y`ORap37)e za-=kY!?GW+pHD&0ieZAIeU@!%Z#EeHgtwG^0a9fYF+e<%fc1QKg2_ipNfs5TQpt|E z1jnFd)CB~|7zCPWJ+=X&wy_TwY(>uNU}18Gcl0=eW0N|tU$rGMvY_jJ=~H?S5eG(p z+-)GFPL&cz(e85YDOgfc|y6!VBr`E$dK-_CV+8YsIyh3}YdBNBY zurTJnJBX^Fa#xA1e-0JU>ed z&JeKB&8dsUH{rl(l)bO^2q-%HT*k={sEPAb!LrPCS_So%1SzVx6smBlaTiM@8ryoO z0WQ_jd%ta@futH(7y(iA2$1s)$^rYS(@Fyd8L8Fb__`-xbRVmQ=3E3JBER%lFyPke zK(zUfTmMJ9wWMjAr^>uV?c9?>E)1l4e!`!AVpHYBW?91 + + +Draw.io Diagram + + + + +
      + + + diff --git a/docs/service-catalog/docs/assets/unbinding.png b/docs/service-catalog/docs/assets/unbinding.png new file mode 100755 index 0000000000000000000000000000000000000000..5000404804de29916ef9bec0f9c2474e08ed80ec GIT binary patch literal 43616 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 literal 0 HcmV?d00001 diff --git a/docs/service-mesh/docs.config.json b/docs/service-mesh/docs.config.json new file mode 100644 index 000000000000..ce789548f5ce --- /dev/null +++ b/docs/service-mesh/docs.config.json @@ -0,0 +1,8 @@ +{ + "spec":{ + "id":"service-mesh", + "displayName":"Service Mesh", + "description":"Overal documentation for Service Mesh", + "type":"Components" + } +} \ No newline at end of file diff --git a/docs/service-mesh/docs/001-overview.md b/docs/service-mesh/docs/001-overview.md new file mode 100644 index 000000000000..d3c4472f1475 --- /dev/null +++ b/docs/service-mesh/docs/001-overview.md @@ -0,0 +1,9 @@ +--- +title: Overview +type: Overview +--- + +Kyma Service Mesh is the component responsible for service-to-service communication, proxying, service discovery, traceability, and security. Kyma Service Mesh +is based on [Istio](https://istio.io/docs/concepts/what-is-istio/overview.html) open platform. The main principle of Kyma Service Mesh operation is the process of injecting Pods of every service with an Envoy - a sidecar proxy which intercepts the communication between the services and regulates it by applying and enforcing the rules you create. Kyma [Dex](https://github.com/coreos/dex), which is also a part of the Service Mesh, allows you to integrate any [OpenID Connect](https://openid.net/connect/)-compliant identity provider or a SAML2-based enterprise authentication server with your solution. + +See this [Istio diagram](https://istio.io/docs/concepts/what-is-istio/img/overview/arch.svg) to understand the relationship between the Istio components and Services. diff --git a/docs/service-mesh/docs/005-sidecar-proxy-injection.md b/docs/service-mesh/docs/005-sidecar-proxy-injection.md new file mode 100644 index 000000000000..cf79bd54e60f --- /dev/null +++ b/docs/service-mesh/docs/005-sidecar-proxy-injection.md @@ -0,0 +1,19 @@ +--- +title: Sidecar Proxy Injection +type: Overview +--- + +By default, the Istio Sidecar Injector watches all Pod creation operations on all Namespaces but it does not inject the newly created Pods with a sidecar proxy. + +To enable the sidecar proxy injection, set the **istio-injection** label value to `enabled` for the Namespace in which you want to enable the sidecar proxy injection. Use this command: + +``` +kubectl label namespace {YOUR_NAMESPACE} istio-injection=enabled +``` + +With the sidecar proxy injection enabled, you can inject the sidecar to Pods of a selected deployment in the given Namespace. Add this annotation to the deployment configuration file: +``` +sidecar.istio.io/inject: "true" +``` + +Read the [Installing the Istio Sidecar](https://istio.io/docs/setup/kubernetes/sidecar-injection.html) document to learn more about sidecar proxy injection. diff --git a/docs/tracing/docs.config.json b/docs/tracing/docs.config.json new file mode 100644 index 000000000000..598797c68c3d --- /dev/null +++ b/docs/tracing/docs.config.json @@ -0,0 +1,8 @@ +{ + "spec":{ + "id":"tracing", + "displayName":"tracing", + "description":"Overal documentation for Tracing", + "type":"Components" + } +} \ No newline at end of file diff --git a/docs/tracing/docs/001-overview-tracing.md b/docs/tracing/docs/001-overview-tracing.md new file mode 100644 index 000000000000..24f889199f15 --- /dev/null +++ b/docs/tracing/docs/001-overview-tracing.md @@ -0,0 +1,32 @@ +--- +title: Overview +type: Overview +--- + +The micro-services based architecture differs from the traditional monoliths in many aspects. From the request observability perspective, there are asynchronous boundaries among various different micro-services that compose a request flow. Moreover, these micro-services can have heterogeneous semantics when it comes to monitoring and observability. It is required to have a tracing solution that can provide a holistic view of the request flow and help the developer understand the system better to take informed decisions regarding troubleshooting and performance optimization. + +Tracing in Kyma uses [Jaeger](https://www.jaegertracing.io/docs/) as a backend which serves as the query mechanism for displaying information about traces. Jaeger is used for monitoring and troubleshooting microservice-based distributed systems, including: + +- Distributed context propagation +- Distributed transaction monitoring +- Root cause analysis +- Service dependency analysis +- Performance and latency optimization + +Jaeger provides compatibility with the Zipkin protocol. The compatibility makes it possible to use Zipkin protocol and clients in Istio, Envoy, and Kyma services. + +## Access Jaeger + +To access the Jaeger UI, follow these steps: + +1. Run the following command to configure port-forwarding: + +``` +kubectl port-forward -n kyma-system $(kubectl get pod -n kyma-system -l app=jaeger -o jsonpath='{.items[0].metadata.name}') 16686:16686 +``` + +2. Access the Jaeger UI at `http://localhost:16686`. + +## Propagate HTTP headers + +The envoy proxy controls the inbound and outbound traffic in the application and automatically sends the trace information to the Zipkin. However, to track the flow of the REST API calls or the service injections in Kyma, it requires the minimal application cooperation from the micro-services code. For this purpose, you need to configure the application to propagate the tracing context in HTTP headers when making outbound calls. See the [Istio documentation](https://istio.io/docs/tasks/telemetry/distributed-tracing.html#understanding-what-happened) for details on which headers are required to ensure the correct tracing in Kyma. diff --git a/docs/tracing/docs/020-architecture-tracing.md b/docs/tracing/docs/020-architecture-tracing.md new file mode 100644 index 000000000000..38dca4e417bb --- /dev/null +++ b/docs/tracing/docs/020-architecture-tracing.md @@ -0,0 +1,26 @@ +--- +title: Architecture +type: Architecture +--- + +See the diagram and steps for an overview of the tracing flow in Kyma: + +![Tracing architecture](assets/tracing-architecture.png) + +The central element of the tracing architecture in Kyma is istio-jaeger. This main component serves both as a target of all query requests made from the Jaeger UI and the space for storing and processing the spans and traces created by Envoy and Kyma services. + +## Request traces + +1. Kyma user accesses Jaeger UI and requests the trace details for a given service by selecting the service from the **Services** drop-down menu and confirming the choice by selecting the **Find Traces** button. +2. Jaeger passes the request to the the UI facade, jaeger-query. +3. The jaeger-query forwards the details to the istio-jaeger component which sends the information back. + +![Request traces](assets/request-traces.png) + +## Store traces + +1. Kyma user configures the application to propagate the correct [HTTP headers](https://istio.io/docs/tasks/telemetry/distributed-tracing.html#understanding-what-happened) for the outbound calls. +2. Envoy passes the trace details to the Zipkin Kubernetes service. This service acts as a facade to receive the trace and span details. +3. Zipkin service forwards the tracing information to the istio-jaeger component which processes the received details. + +![Store traces](assets/store-traces.png) diff --git a/docs/tracing/docs/assets/request-traces.html b/docs/tracing/docs/assets/request-traces.html new file mode 100755 index 000000000000..66760053e5f9 --- /dev/null +++ b/docs/tracing/docs/assets/request-traces.html @@ -0,0 +1,12 @@ + + + +Draw.io Diagram + + + + +

      + + + diff --git a/docs/tracing/docs/assets/request-traces.png b/docs/tracing/docs/assets/request-traces.png new file mode 100755 index 0000000000000000000000000000000000000000..7e8c7701244db4212abbf33902fb58ab7baea8b5 GIT binary patch literal 15556 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 literal 0 HcmV?d00001 diff --git a/docs/tracing/docs/assets/store-traces.html b/docs/tracing/docs/assets/store-traces.html new file mode 100755 index 000000000000..980236966c2a --- /dev/null +++ b/docs/tracing/docs/assets/store-traces.html @@ -0,0 +1,12 @@ + + + +Draw.io Diagram + + + + +
      + + + diff --git a/docs/tracing/docs/assets/store-traces.png b/docs/tracing/docs/assets/store-traces.png new file mode 100755 index 0000000000000000000000000000000000000000..85fa67d5b8df4833864adefd45e07c77ee5fc2e8 GIT binary patch literal 20817 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 OlBkfRV1d9Z&;JLj + + +Draw.io Diagram + + + + +
      + + + diff --git a/docs/tracing/docs/assets/tracing-architecture.png b/docs/tracing/docs/assets/tracing-architecture.png new file mode 100755 index 0000000000000000000000000000000000000000..3da81e6c4fb31ae9578dc95ff39a227f4f4310ae GIT binary patch literal 27527 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 NE-9-kWh kubeconfig + export KUBECONFIG=kubeconfig +} + +function deletePod { + setKubeconfig + + kubectl delete pod $POD_NAME +} + +function createPod { + setKubeconfig + + cat < pod.yaml +apiVersion: v1 +kind: Pod +metadata: + name: $POD_NAME + labels: + app: kyma +spec: + restartPolicy: Never + containers: + - image: $FULL_IMAGE_NAME + name: minikube + resources: + requests: + memory: "6Gi" + env: + - name: RUN_TESTS + value: "true" + - name: IGNORE_TEST_FAIL + value: "false" + securityContext: + privileged: true + volumeMounts: + - mountPath: /var/lib/docker + name: docker-volume + - mountPath: /var/lib/localkube/etcd + name: etcd-volume + volumes: + - name: docker-volume + emptyDir: {} + - name: etcd-volume + emptyDir: {} +EOF + + kubectl create -f pod.yaml + + for i in {1..20}; + do + podStatus=$(kubectl get pod $POD_NAME -o jsonpath='{.status.phase}') + if [[ "$podStatus" == "Running" ]]; + then + break + fi + containerReason=$(kubectl get pod $POD_NAME -o jsonpath='{.status.containerStatuses[0].state.waiting.message}') + echo "Pod is in status: $podStatus - $containerReason. Waiting..." + sleep 5 + done + + kubectl logs $POD_NAME -f + + podStatus=$(kubectl get pod $POD_NAME -o jsonpath='{.status.phase}') + + if [[ "$podStatus" == "Succeeded" ]]; + then + echo "BUILD SUCCESS" + else + echo "BUILD FAILED" + exit 1 + fi +} + +# Run function passed as first argument +$1 diff --git a/installation/cmd/ci.build.ps1 b/installation/cmd/ci.build.ps1 new file mode 100644 index 000000000000..a1a0cbf3eb5a --- /dev/null +++ b/installation/cmd/ci.build.ps1 @@ -0,0 +1,23 @@ +$CURRENT_DIR = Split-Path $MyInvocation.MyCommand.Path +$ROOT_DIR = "${CURRENT_DIR}\..\.." + +$DOCKERFILE = "${ROOT_DIR}\ci.Dockerfile" + +$FINAL_IMAGE="kyma-on-minikube" +$KUBECTL_CLI_VERSION = "1.9.0" +$KUBELESS_CLI_VERSION = "1.0.0-alpha.5" +$MINIKUBE_VERSION = "0.28.0" +$HELM_VERSION = "2.8.2" + +Push-Location $ROOT_DIR + +$cmd = "docker build -t ${FINAL_IMAGE}"` + + " -f ${DOCKERFILE}"` + + " --build-arg KUBECTL_CLI_VERSION=${KUBECTL_CLI_VERSION}"` + + " --build-arg KUBELESS_CLI_VERSION=${KUBELESS_CLI_VERSION}"` + + " --build-arg MINIKUBE_VERSION=${MINIKUBE_VERSION}"` + + " --build-arg HELM_VERSION=${HELM_VERSION} ." + +Invoke-Expression -Command $cmd + +Pop-Location \ No newline at end of file diff --git a/installation/cmd/ci.build.sh b/installation/cmd/ci.build.sh new file mode 100755 index 000000000000..e2cfabd72de9 --- /dev/null +++ b/installation/cmd/ci.build.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +set -o errexit + +CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +ROOT_DIR=$CURRENT_DIR/../../ + +DOCKERFILE="${ROOT_DIR}/ci.Dockerfile" + +FINAL_IMAGE="kyma-on-minikube" +KUBECTL_CLI_VERSION=1.10.0 +KUBELESS_CLI_VERSION=1.0.0-alpha.5 +MINIKUBE_VERSION=0.28.0 +HELM_VERSION=2.8.2 + +pushd $ROOT_DIR + +docker build -t ${FINAL_IMAGE} \ + -f ${DOCKERFILE} \ + --build-arg KUBECTL_CLI_VERSION=${KUBECTL_CLI_VERSION} \ + --build-arg KUBELESS_CLI_VERSION=${KUBELESS_CLI_VERSION} \ + --build-arg MINIKUBE_VERSION=${MINIKUBE_VERSION} \ + --build-arg HELM_VERSION=${HELM_VERSION} . + +popd \ No newline at end of file diff --git a/installation/cmd/ci.run.ps1 b/installation/cmd/ci.run.ps1 new file mode 100644 index 000000000000..af893bb3551b --- /dev/null +++ b/installation/cmd/ci.run.ps1 @@ -0,0 +1,35 @@ +param ( + [switch]$IGNORE_TEST_FAIL = $false, + [switch]$SKIP_TESTS = $false, + [switch]$NON_INTERACTIVE = $false +) + +$FINAL_IMAGE = "kyma-on-minikube" + + +$DOCKER_RUN_COMMAND = "docker run --rm -v /var/lib/docker"` + + " -p 443:443"` + + " -p 8443:8443"` + + " -p 8001:8001"` + + " -p 9411:9411"` + + " -p 32000:32000"` + + " -p 32001:32001"` + + " --privileged" + +if ($IGNORE_TEST_FAIL -eq $false) { + $DOCKER_RUN_COMMAND = "${DOCKER_RUN_COMMAND} -e IGNORE_TEST_FAIL=false" +} + +if ($SKIP_TESTS -eq $true) { + $DOCKER_RUN_COMMAND = "${DOCKER_RUN_COMMAND} -e RUN_TESTS=false" +} + +if ($NON_INTERACTIVE -eq $false) { + $DOCKER_RUN_COMMAND = "${DOCKER_RUN_COMMAND} -it" +} + +$DOCKER_RUN_COMMAND = "${DOCKER_RUN_COMMAND} ${FINAL_IMAGE}" + +Write-Output "Running command: ${DOCKER_RUN_COMMAND}" + +Invoke-Expression -Command $DOCKER_RUN_COMMAND \ No newline at end of file diff --git a/installation/cmd/ci.run.sh b/installation/cmd/ci.run.sh new file mode 100755 index 000000000000..45752dab3938 --- /dev/null +++ b/installation/cmd/ci.run.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +set -o errexit + +FINAL_IMAGE="kyma-on-minikube" +IGNORE_TEST_FAIL=true +RUN_TESTS=true + +POSITIONAL=() +while [[ $# -gt 0 ]] +do + key="$1" + + case ${key} in + --non-interactive) + NON_INTERACTIVE=true + shift # past argument + ;; + --exit-on-test-fail) + IGNORE_TEST_FAIL=false + shift # past argument + ;; + --skip-tests) + RUN_TESTS=false + shift + ;; + *) # unknown option + POSITIONAL+=("$1") # save it in an array for later + shift # past argument + ;; + esac +done +set -- "${POSITIONAL[@]}" # restore positional parameters + +DOCKER_RUN_COMMAND="docker run --rm -v /var/lib/docker \ + -p 443:443 \ + -p 8443:8443 \ + -p 8001:8001 \ + -p 9411:9411 \ + -p 32000:32000 \ + -p 32001:32001 \ + -e IGNORE_TEST_FAIL=${IGNORE_TEST_FAIL} \ + -e RUN_TESTS=${RUN_TESTS} \ + --privileged" + +if [ -z "${NON_INTERACTIVE}" ]; then + DOCKER_RUN_COMMAND="${DOCKER_RUN_COMMAND} -it " +fi + +DOCKER_RUN_COMMAND="${DOCKER_RUN_COMMAND} ${FINAL_IMAGE}" + +if type sudo 1> /dev/null 2> /dev/null; then + sudo ${DOCKER_RUN_COMMAND} +else + ${DOCKER_RUN_COMMAND} +fi diff --git a/installation/cmd/run.ps1 b/installation/cmd/run.ps1 new file mode 100644 index 000000000000..e623ecd6774d --- /dev/null +++ b/installation/cmd/run.ps1 @@ -0,0 +1,37 @@ +param ( + [string]$CR_PATH = "", + [switch]$SKIP_MINIKUBE_START = $false, + [string]$VM_DRIVER = "hyperv" +) + +$CURRENT_DIR = Split-Path $MyInvocation.MyCommand.Path +$SCRIPTS_DIR = "$CURRENT_DIR\..\scripts" +$LOCAL = $true +$DOMAIN = "kyma.local" + +if ($CR_PATH -ne "") { + $LOCAL = $false +} + +if ($SKIP_MINIKUBE_START -eq $false) { + Invoke-Expression -Command "${SCRIPTS_DIR}\minikube.ps1 -vm_driver ${VM_DRIVER} -domain ${DOMAIN}" + + if($LastExitCode -gt 0){ + exit + } +} + +if ($LOCAL -eq $true) { + $CR_PATH = (New-TemporaryFile).FullName + + $cmd = "$SCRIPTS_DIR\create-cr.ps1 -output ${CR_PATH} -domain ${DOMAIN}" + Invoke-Expression -Command $cmd + Get-Content -Path $CR_PATH + + $cmd = "${SCRIPTS_DIR}\installer.ps1 -local -cr_path ${CR_PATH}" + Invoke-Expression -Command $cmd +} +else { + Write-Output "Non-local run is not supported!" + exit +} \ No newline at end of file diff --git a/installation/cmd/run.sh b/installation/cmd/run.sh new file mode 100755 index 000000000000..189aaf59a7bf --- /dev/null +++ b/installation/cmd/run.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +set -o errexit + +CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +DOMAIN="kyma.local" + +POSITIONAL=() +while [[ $# -gt 0 ]] +do + key="$1" + + case ${key} in + --skip-minikube-start) + SKIP_MINIKUBE_START=true + shift # past argument + ;; + --cr) + CR_PATH="$2" + shift # past argument + shift # past value + ;; + *) # unknown option + POSITIONAL+=("$1") # save it in an array for later + shift # past argument + ;; + esac +done +set -- "${POSITIONAL[@]}" # restore positional parameters + +if [[ ! ${SKIP_MINIKUBE_START} ]]; then + bash $CURRENT_DIR/../scripts/minikube.sh --domain ${DOMAIN} +fi + +if [ -z "$CR_PATH" ]; then + + TMPDIR=`mktemp -d "$CURRENT_DIR/../../temp-XXXXXXXXXX"` + CR_PATH="$TMPDIR/installer-cr-local.yaml" + + bash $CURRENT_DIR/../scripts/create-cr.sh --output ${CR_PATH} --domain ${DOMAIN} + bash $CURRENT_DIR/../scripts/installer.sh --local --cr "$CR_PATH" + + rm -rf $TMPDIR +else + bash $CURRENT_DIR/../scripts/installer.sh --cr "$CR_PATH" +fi diff --git a/installation/resources/azure-blobstore-secret.yaml.tpl b/installation/resources/azure-blobstore-secret.yaml.tpl new file mode 100644 index 000000000000..360cc079b001 --- /dev/null +++ b/installation/resources/azure-blobstore-secret.yaml.tpl @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: azure-blobstore-secret + namespace: kyma-installer +type: Opaque +data: + shared_key: "__KYMA_RELEASES_AZURE_BLOBSTORE_KEY__" diff --git a/installation/resources/azure-broker-secret.yaml.tpl b/installation/resources/azure-broker-secret.yaml.tpl new file mode 100644 index 000000000000..475d0223e979 --- /dev/null +++ b/installation/resources/azure-broker-secret.yaml.tpl @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: azure-broker + namespace: kyma-installer +type: Opaque +data: + azure_broker_subscription_id: __AZURE_BROKER_SUBSCRIPTION_ID__ + azure_broker_tenant_id: __AZURE_BROKER_TENANT_ID__ + azure_broker_client_id: __AZURE_BROKER_CLIENT_ID__ + azure_broker_client_secret: __AZURE_BROKER_CLIENT_SECRET__ diff --git a/installation/resources/cluster-certificate-secret.yaml.tpl b/installation/resources/cluster-certificate-secret.yaml.tpl new file mode 100644 index 000000000000..417a10cf9b55 --- /dev/null +++ b/installation/resources/cluster-certificate-secret.yaml.tpl @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: cluster-certificate + namespace: kyma-installer +type: Opaque +data: + tls_cert: __TLS_CERT__ + tls_key: __TLS_KEY__ diff --git a/installation/resources/installation-config.yaml.tpl b/installation/resources/installation-config.yaml.tpl new file mode 100644 index 000000000000..fa4a1dcc6366 --- /dev/null +++ b/installation/resources/installation-config.yaml.tpl @@ -0,0 +1,19 @@ +################################################## +# ConfigMap for Installer (Template) # +# # +# This map is used to populate # +# environment variables for Kyma Installer. # +# # +################################################## +apiVersion: v1 +kind: ConfigMap +metadata: + name: installation-config + namespace: kyma-installer +data: + external_ip_address: "__EXTERNAL_IP_ADDRESS__" + domain: "__DOMAIN__" + remote_env_ip: "__REMOTE_ENV_IP__" + k8s_apiserver_url: "__K8S_APISERVER_URL__" + k8s_apiserver_ca: "__K8S_APISERVER_CA__" + admin_group: "__ADMIN_GROUP__" diff --git a/installation/resources/installer-cr.yaml.tpl b/installation/resources/installer-cr.yaml.tpl new file mode 100644 index 000000000000..cfd4f54abedc --- /dev/null +++ b/installation/resources/installer-cr.yaml.tpl @@ -0,0 +1,11 @@ +apiVersion: "installer.kyma.cx/v1alpha1" +kind: Installation +metadata: + name: kyma-installation + labels: + action: install + finalizers: + - finalizer.installer.kyma.cx +spec: + version: __VERSION__ + url: __URL__ diff --git a/installation/resources/installer-types.yaml b/installation/resources/installer-types.yaml new file mode 100644 index 000000000000..e89aceaaf46d --- /dev/null +++ b/installation/resources/installer-types.yaml @@ -0,0 +1,27 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: installations.installer.kyma.cx +spec: + group: installer.kyma.cx + version: v1alpha1 + scope: Namespaced + names: + kind: Installation + singular: installation + plural: installations + shortNames: ['installation'] +--- +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: releases.release.kyma.cx +spec: + group: release.kyma.cx + version: v1alpha1 + scope: Namespaced + names: + kind: Release + singular: release + plural: releases + shortNames: ['release'] diff --git a/installation/resources/installer.yaml b/installation/resources/installer.yaml new file mode 100644 index 000000000000..e29026f519e8 --- /dev/null +++ b/installation/resources/installer.yaml @@ -0,0 +1,149 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kyma-installer +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: kyma-installer +spec: + template: + metadata: + labels: + name: kyma-installer + spec: + serviceAccountName: kyma-installer + containers: + - name: kyma-installer-container + image: eu.gcr.io/kyma-project/installer:0.1.4 + imagePullPolicy: IfNotPresent + env: + - name: AZURE_BROKER_SUBSCRIPTION_ID + valueFrom: + secretKeyRef: + name: azure-broker + key: azure_broker_subscription_id + optional: true + - name: AZURE_BROKER_TENANT_ID + valueFrom: + secretKeyRef: + name: azure-broker + key: azure_broker_tenant_id + optional: true + - name: AZURE_BROKER_CLIENT_ID + valueFrom: + secretKeyRef: + name: azure-broker + key: azure_broker_client_id + optional: true + - name: AZURE_BROKER_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: azure-broker + key: azure_broker_client_secret + optional: true + - name: TLS_CERT + valueFrom: + secretKeyRef: + name: cluster-certificate + key: tls_cert + optional: true + - name: TLS_KEY + valueFrom: + secretKeyRef: + name: cluster-certificate + key: tls_key + optional: true + - name: REMOTE_ENV_CA + valueFrom: + secretKeyRef: + name: remote-env-certificate + key: remote_env_ca + optional: true + - name: REMOTE_ENV_CA_KEY + valueFrom: + secretKeyRef: + name: remote-env-certificate + key: remote_env_ca_key + optional: true + - name: UI_TEST_USER + valueFrom: + secretKeyRef: + name: ui-test + key: user + optional: true + - name: UI_TEST_PASSWORD + valueFrom: + secretKeyRef: + name: ui-test + key: password + optional: true + - name: EXTERNAL_IP_ADDRESS + valueFrom: + configMapKeyRef: + name: installation-config + key: external_ip_address + - name: DOMAIN + valueFrom: + configMapKeyRef: + name: installation-config + key: domain + - name: REMOTE_ENV_IP + valueFrom: + configMapKeyRef: + name: installation-config + key: remote_env_ip + - name: K8S_APISERVER_URL + valueFrom: + configMapKeyRef: + name: installation-config + key: k8s_apiserver_url + - name: K8S_APISERVER_CA + valueFrom: + configMapKeyRef: + name: installation-config + key: k8s_apiserver_ca + - name: ADMIN_GROUP + valueFrom: + configMapKeyRef: + name: installation-config + key: admin_group + optional: true +--- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1beta1 +metadata: + name: kyma-installer-reader +rules: +- apiGroups: ["*"] + resources: ["*"] + verbs: ["*"] +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1beta1 +metadata: + name: kyma-installer +subjects: +- kind: ServiceAccount + name: kyma-installer + namespace: kyma-installer +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kyma-installer-reader +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: default-sa-cluster-admin + labels: + app: kyma +subjects: +- kind: ServiceAccount + name: default + namespace: kube-system +roleRef: + kind: ClusterRole + apiGroup: rbac.authorization.k8s.io + name: cluster-admin diff --git a/installation/resources/local-tls-certs.yaml b/installation/resources/local-tls-certs.yaml new file mode 100644 index 000000000000..dae9ff611116 --- /dev/null +++ b/installation/resources/local-tls-certs.yaml @@ -0,0 +1,2 @@ +tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUpRd0lCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQ1Mwd2dna3BBZ0VBQW9JQ0FRRG9EVnBremZUSkk4cEUKZTZBZ1JSMUkvekE2b2NwNHNPQVJ5NC9IdXdpMU1yVnFVNzFqU1VrRzRWUXZzUGVoeHBqYmRMeFVPTWZDMDVMRApFWS9xbFpMeHRQdEFpekZRWnNVZEZMODJCamp4RXlReXBlZW5hb3RBdVhJSUVzSGV1LzhwVUgzZGJLZzFEeHR5ClZIMGpSMEVHUHVTUk5JaVhIc1FRc1B5cHVFTWt3S2JZUzk3UjlabUF6d0FoMDdtWlpZT2hFc1lIU1hKdmxZZlUKUTZKb2plNTdYOUdFNXFsbEIzc1dFUlBxdFJYQkdqOGZmM2ZRQzNvTG1MeGpScDlUL1BrTUhUUXZ1V2RCQlpFTgppclpvMHdCamlwSlNENXpBUlo1c1JSeWpJTjBwTFhYT3R5RFV1M0I5KzdkM0tJc2V6aHFmWHE3YUIxY3ZudmZ1CjIyNis0Q0dsN0RSRlU2K0JXS3g5a2pzYStkdVVBQ1JqdnViMkR3bHlVU0xxakhudTN2TzRlQjBxeEZGbFRjVGIKU0FCbFRHMDVsbXM0ZHBSd0laTm5Ia2QwUTJRdE1PR2pjTEVFbEhUSzltZ1lnSVoyakI1K0c3eDM4THZLa0s5YwpvazArSnVDU0poUlV1Tjc1WEF5K24xQVlPcXJVbzh3ZGJXTlhEcjlzcmdMZHhtQ1J3bE5Gc1JsUXdUdURoWUtSCnU0VFVRODlodVJOYmZ4SHVrK2RjUHV2cU1zc1c2REl2am5WSURLV1BJcktld0RJWnIxUlBScHVSNlFTUUJldjYKMnVXQW9PUExpSks1eG1KNlBja2lhWVNrZ1pkRXkvekR1VUVJaWFCeE9yc253VmdmV0lFZ0xmYmR0bzBxa1dTVQpiU3BKYWM1VVJ4UXpXT0tYMDVJc2ppVUZtMHZCS3dJREFRQUJBb0lDQUZDN3ZKUlB0M2QzVlRyb1MvaU9NemNmCldaYzhqT1hhbThwMUtRdlRQWjlWQ2hyNUVXNEdwRHFaa0tHYkR6eWdqTFBsZEZSVkFPTCtteFAwK3o0aFZlTjAKRk9vS3cxaDJ1T042UVdBNVgvdzNyYU5WWnpndThFM1BkeVhwNkx0bWFzcmo3elpuUkVwWmZESVZ4UWZPRllobgp2enZwckEvdnEwVW5YbkJwNUNwWVFIUUdTWHFBMlN3Z1dLcHNNQ2wzVVFsc0w2dC9XU29MT3h1VmdGNmg2clBQCnpXUlFuK1MvYW9wdDNLRU82WWVxYXdXNVltVG1hVXE1aytseU82S0w0OVhjSHpqdlowWU8rcjFjWWtRc0RQbVUKejMxdll4amQzOVZKWWtJNi85Y0Fzdmo5YTVXM3ROYVFDZStTRW56Z05oRDJieHo1NnRKdG0xTGwweXpqYTdEVgp0OW9aQVd6WURMOTVnMDFZWVlQMG51c3A4WWlNOXd0OUtLc2NkcUd3RFN5WHNrWFBrcWFLVjNVcHJYdmhFbFVaCkErMmtjcm9VaDNGbEV4bGpwUmtKVkhOZXJ3NHRLRi9oYTFWRjZPdE10eTVQcXV0N0dGQmIvamtWeUg5cnpueWUKTXQyTWVyTTVPazMwd1NuTThISUdTUXpxYlplekJEZlNaUzRzcWdZZnBIMlhtMEs0SjgrRUowQ2hhMXZVSmVNMAoyZ284d00vaHljdmtqTEgxSmM3OEhpaVBTQ01udkpHemUxc2tWdmtRRFhBSFdldzBTUHpUSTZHYjZCb0Y4aVNECm0wZjR2azNoV3NlUWZBaXVZSnlUeUZXNmRhOGE1K2lpSDN4cVRsUUN1MDN1Nmo0U0l4aThJZlNmd0YwQTBldVAKNGtzalZTZVZyT3ExUnlvNUtpR3hBb0lCQVFEOWZtYnl6aW9QdVhRYzl0QXBxMUpSMzErQzlCdFFzcDg5WkZkSQpQaU5xaTJ3NVlVcTA0OFM2Z3VBb3JGOHNObUI3QjhWa1JlclowQ3hub2NHY0tleWdTYWsvME5qVElndk5weGJwCnBGbkFnRjlmbW1oTEl2SlF2REo2Q0ZidDRCQlZIdkJEWlYyQnZqK0k3NUxkK01jN2RPVDdFek1FRjBXcUdzY2MKTUpyNjRXQi9UMkF5dWR1YXlRT2NobmJFQ25FUmdRcHFlbG54MVBraytqbGNvYUs5QjFYUStVOUgzOHppM0FYNApENUxMY0Nhem9YYWlvS0swckNlNE5Ga2hOVXd0TFV0QXhSTXk2aksyZUZudWczUFRlY3N1WktNMElITktqZ0dCCnpGanZVb2tMcFVFb3BJa3FHM09yc0xmanpHZW9jaVFPUXNEdzlUb3lXL0FSOFhmWkFvSUJBUURxV0s2TThVN3EKUXJPeTYzNnpEZlBaZ2ljeXlsUWVoOFdMclBlbW5NeWdQYzR4eWoybnMrTmVRSnNEUmtPT2tWY250SEFYaTcwWgoyT0NCV3dwZHJuTXlSc3RIMU05bjdFNU5TVHZlZDlkU3YxUzRBb3NzS1hDSmgyUHBjYjV0OE9nL3ZGTlNYUlUyClk2aUorWTdOcDBZNDNxSlJOVnlRemd3YmFzaEpiUVdkVFFoVEVhdEVRS2JsUlZSblhlQWRjOXlhNUpHbkRpaTkKbFQrRWEzdFpvN1dha05oeHJkYjVuTkZ3a0xoNEs3ZkFtT3ZzMVBMQWx0SUZqeURCeDEvY1ZHblpDUDBVQmJqZgpkU2FueXBBdVRuMzd1VUwrcXpPVDlYWEZENllGT0x0NWV4d3RxdnUwSzZCNjcvajFFTlJDRk45RnMzWlV5RFFXClZUaDcybFhWU3NLakFvSUJBUUR6ZE5pdXpTNDhWK0thaHJpNXJGNnRYeGkrRG0vRmV5ZlFzSFBiWUVKbmEyd1AKVjgrR0YxS3p4a28vQmYySjJ0ZWlrWDRVcGNtK1UxNnlVUG8vWDB4eFRRMk55cWpUYmRsa005dWZuVWJOeVB6UQpOdDEvZkJxNVMyWTNLWmREY25SOUsrK1k2dHQ1Wmh4akNhUkdKMDVCWGkwa3JmWExNZ2FvTG51WUtWNVBJUEdxCms3TlNSSW9UQ0llOVpxN2Q3U0ZXckZZeW1UdVZOUFByZlo1bHhwOGphTTRVbTd4MnpReGJ2UERHb3o1YXdHV0wKRThGNncwaEF1UzZValVJazBLbE9vamVxQnh3L1JBcGNrUTNlTXNXbEQwNENTb2tyNFJhWlBmVllrY2ZBWWNaWgpOdWR6ZjBKMC9GU0ZTbjN4L0RoNTROV2NGS1IxUnpBVGVaVUJ4cVZSQW9JQkFRRFlDNmZvVWpOQnJ2ckNFVzkrCkhYZlk1Ni9CbUZ4U3hUTHU0U2h6VncwakViZTltVWljQ2pDc1hQMUwySVJCdEdaWU9YWTVqdDlvSzlSV0RSdVMKWUZqZFdmemduU1lWRmZyZUw0emRQVGlxbGEvQjhMNWptVlNoeGNycmxheE02Uk1FWjFlZGtDa1ZPbTFQdmwzVAo1TW5OZGhySXFWeE1OMWxjRVdiU29vclJpUW9Lb3poMHRQSG9YckZBbG9BZVJ3bHpWeE9jb21ZVzJiaDBHUzdmCjVoaHZoZWUxYmVISnY3UXFoWkU3WUhxSU9iTVBaUWJqWEdnRkxmMnlDRitzM2Jtem1DRFJTN0V6ZVdxSXVDdVMKTlZUYU0rSzZyQlRoN0NLRjZUWlNqQW55SmZoRmRlT1ZKNzlNZDEzYWVJaG0zNTB6UWc3dWZKL2drdkorNUR2TApacC9uQW9JQkFBVlg4WHpFTzdMVk1sbENKZFVUdURTdXNPcDNrQVlFZ2dZNFFRM3FNTlcxRnl5WEM3WjBGOWJFCmtTSEhkalJtU2RUbFZueGN2UW1KTS9WL2tJanpNUHhFT3NCS1BVVkR3N3BhOHdiejlGcTRPOCtJb3lqN1ZXclcKMmExL1FNWXlzSGlpTlBzNy8vWUtvMy9rdkhCWUY2SnNkenkyQkVSTkQ0aTlVOWhDN0RqcGxKR3BSNktMTVBsegpNWFJ3VjVTM2V3cnBXZVcxQW5ONC9EKy9zUGlNQTNnS0swSlBFdGVBV1dndEZHTnNBSkJnaFBoUExxQi9CcDUvCkhOeC96M0w0MWtqRnpqOHNWaHMrVDRZYlhiaGF2R2xxc2h5ZldQbnRhV1VOMG15MjU1RFdQUDhWa24yeFNlV2kKVm1hVW5TSDBTZ2tlUENMRnlra25yQzgxU2pXZkRBMD0KLS0tLS1FTkQgUFJJVkFURSBLRVktLS0tLQo= +tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUU4RENDQXRpZ0F3SUJBZ0lKQUpBKzlrQzZZZnZlTUEwR0NTcUdTSWIzRFFFQkN3VUFNQmN4RlRBVEJnTlYKQkFNTURDb3VhM2x0WVM1c2IyTmhiREFlRncweE9EQTNNVGd3T0RNNE1UTmFGdzB5T0RBM01UVXdPRE00TVROYQpNQmN4RlRBVEJnTlZCQU1NRENvdWEzbHRZUzVzYjJOaGJEQ0NBaUl3RFFZSktvWklodmNOQVFFQkJRQURnZ0lQCkFEQ0NBZ29DZ2dJQkFPZ05XbVROOU1ranlrUjdvQ0JGSFVqL01EcWh5bml3NEJITGo4ZTdDTFV5dFdwVHZXTkoKU1FiaFZDK3c5NkhHbU50MHZGUTR4OExUa3NNUmorcVZrdkcwKzBDTE1WQm14UjBVdnpZR09QRVRKREtsNTZkcQppMEM1Y2dnU3dkNjcveWxRZmQxc3FEVVBHM0pVZlNOSFFRWSs1SkUwaUpjZXhCQ3cvS200UXlUQXB0aEwzdEgxCm1ZRFBBQ0hUdVpsbGc2RVN4Z2RKY20rVmg5UkRvbWlON250ZjBZVG1xV1VIZXhZUkUrcTFGY0VhUHg5L2Q5QUwKZWd1WXZHTkduMVA4K1F3ZE5DKzVaMEVGa1EyS3RtalRBR09La2xJUG5NQkZubXhGSEtNZzNTa3RkYzYzSU5TNwpjSDM3dDNjb2l4N09HcDllcnRvSFZ5K2U5KzdiYnI3Z0lhWHNORVZUcjRGWXJIMlNPeHI1MjVRQUpHTys1dllQCkNYSlJJdXFNZWU3ZTg3aDRIU3JFVVdWTnhOdElBR1ZNYlRtV2F6aDJsSEFoazJjZVIzUkRaQzB3NGFOd3NRU1UKZE1yMmFCaUFobmFNSG40YnZIZnd1OHFRcjF5aVRUNG00SkltRkZTNDN2bGNETDZmVUJnNnF0U2p6QjF0WTFjTwp2Mnl1QXQzR1lKSENVMFd4R1ZEQk80T0ZncEc3aE5SRHoyRzVFMXQvRWU2VDUxdys2K295eXhib01pK09kVWdNCnBZOGlzcDdBTWhtdlZFOUdtNUhwQkpBRjYvcmE1WUNnNDh1SWtybkdZbm85eVNKcGhLU0JsMFRML01PNVFRaUoKb0hFNnV5ZkJXQjlZZ1NBdDl0MjJqU3FSWkpSdEtrbHB6bFJIRkROWTRwZlRraXlPSlFXYlM4RXJBZ01CQUFHagpQekE5TUFrR0ExVWRFd1FDTUFBd0N3WURWUjBQQkFRREFnWGdNQ01HQTFVZEVRUWNNQnFDQ210NWJXRXViRzlqCllXeUNEQ291YTNsdFlTNXNiMk5oYkRBTkJna3Foa2lHOXcwQkFRc0ZBQU9DQWdFQUlYYTlwenlyQ2dzMTRTOHUKZVFZdkorNEFzUE9uT1RGcExkaVl5UkVyNXdyNmJuMXUvMjZxc2FKckpxbkkyRk16SmdEQVRwZEtjbXRHYjBUOQp3S2wrYUJHcFFKcThrUWJwakVGTHhaWDJzaUNrRG82WittaUcrRjRKMHpKa3BKK0JHMS92eGZKbk0zK1ptdXQ5Ck9RV2ZjYTN3UHlhTWRDbGIyZjQwYlRFaFo5Mk9kcWlQMzFMbDlHWExSZmhaNTNsUzF0QWdvUGZoR25NbFY4b2MKWmxuSUROK25wS0Nma2tXUDJZUjlRLy9pa01tM01YRm9RSFppaVJseVZHSGFKZWRLMmNOQzlUYk4xNDFTaWZHZQo3V2FsQVBNcWNOQ3F3YStnN2RFSmR3ZjlRMklJTml0SjlDUVprT1dUZElYY2VHK2lZWWUrQXpmK1NkaHBocVdPCllFcDF6ek40dXI5U2VxU3NSaU9WY0RzVUFSa1M0clgrb0Vzb2hHL1Q5OTcrSDhjR2gzczl6TE84emtwRXZKSmEKS05QT0N5ODhVeEFOV2RRejFLMXRKVVQ2c3hkd0FEcXRJQnNPemhYVjlybDRRNStlZExlcmZPcUtCbUFRMUY5Swo2L1l0ZlNyY0JpeXZEU24wdFJ3OHJLRFVQU1hFNDFldXArOURNeThLVGl6T0RPTXVMSnR2dkJrTEFpNGNYQjVBCjQxMjBEdHdZQXNyNzNZYVl2SW8rWjV2OGZ4TjF3M3IwYS9KOVhZQlg3S3p1OFl4MnNUNWtWM2dNTHFCTXBaa3gKY29FTjNSandDMmV4VHl6dGc1ak1ZN2U4VFJ4OFFTeUxkK0pBd2t1Tm01NlNkcHFHNTE3cktJYkVMNDZzbkd0UgpCYUVOK01GeXNqdDU3ejhKQXJDMzhBMFN5dTQ9Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K diff --git a/installation/resources/release-adder.yaml b/installation/resources/release-adder.yaml new file mode 100644 index 000000000000..3c9821f76b52 --- /dev/null +++ b/installation/resources/release-adder.yaml @@ -0,0 +1,72 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: releases.release.kyma.cx +spec: + group: release.kyma.cx + version: v1alpha1 + scope: Namespaced + names: + kind: Release + singular: release + plural: releases + shortNames: ['release'] +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kyma-release-adder +--- +apiVersion: batch/v1beta1 +kind: CronJob +metadata: + name: "kyma-release-adder" +spec: + schedule: "*/5 * * * *" + successfulJobsHistoryLimit: 0 + failedJobsHistoryLimit: 10 + jobTemplate: + metadata: + labels: + name: kyma-release-adder + spec: + template: + spec: + serviceAccountName: kyma-release-adder + containers: + - name: kyma-release-adder-container + image: eu.gcr.io/kyma-project/release-adder:0.1.0 + imagePullPolicy: IfNotPresent + env: + - name: BLOB_ACCOUNT_NAME + value: "kymainstaller" + - name: BLOB_ACCOUNT_KEY + valueFrom: + secretKeyRef: + name: azure-blobstore-secret + key: shared_key + - name: BLOB_CONTAINER_NAME + value: "kyma" + restartPolicy: Never +--- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1beta1 +metadata: + name: kyma-release-adder-reader +rules: +- apiGroups: ["release.kyma.cx"] + resources: ["releases"] + verbs: ["get", "list", "create"] +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1beta1 +metadata: + name: kyma-release-adder +subjects: +- kind: ServiceAccount + name: kyma-release-adder + namespace: kyma-installer +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kyma-release-adder-reader diff --git a/installation/resources/remote-env-certificate-secret.yaml.tpl b/installation/resources/remote-env-certificate-secret.yaml.tpl new file mode 100644 index 000000000000..09fd00916937 --- /dev/null +++ b/installation/resources/remote-env-certificate-secret.yaml.tpl @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: remote-env-certificate + namespace: kyma-installer +type: Opaque +data: + remote_env_ca: __REMOTE_ENV_CA__ + remote_env_ca_key: __REMOTE_ENV_CA_KEY__ diff --git a/installation/resources/ui-test-secret.yaml.tpl b/installation/resources/ui-test-secret.yaml.tpl new file mode 100644 index 000000000000..188ed29bf9fc --- /dev/null +++ b/installation/resources/ui-test-secret.yaml.tpl @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: ui-test + namespace: kyma-installer +type: Opaque +data: + user: __UI_TEST_USER__ + password: __UI_TEST_PASSWORD__ diff --git a/installation/resources/watch-pods.yaml b/installation/resources/watch-pods.yaml new file mode 100644 index 000000000000..d5e963bd0f8e --- /dev/null +++ b/installation/resources/watch-pods.yaml @@ -0,0 +1,53 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: watch-pods + namespace: kyma-system + labels: + app: watch-pods +--- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1beta1 +metadata: + name: watch-pods + labels: + app: watch-pods +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get", "watch", "list"] +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1beta1 +metadata: + name: watch-pods + labels: + app: watch-pods +subjects: +- kind: ServiceAccount + name: watch-pods + namespace: kyma-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: watch-pods +--- +apiVersion: v1 +kind: Pod +metadata: + name: watch-pods + namespace: kyma-system + labels: + app: watch-pods +spec: + containers: + - name: watch-pods + image: eu.gcr.io/kyma-project/watch-pods:0.2.72 + imagePullPolicy: Always + env: + - name: ARGS + valueFrom: + configMapKeyRef: + name: pod-watch-config + key: ARGS + serviceAccountName: watch-pods diff --git a/installation/scripts/clean-up.ps1 b/installation/scripts/clean-up.ps1 new file mode 100644 index 000000000000..1daffccff6df --- /dev/null +++ b/installation/scripts/clean-up.ps1 @@ -0,0 +1,35 @@ +$cmd = "kubectl.exe delete installation/kyma-installation" +Invoke-Expression -Command $cmd + +$cmd = "kubectl.exe delete ns kyma-installer" +Invoke-Expression -Command $cmd + +$cmd = "helm.exe del --purge ec-default" +Invoke-Expression -Command $cmd + +$cmd = "helm.exe del --purge hmc-default" +Invoke-Expression -Command $cmd + +$cmd = "helm.exe del --purge dex" +Invoke-Expression -Command $cmd + +$cmd = "helm.exe del --purge core" +Invoke-Expression -Command $cmd + +$cmd = "helm.exe del --purge istio" +Invoke-Expression -Command $cmd + +$cmd = "helm.exe del --purge cluster-essentials" +Invoke-Expression -Command $cmd + +$cmd = "helm.exe del --purge prometheus-operator" +Invoke-Expression -Command $cmd + +$cmd = "kubectl.exe delete ns kyma-system" +Invoke-Expression -Command $cmd + +$cmd = "kubectl.exe delete ns istio-system" +Invoke-Expression -Command $cmd + +$cmd = "kubectl.exe delete ns kyma-integration" +Invoke-Expression -Command $cmd \ No newline at end of file diff --git a/installation/scripts/clean-up.sh b/installation/scripts/clean-up.sh new file mode 100755 index 000000000000..c4ae48e2ea49 --- /dev/null +++ b/installation/scripts/clean-up.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +# set -o errexit + +kubectl delete installation/kyma-installation +kubectl delete ns kyma-installer + +helm del --purge ec-default +helm del --purge hmc-default +helm del --purge dex +helm del --purge core +helm del --purge istio +helm del --purge cluster-essentials +helm del --purge prometheus-operator + +kubectl delete ns kyma-system +kubectl delete ns istio-system +kubectl delete ns kyma-integration \ No newline at end of file diff --git a/installation/scripts/copy-resource.ps1 b/installation/scripts/copy-resource.ps1 new file mode 100644 index 000000000000..87afbbd243a7 --- /dev/null +++ b/installation/scripts/copy-resource.ps1 @@ -0,0 +1,31 @@ +$CURRENT_DIR = Split-Path $MyInvocation.MyCommand.Path +$LOCAL_DIR = "${CURRENT_DIR}\..\..".Substring(2) +$INSTALLER_NS = "kyma-installer" +$INSTALLER_POD = "kyma-installer" +$REMOTE_DIR = "/kyma" + +$cmd = "kubectl.exe -n ${INSTALLER_NS} get pods -o jsonpath='{.items[*].metadata.name}'" +$POD_NAME = (Invoke-Expression -Command $cmd | Out-String).ToString().Trim() + +Write-Output "Copying kyma sources from ${LOCAL_DIR} into ${POD_NAME}:${REMOTE_DIR} ..." + +$cmd = "${CURRENT_DIR}\is-ready.ps1 -ns ${INSTALLER_NS} name ${INSTALLER_POD}" +Invoke-Expression -Command $cmd + +$cmd = "kubectl.exe exec -n ${INSTALLER_NS} ${POD_NAME} -- /bin/rm -rf ${REMOTE_DIR}" +Invoke-Expression -Command $cmd + +$cmd = "kubectl.exe exec -n ${INSTALLER_NS} ${POD_NAME} -- /bin/mkdir ${REMOTE_DIR}" +Invoke-Expression -Command $cmd + +$cmd = "kubectl.exe cp ${LOCAL_DIR}\resources ${INSTALLER_NS}/${POD_NAME}:${REMOTE_DIR}/resources" +Invoke-Expression -Command $cmd + +$cmd = "kubectl.exe cp ${LOCAL_DIR}\installation ${INSTALLER_NS}/${POD_NAME}:${REMOTE_DIR}/installation" +Invoke-Expression -Command $cmd + +$cmd = "kubectl.exe cp ${LOCAL_DIR}\docs ${INSTALLER_NS}/${POD_NAME}:${REMOTE_DIR}/docs" +Invoke-Expression -Command $cmd + +$cmd = "kubectl.exe exec -n ${INSTALLER_NS} ${POD_NAME} -- /bin/chmod -R +x ${REMOTE_DIR}" +Invoke-Expression -Command $cmd diff --git a/installation/scripts/copy-resources.sh b/installation/scripts/copy-resources.sh new file mode 100755 index 000000000000..d613cd5a89e2 --- /dev/null +++ b/installation/scripts/copy-resources.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +INSTALLER_NS_NAME=kyma-installer +INSTALLER_POD_PATTERN=kyma-installer +REMOTE_DIRECTORY=/kyma +CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +LOCAL_DIRECTORY="$( cd "$( dirname "${BASH_SOURCE[0]}" )/../.." && pwd )" + +function findPodName { + POD_PATTERN=$1 + echo $(kubectl -n ${INSTALLER_NS_NAME} get pods -o jsonpath="{.items[*].metadata.name}") +} + +function findContainerName { + POD_NAME=$1 + echo $(kubectl -n ${INSTALLER_NS_NAME} get pods ${POD_NAME} -o jsonpath='{.spec.containers[*].name}') +} + +POD_NAME=$(findPodName ${INSTALLER_POD_PATTERN}) +echo "Copying kyma sources from ${LOCAL_DIRECTORY} into ${POD_NAME}:${REMOTE_DIRECTORY} ..." + +bash $CURRENT_DIR/is-ready.sh $INSTALLER_NS_NAME name $INSTALLER_POD_PATTERN + +kubectl -n ${INSTALLER_NS_NAME} exec $POD_NAME -- "/bin/rm" "-rf" "${REMOTE_DIRECTORY}" +kubectl -n ${INSTALLER_NS_NAME} exec $POD_NAME -- "/bin/mkdir" "${REMOTE_DIRECTORY}" + +kubectl cp "${LOCAL_DIRECTORY}/resources" "${INSTALLER_NS_NAME}/${POD_NAME}:${REMOTE_DIRECTORY}/resources" +kubectl cp "${LOCAL_DIRECTORY}/installation" "${INSTALLER_NS_NAME}/${POD_NAME}:${REMOTE_DIRECTORY}/installation" +kubectl cp "${LOCAL_DIRECTORY}/docs" "${INSTALLER_NS_NAME}/${POD_NAME}:${REMOTE_DIRECTORY}/docs" diff --git a/installation/scripts/create-config-map.ps1 b/installation/scripts/create-config-map.ps1 new file mode 100644 index 000000000000..96e8d2fea95c --- /dev/null +++ b/installation/scripts/create-config-map.ps1 @@ -0,0 +1,21 @@ +param ( + [string]$DOMAIN = "", + [string]$IP_ADDRESS = "", + [string]$REMOTE_ENV_IP = "", + [string]$K8S_APISERVER_URL = "", + [string]$K8S_APISERVER_CA = "", + [string]$ADMIN_GROUP = "", + [string]$OUTPUT = "" +) + +$CURRENT_DIR = Split-Path $MyInvocation.MyCommand.Path +$TPL_PATH = "${CURRENT_DIR}\..\resources\installation-config.yaml.tpl" + +Copy-Item -Path $TPL_PATH -Destination $OUTPUT + +(Get-Content $OUTPUT).replace("__EXTERNAL_IP_ADDRESS__", $IP_ADDRESS) | Set-Content $OUTPUT +(Get-Content $OUTPUT).replace("__DOMAIN__", $DOMAIN) | Set-Content $OUTPUT +(Get-Content $OUTPUT).replace("__REMOTE_ENV_IP__", $REMOTE_ENV_IP) | Set-Content $OUTPUT +(Get-Content $OUTPUT).replace("__K8S_APISERVER_URL__", $K8S_APISERVER_URL) | Set-Content $OUTPUT +(Get-Content $OUTPUT).replace("__K8S_APISERVER_CA__", $K8S_APISERVER_CA) | Set-Content $OUTPUT +(Get-Content $OUTPUT).replace("__ADMIN_GROUP__", $ADMIN_GROUP) | Set-Content $OUTPUT diff --git a/installation/scripts/create-config-map.sh b/installation/scripts/create-config-map.sh new file mode 100755 index 000000000000..4b50d4496cb7 --- /dev/null +++ b/installation/scripts/create-config-map.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +set -o errexit + +CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +TPL_PATH="$CURRENT_DIR/../resources/installation-config.yaml.tpl" + +EXTERNAL_IP_ADDRESS="" +DOMAIN="" +REMOTE_ENV_IP="" +K8S_APISERVER_URL="" +K8S_APISERVER_CA="" +ADMIN_GROUP="" + +POSITIONAL=() +while [[ $# -gt 0 ]] +do + key="$1" + + case ${key} in + --ip-address) + EXTERNAL_IP_ADDRESS="$2" + shift # past argument + shift # past value + ;; + --domain) + DOMAIN="$2" + shift # past argument + shift # past value + ;; + --remote-env-ip) + REMOTE_ENV_IP="$2" + shift # past argument + shift # past value + ;; + --k8s-apiserver-url) + K8S_APISERVER_URL="$2" + shift # past argument + shift # past value + ;; + --k8s-apiserver-ca) + K8S_APISERVER_CA="$2" + shift # past argument + shift # past value + ;; + --admin-group) + ADMIN_GROUP="$2" + shift # past argument + shift # past value + ;; + --output) + OUTPUT="$2" + shift + shift + ;; + *) # unknown option + POSITIONAL+=("$1") # save it in an array for later + shift # past argument + ;; + esac +done +set -- "${POSITIONAL[@]}" # restore positional parameters + +cp $TPL_PATH $OUTPUT + +case `uname -s` in + Darwin) + sed -i "" "s;__EXTERNAL_IP_ADDRESS__;${EXTERNAL_IP_ADDRESS};" "$OUTPUT" + sed -i "" "s;__DOMAIN__;${DOMAIN};" "$OUTPUT" + sed -i "" "s;__REMOTE_ENV_IP__;${REMOTE_ENV_IP};" "$OUTPUT" + sed -i "" "s;__K8S_APISERVER_URL__;${K8S_APISERVER_URL};" "$OUTPUT" + sed -i "" "s;__K8S_APISERVER_CA__;${K8S_APISERVER_CA};" "$OUTPUT" + sed -i "" "s;__ADMIN_GROUP__;${ADMIN_GROUP};" "$OUTPUT" + ;; + *) + sed -i "s;__EXTERNAL_IP_ADDRESS__;${EXTERNAL_IP_ADDRESS};g" "$OUTPUT" + sed -i "s;__DOMAIN__;${DOMAIN};g" "$OUTPUT" + sed -i "s;__REMOTE_ENV_IP__;${REMOTE_ENV_IP};g" "$OUTPUT" + sed -i "s;__K8S_APISERVER_URL__;${K8S_APISERVER_URL};g" "$OUTPUT" + sed -i "s;__K8S_APISERVER_CA__;${K8S_APISERVER_CA};g" "$OUTPUT" + sed -i "s;__ADMIN_GROUP__;${ADMIN_GROUP};g" "$OUTPUT" + ;; +esac diff --git a/installation/scripts/create-cr.ps1 b/installation/scripts/create-cr.ps1 new file mode 100644 index 000000000000..09bf54c372b3 --- /dev/null +++ b/installation/scripts/create-cr.ps1 @@ -0,0 +1,13 @@ +param ( + [string]$URL = "", + [string]$OUTPUT = "", + [string]$VERSION = "0.0.1" +) + +$CURRENT_DIR = Split-Path $MyInvocation.MyCommand.Path +$CRTPL_PATH = "${CURRENT_DIR}\..\resources\installer-cr.yaml.tpl" + +Copy-Item -Path $CRTPL_PATH -Destination $OUTPUT + +(Get-Content $OUTPUT).replace("__VERSION__", $VERSION) | Set-Content $OUTPUT +(Get-Content $OUTPUT).replace("__URL__", $URL) | Set-Content $OUTPUT diff --git a/installation/scripts/create-cr.sh b/installation/scripts/create-cr.sh new file mode 100755 index 000000000000..9f74dbf69844 --- /dev/null +++ b/installation/scripts/create-cr.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +set -o errexit + +CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +CRTPL_PATH="$CURRENT_DIR/../resources/installer-cr.yaml.tpl" + +POSITIONAL=() +while [[ $# -gt 0 ]] +do + key="$1" + + case ${key} in + --url) + URL="$2" + shift # past argument + shift # past value + ;; + --output) + OUTPUT="$2" + shift + shift + ;; + --version) + VERSION="$2" + shift + shift + ;; + *) # unknown option + POSITIONAL+=("$1") # save it in an array for later + shift # past argument + ;; + esac +done +set -- "${POSITIONAL[@]}" # restore positional parameters + +cp $CRTPL_PATH $OUTPUT + +case `uname -s` in + Darwin) + sed -i "" "s/__VERSION__/${VERSION}/" "$OUTPUT" + sed -i "" "s;__URL__;${URL};" "$OUTPUT" + ;; + *) + sed -i "s/__VERSION__/${VERSION}/g" "$OUTPUT" + sed -i "s;__URL__;${URL};g" "$OUTPUT" + ;; +esac diff --git a/installation/scripts/create-generic-secret.sh b/installation/scripts/create-generic-secret.sh new file mode 100755 index 000000000000..4bd3bc35cb12 --- /dev/null +++ b/installation/scripts/create-generic-secret.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +set -o errexit + +CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +function fillTemplate { + local OUTPUT="$1" + local KEY="$2" + local VALUE="$3" + local VALUE_BASE64=$(echo -n "${VALUE}" | base64 | tr -d '\n') + + case `uname -s` in + Darwin) + sed -i "" "s;__${KEY}__;${VALUE_BASE64};" "${OUTPUT}" + ;; + *) + sed -i "s;__${KEY}__;${VALUE_BASE64};g" "${OUTPUT}" + ;; + esac +} + +OUTPUT="$1" +shift + +TPL_PATH="${OUTPUT}.tpl" +cp $TPL_PATH $OUTPUT + +while [[ $# -gt 0 ]] +do + KEY="$1" + VALUE="$2" + + fillTemplate "${OUTPUT}" "${KEY}" "${VALUE}" + shift + shift +done diff --git a/installation/scripts/cut.sh b/installation/scripts/cut.sh new file mode 100755 index 000000000000..df9c43b1283c --- /dev/null +++ b/installation/scripts/cut.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +OPERATION="" +DELIMITER="" +ORIGINAL_TEXT="" + +while [[ $# -gt 0 ]] ; do + case "$1" in + --prefix) + OPERATION="PREFIX" + shift + ;; + --suffix) + OPERATION="SUFFIX" + shift + ;; + --delimiter | -d) + DELIMITER=$2 + shift + shift + ;; + *) + break + ;; + esac +done + +if [[ -z $DELIMITER ]] ; then + (>&2 echo "Delimiter not provided") + exit 1 +fi + +ORIGINAL_TEXT=$1 +if [[ -z $ORIGINAL_TEXT ]]; then + (>&2 echo "Text to cut not provided") + exit 1 +fi + +if [[ "$OPERATION" = "PREFIX" ]]; then + echo "${ORIGINAL_TEXT#*$DELIMITER}" # removes prefix ending with delimiter +elif [[ "$OPERATION" = "SUFFIX" ]]; then + echo "${ORIGINAL_TEXT%$DELIMITER*}" # removes suffix starting with delimiter +else + (>&2 echo "Operation not specified. Please provide flag \"--prefix\" or \"--suffix\"") + exit 1 +fi \ No newline at end of file diff --git a/installation/scripts/docker-start.sh b/installation/scripts/docker-start.sh new file mode 100755 index 000000000000..3e4e826561e3 --- /dev/null +++ b/installation/scripts/docker-start.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -o errexit + +service docker start \ No newline at end of file diff --git a/installation/scripts/generate-cluster-config.sh b/installation/scripts/generate-cluster-config.sh new file mode 100755 index 000000000000..91847bbff587 --- /dev/null +++ b/installation/scripts/generate-cluster-config.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +set -e + +ROOT_PATH=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) + +KYMA_PUBLIC_IP_NAME="KYMA-ingress-public-ip" +KYMA_REMOTE_ENV_IP_NAME="KYMA-nginx-ingress-public-ip" + +FULL_DOMAIN=${DOMAIN_PREFIX}.${yBaseDomain} +VAULT_SECRET_NAME_PREFIX=$(echo "$FULL_DOMAIN"| tr '.' '-') +CERT_PATH="/etc/acme/data" +CERT_SAN="*.$FULL_DOMAIN" +PUBLIC_IP="" +#FULL_DOMAIN already set +REMOTE_ENV_IP="" +K8S_APISERVER_URL="" +K8S_APISERVER_CA="" +#SCI_TENANT comes from environment +#SCI_DOMAIN comes from environment +#SCI_CA_PEM comes from environment +#ADMIN_GROUP comes from environment +#ARM_CLIENT_ID comes from environment +#ARM_CLIENT_SECRET comes from environment +#ARM_TENANT_ID comes from environment +#AZURE_SUBSCIPTION_ID comes from environment +#VAULT_NAME comes from environment +#AZURE_BROKER_SUBSCRIPTION_ID comes from environment +#AZURE_BROKER_TENANT_ID comes from environment +#AZURE_BROKER_CLIENT_ID comes from environment +#AZURE_BROKER_CLIENT_SECRET comes from environment +#KYMA_RELEASES_AZURE_BLOBSTORE_KEY comes from environment +#UI_TEST_USER comes from environment +#UI_TEST_PASSWORD comes from environment + +function azure_login { + az login --service-principal -u ${ARM_CLIENT_ID} -p ${ARM_CLIENT_SECRET} --tenant ${ARM_TENANT_ID} > /dev/null + az account set --subscription ${AZURE_SUBSCIPTION_ID} > /dev/null +} + +function fetchPublicIP { + echo -e "\nFetching public IP" + + local RG_NAME="kyma-cluster-$FULL_DOMAIN" + + PUBLIC_IP=$(az network public-ip show --resource-group ${RG_NAME} --name ${KYMA_PUBLIC_IP_NAME} | jq -r .ipAddress) + if [[ -z "$PUBLIC_IP" ]] + then + echo -e "\nNo public IP with name: ${KYMA_PUBLIC_IP_NAME} found in resource group: $RG_NAME" + exit 1 + fi + + REMOTE_ENV_IP=$(az network public-ip show --resource-group ${RG_NAME} --name ${KYMA_REMOTE_ENV_IP_NAME} | jq -r .ipAddress) + if [[ -z "$REMOTE_ENV_IP" ]] + then + echo -e "\nNo public IP with name: ${KYMA_REMOTE_ENV_IP_NAME} found in resource group: $RG_NAME" + exit 1 + fi +} + +function parseYaml { + local prefix=$2 + local s='[[:space:]]*' w='[a-zA-Z0-9_-]*' fs=$(echo @|tr @ '\034') + sed -ne "s|^\($s\):|\1|" \ + -e "s|^\($s\)\($w\)$s:$s[\"']\(.*\)[\"']$s\$|\1$fs\2$fs\3|p" \ + -e "s|^\($s\)\($w\)$s:$s\(.*\)$s\$|\1$fs\2$fs\3|p" $1 | + awk -F$fs '{ + indent = length($1)/2; + vname[indent] = $2; + for (i in vname) {if (i > indent) {delete vname[i]}} + if (length($3) > 0) { + vn=""; for (i=0; i "${crtFile}" + + log "DEX TLS certificate saved in the container: ${crtFile}" green +} + +#TODO refactor to use minikube status! +function waitForMinikubeToBeUp() { + set +o errexit + + log "Waiting for minikube to be up..." green + + LIMIT=15 + COUNTER=0 + + while [ ${COUNTER} -lt ${LIMIT} ] && [ -z "$STATUS" ]; do + (( COUNTER++ )) + log "Keep calm, there are $LIMIT possibilities and so far it is attempt number $COUNTER" green + STATUS="$(kubectl get namespaces || :)" + sleep 1 + done + + # In case apiserver is not available get localkube logs + if [[ -z "$STATUS" ]] && [[ "$VM_DRIVER" = "none" ]]; then + cat /var/lib/localkube/localkube.err + fi + + set -o errexit + + log "Minikube is up" green +} + +# After upgrade to minikube v0.24.1, our CI plans (use dind to start +# kyma) are not able to pull images from internet +function fixDindMinikubeIssue() { + echo "nameserver 8.8.8.8" >> /etc/resolv.conf +} + +function checkIfMinikubeIsInitialized() { + local status=$(minikube status --format "{{.MinikubeStatus}}") + if [ -n "${status}" ]; then + log "Minikube is already initialized" red + read -p "Do you want to remove previous minikube cluster [y/N]: " deleteMinikube + if [ "${deleteMinikube}" == "y" ]; then + minikube delete + else + log "Starting minikube cancelled" red + exit -1 + fi + fi +} + +function checkMinikubeVersion() { + local version=$(minikube version | awk '{print $3}') + + if [[ "${version}" != *"${MINIKUBE_VERSION}"* ]]; then + echo "Your minikube is in v${version}. v${MINIKUBE_VERSION} is supported version of minikube. Install supported version!" + exit -1 + fi +} + +function addDevDomainsToEtcHosts() { + local hostnames=$1 + local minikubeIP=$(minikube ip) + + log "Minikube IP address: ${minikubeIP}" green + + if [[ "$VM_DRIVER" != "none" ]]; then + log "Adding ${hostnames} to /etc/hosts on Minikube" yellow + minikube ssh "echo \"127.0.0.1 ${hostnames}\" | sudo tee -a /etc/hosts" + + # Delete old host alias + case `uname -s` in + Darwin) + sudo sed -i '' "/${MINIKUBE_DOMAIN}/d" /etc/hosts + ;; + *) + sudo sed -i "/${MINIKUBE_DOMAIN}/d" /etc/hosts + ;; + esac + fi + + log "Adding ${hostnames} to /etc/hosts on localhost" yellow + local hostAlias="${minikubeIP} ${hostnames}" + + #Set new host alias + echo ${hostAlias} | sudo tee -a /etc/hosts > /dev/null + + log "Domain added to /etc/hosts" green +} + +function start() { + checkMinikubeVersion + + checkIfMinikubeIsInitialized + + initializeMinikubeConfig + + uploadDexTlsCertForApiserver + + if [[ "$VM_DRIVER" = "none" ]]; then + fixDindMinikubeIssue + fi + + minikube start \ + --memory 8192 \ + --cpus 4 \ + --extra-config=apiserver.Authorization.Mode=RBAC \ + --extra-config=apiserver.Authentication.OIDC.IssuerURL="https://dex.${MINIKUBE_DOMAIN}" \ + --extra-config=apiserver.Authentication.OIDC.CAFile=/dex-ca.crt \ + --extra-config=apiserver.Authentication.OIDC.ClientID=kyma-client \ + --extra-config=apiserver.Authentication.OIDC.UsernameClaim=email \ + --extra-config=apiserver.Authentication.OIDC.GroupsClaim=groups \ + --extra-config=apiserver.GenericServerRunOptions.CorsAllowedOriginList=".*" \ + --extra-config=controller-manager.ClusterSigningCertFile="/var/lib/localkube/certs/ca.crt" \ + --extra-config=controller-manager.ClusterSigningKeyFile="/var/lib/localkube/certs/ca.key" \ + --extra-config=apiserver.Admission.PluginNames="Initializers,NamespaceLifecycle,LimitRanger,ServiceAccount,DefaultStorageClass,DefaultTolerationSeconds,MutatingAdmissionWebhook,ValidatingAdmissionWebhook,ResourceQuota,PodPreset,PersistentVolumeLabel" \ + --kubernetes-version=v$KUBERNETES_VERSION \ + --vm-driver=$VM_DRIVER \ + --feature-gates="MountPropagation=false" \ + -b=localkube + + waitForMinikubeToBeUp + + # Adding domains to /etc/hosts files + addDevDomainsToEtcHosts "apiserver.${MINIKUBE_DOMAIN} console.${MINIKUBE_DOMAIN} catalog.${MINIKUBE_DOMAIN} instances.${MINIKUBE_DOMAIN} dex.${MINIKUBE_DOMAIN} docs.${MINIKUBE_DOMAIN} lambdas-ui.${MINIKUBE_DOMAIN} ui-api.${MINIKUBE_DOMAIN} minio.${MINIKUBE_DOMAIN} jaeger.${MINIKUBE_DOMAIN} grafana.${MINIKUBE_DOMAIN} configurations-generator.${MINIKUBE_DOMAIN} gateway.${MINIKUBE_DOMAIN} connector-service.${MINIKUBE_DOMAIN}" +} + +start + diff --git a/installation/scripts/replace-placeholder.ps1 b/installation/scripts/replace-placeholder.ps1 new file mode 100644 index 000000000000..f1e83a985121 --- /dev/null +++ b/installation/scripts/replace-placeholder.ps1 @@ -0,0 +1,7 @@ +param ( + [string]$PATH, + [string]$PLACEHOLDER, + [string]$VALUE +) + +(Get-Content $PATH).replace("${PLACEHOLDER}", ${VALUE}) | Set-Content $PATH \ No newline at end of file diff --git a/installation/scripts/testing.sh b/installation/scripts/testing.sh new file mode 100755 index 000000000000..e4641612d2fc --- /dev/null +++ b/installation/scripts/testing.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +ROOT_PATH=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) +source ${ROOT_PATH}/utils.sh + +function printLogsFromFailedHelmTests() { + local namespace=$1 + + for POD in $(kubectl get pods -n ${namespace} -l helm-chart-test=true --show-all -o jsonpath='{.items[*].metadata.name}') + do + log "Testing '${POD}'" nc bold + + phase=$(kubectl get pod ${POD} -n ${namespace} -o jsonpath="{ .status.phase }") + + case ${phase} in + "Failed") + log "'${POD}' has Failed status" red + printLogsFromPod ${namespace} ${POD} + ;; + "Running") + log "'${POD}' failed due to too long Running status" red + printLogsFromPod ${namespace} ${POD} + ;; + "Pending") + log "'${POD}' failed due to too long Pending status" red + printf "Fetching events from '${POD}':\n" + kubectl describe po ${POD} -n ${namespace} | awk 'x==1 {print} /Events:/ {x=1}' + ;; + "Unknown") + log "'${POD}' failed with Unknown status" red + printLogsFromPod ${namespace} ${POD} + ;; + "Succeeded") + echo "Test of '${POD}' was successful" + echo "Logs are not displayed after success" + ;; + *) + log "Unknown status of '${POD}' - ${phase}" red + printLogsFromPod ${namespace} ${POD} + ;; + esac + log "End of testing '${POD}'\n" nc bold + done +} + +function printLogsFromPod() { + local namespace=$1 pod=$2 + + log "Fetching logs from '${pod}'" nc bold + result=$(kubectl logs -n ${namespace} ${pod}) + if [ "${#result}" -eq 0 ]; then + log "FAILED" red + return 1 + fi + echo "${result}" +} + +function checkTestPodTerminated() { + local namespace=$1 + + runningPods=false + + for POD in $(kubectl get pods -n ${namespace} -l helm-chart-test=true --show-all -o jsonpath='{.items[*].metadata.name}') + do + phase=$(kubectl get pod "$POD" -n ${namespace} -o jsonpath="{ .status.phase }") + # A Pod's phase Failed or Succeeded means pod has terminated. + # see: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-phase + if [ "${phase}" != "Succeeded" ] && [ "${phase}" != "Failed" ] + then + log "Test pod '${POD}' has not terminated, pod phase: ${phase}" red + runningPods=true + fi + done + + if [ ${runningPods} = true ]; + then + return 1 + fi +} + +function checkTestPodLabel() { + local namespace=$1 + + err=false + + log "Test pods should be marked with label 'helm-chart-test=true'. Checking..." nc bold + for POD in $(kubectl get pods -n ${namespace} --show-all -o jsonpath='{.items[*].metadata.name}') + do + annotation=$(kubectl get pod "$POD" -n ${namespace} -o jsonpath="{ .metadata.annotations.helm\.sh/hook }") + if [ "${annotation}" == "test-success" ] || [ "${annotation}" == "test-failure" ] + then + helmLabel=$(kubectl get pod "${POD}" -n ${namespace} -o jsonpath="{ .metadata.labels.helm-chart-test }" ) + if [ "${helmLabel}" != "true" ]; + then + err=true + log "Pod ${POD} is wrongly labeled" red + fi + fi + done + + if [ ${err} = true ]; + then + log "FAILED" red + return 1 + fi + log "OK" green bold +} + +function cleanupHelmTestPods() { + local namespace=$1 + + log "\nCleaning up helm test pods" nc bold + kubectl delete pod -n ${namespace} -l helm-chart-test=true + log "End of cleaning test pods.\n" nc bold +} + +function waitForTestPodsTermination() { + local retry=0 + local namespace=$1 + + log "All test pods should be terminated. Checking..." nc bold + while [ ${retry} -lt 3 ]; do + checkTestPodTerminated ${namespace} + checkTestPodTerminatedErr=$? + if [ ${checkTestPodTerminatedErr} -ne 0 ]; then + echo "Waiting for test pods to terminate..." + sleep 1 + else + log "OK" green bold + return 0 + fi + retry=$[retry + 1] + done + log "FAILED" red + return 1 +} + +function checkAndCleanupTest() { + local namespace=$1 + + waitForTestPodsTermination ${namespace} + checkTestPodTerminatedErr=$? + + printLogsFromFailedHelmTests ${namespace} + + checkTestPodLabel ${namespace} + checkTestPodLabelErr=$? + + cleanupHelmTestPods ${namespace} + + if [ ${checkTestPodTerminatedErr} -ne 0 ] || [ ${checkTestPodLabelErr} -ne 0 ] + then + return 1 + fi +} + +function printImagesWithLatestTag(){ + + # We ignore the alpine image as this is required by istio-sidecar + local images=$(kubectl get pods --all-namespaces -o jsonpath="{..image}" |\ + tr -s '[[:space:]]' '\n' |\ + grep ":latest" | grep -v "alpine:latest") + + log "Images with tag latest are not allowed. Checking..." nc bold + if [ ${#images} -ne 0 ]; then + log "${images}" red + log "FAILED" red + return 1 + fi + log "OK" green bold + return 0 +} + + + +echo "----------------------------" +echo "- Testing Kyma..." +echo "----------------------------" + +echo "- Testing Core components..." +# timeout set to 10 minutes +helm test core --timeout 600 +coreTestErr=$? + +checkAndCleanupTest kyma-system +testCheckCore=$? + +echo "- Testing Istio components..." +helm test istio +istioTestErr=$? + +checkAndCleanupTest istio-system +testCheckIstio=$? + +echo "- Testing Remote Environments" +helm test ec-default +helm test hmc-default +gatewayTestErr=$? + +checkAndCleanupTest kyma-integration +testCheckGateway=$? + +printImagesWithLatestTag +latestTagsErr=$? + +if [ ${latestTagsErr} -ne 0 ] || [ ${coreTestErr} -ne 0 ] || [ ${istioTestErr} -ne 0 ] || [ ${gatewayTestErr} -ne 0 ] +then + exit 1 +else + exit 0 +fi diff --git a/installation/scripts/update.sh b/installation/scripts/update.sh new file mode 100755 index 000000000000..42562eafc1f9 --- /dev/null +++ b/installation/scripts/update.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +set -o errexit + +CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +LOCAL=0 + +POSITIONAL=() +while [[ $# -gt 0 ]] +do + key="$1" + + case ${key} in + --local) + LOCAL=1 + shift + ;; + --cr-name) + CR_NAME="$2" + shift # past argument + shift # past value + ;; + *) # unknown option + POSITIONAL+=("$1") # save it in an array for later + shift # past argument + ;; + esac +done +set -- "${POSITIONAL[@]}" # restore positional parameters + +if [ -z "$CR_NAME" ]; then + echo "[ERR] Please provide installation custom resource name via --cr-name parameter" + exit 1 +fi + +if [ $LOCAL -eq 1 ]; then + bash $CURRENT_DIR/copy-resources.sh +fi + +kubectl label installation/${CR_NAME} action=update --overwrite \ No newline at end of file diff --git a/installation/scripts/utils.sh b/installation/scripts/utils.sh new file mode 100644 index 000000000000..3ecbbcc8f5f0 --- /dev/null +++ b/installation/scripts/utils.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash + +# +# Log is function useful for logs creation. +# It accepts three arguments: +# $1 - text we want to print +# $2 - text color +# $3 - text style +# It will create single log with defined color and font style. +# To specify style without color we have to put 'nc' before style. +# For example: +# log "gophers" magenta bold - will print bold 'gophers' in magenta color. +# log "text" bold - it will print normal text. +# log "text" nc bold - it will print bold text. +# By default log will print normal text like echo command. +# Use source [utils.sh path] to import log function into your script. +# + +function log() { + local exp=$1; + local color=$2; + local style=$3; + local NC='\033[0m' + if ! [[ ${color} =~ '^[0-9]$' ]] ; then + case $(echo ${color} | tr '[:upper:]' '[:lower:]') in + black) color='\e[30m' ;; + red) color='\e[31m' ;; + green) color='\e[32m' ;; + yellow) color='\e[33m' ;; + blue) color='\e[34m' ;; + magenta) color='\e[35m' ;; + cyan) color='\e[36m' ;; + white) color='\e[37m' ;; + nc|*) color=${NC} ;; # no color or invalid color + esac + fi + if ! [[ ${style} =~ '^[0-9]$' ]] ; then + case $(echo ${style} | tr '[:upper:]' '[:lower:]') in + bold) style='\e[1m' ;; + underline) style='\e[4m' ;; + inverted) style='\e[7m' ;; + *) style="" ;; # no style or invalid style + esac + fi + printf "${color}${style}${exp}${NC}\n" +} + +# +# showFailedResources is function to print some logs and pods details +# It accepts on argument: +# $1 - namespace from which we want to get details +# It will +# - display list of pods and PVCs, +# - describe pods with status != running +# - display logs for not running pods +# - describe PVCs with status != Bound +# + +function showFailedResources { + local ns=$1 + kubectl get pods,pvc -n $1 -o wide + + notRunningPods=($(kubectl get pods -n $1 -o=custom-columns=NAME:.metadata.name,STATUS:.status.phase | grep -v Running | awk '{if(NR>1)print $1}')) + if [[ -n ${notRunningPods-} ]]; + then + for i in "${notRunningPods[@]}" + do + echo "======================================================================================" + echo "kubectl describe pod $i" + echo "======================================================================================" + kubectl describe pod $i -n $1 + containers=($(kubectl get pods -n $1 $i -o jsonpath='{range .status.containerStatuses[*]}{.name}{"\t"}{.ready}{"\n"}' | grep "\ttrue" | awk '{if(NR>1)print $1}')) + if [[ -n ${containers-} ]]; + then + for j in "${containers[@]}" + do + echo "==========" + echo "kubectl logs $i -c $j" + echo "==========" + kubectl logs -n $1 --tail=100 $i -c $j + done + fi + done + fi + + notBoundPvc=($(kubectl get pvc -n $1 -o=custom-columns=NAME:.metadata.name,STATUS:.status.phase | grep -v Bound | awk '{if(NR>1)print $1}')) + if [[ -n ${notBoundPvc-} ]]; + then + for i in "${notBoundPvc[@]}" + do + echo "======================================================================================" + echo "kubectl describe pvc $i" + echo "======================================================================================" + kubectl describe pvc $i -n $1 + done + fi +} \ No newline at end of file diff --git a/installation/scripts/watch-pods.sh b/installation/scripts/watch-pods.sh new file mode 100755 index 000000000000..c98d47b70d81 --- /dev/null +++ b/installation/scripts/watch-pods.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +set -o errexit # exit immediately if a command exits with a non-zero status. +set -o nounset # exit when script tries to use undeclared variables + +CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +WORKING_NAMESPACE="kyma-system" + +echo "Run stability test..." +kubectl create configmap pod-watch-config -n ${WORKING_NAMESPACE} --from-literal="ARGS=-maxWaitingPeriod=10m -ignorePodsPattern=core-azure-broker-docs-*|kyma-release-adder-*" >/dev/null +kubectl apply -f ${CURRENT_DIR}/../resources/watch-pods.yaml >/dev/null +${CURRENT_DIR}/../scripts/is-ready.sh ${WORKING_NAMESPACE} app watch-pods + +kubectl logs pod/watch-pods --timestamps --follow -n ${WORKING_NAMESPACE} +STATUS="$(kubectl get pods watch-pods -o jsonpath='{.status.containerStatuses[*].lastState.terminated.exitCode}' -n ${WORKING_NAMESPACE})" +echo "Resulting exit status of stability test is: $STATUS" + +kubectl delete -f ${CURRENT_DIR}/../resources/watch-pods.yaml >/dev/null +kubectl delete configmap pod-watch-config -n ${WORKING_NAMESPACE} >/dev/null + +exit ${STATUS} diff --git a/logo.png b/logo.png new file mode 100755 index 0000000000000000000000000000000000000000..dc74682226277d3561cf053ea978ecbf6348e9e3 GIT binary patch literal 29008 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 b7JvcDvmzj-oxf8YMd*i6*Sl4%YWw7WYQ6Bz literal 0 HcmV?d00001 diff --git a/orchestrator.Jenkinsfile b/orchestrator.Jenkinsfile new file mode 100644 index 000000000000..a22a5abbbb97 --- /dev/null +++ b/orchestrator.Jenkinsfile @@ -0,0 +1,206 @@ +#!/usr/bin/env groovy +/* + +Monorepo root orchestrator: This Jenkinsfile runs the Jenkinsfiles of all subprojects based on the changes made and triggers kyma integration. + - checks for changes since last successful build on master and compares to master if on a PR. + - for every changed project, triggers related job async as configured in the seedjob. + - for every changed additional project, triggers the kyma integration job. + - passes info of: + - revision + - branch + - current app version + +*/ +def label = "kyma-${UUID.randomUUID().toString()}" +appVersion = "0.2." + env.BUILD_NUMBER + +/* + Projects that are built when changed. + IMPORTANT NOTE: Projects trigger jobs and therefore are expected to have a job defined with the same name. +*/ +projects = [ + "docs", + "components/api-controller", + "components/binding-usage-controller", + "components/configurations-generator", + "components/environments", + "components/istio-webhook", + "components/helm-broker", + "components/remote-environment-broker", + "components/application-connector", + "components/gateway", + "components/installer", + "components/connector-service", + "components/ui-api-layer", + "components/event-bus", + "tools/alpine-net", + "tools/watch-pods", + "tools/stability-checker", + "tests/test-logging-monitoring", + "tests/acceptance", + "tests/ui-api-layer-acceptance-tests", + "tests/application-connector-tests", + "tests/gateway-tests", + "tests/test-environments", + "tests/kubeless-test-client", + "tests/api-controller-acceptance-tests", + "tests/connector-service-tests", + "tests/event-bus", + "governance" +] + +/* + Projects that are NOT built when changed, but do trigger the kyma integration job. +*/ +additionalProjects = ["resources","cluster","installation"] + +/* + project jobs to run are stored here to be sent into the parallel block outside the node executor. +*/ +jobs = [:] + +/* + If true, Kyma integration will run at the end. + NOTE: This is set automaticlly based on the changes detected. +*/ +runIntegration = false + +properties([ + buildDiscarder(logRotator(numToKeepStr: '10')), + disableConcurrentBuilds() +]) + +podTemplate(label: label) { + node(label) { + try { + timestamps { + ansiColor('xterm') { + stage("setup") { + checkout scm + // use HEAD of branch as revision, Jenkins does a merge to master commit before starting this script, which will not be available on the jobs triggered below + commitID = sh (script: "git rev-parse origin/${env.BRANCH_NAME}", returnStdout: true).trim() + changes = changedProjects() + runIntegration = changes.size() > 0 + if (changes.size() == 1 && changes[0] == "governance") { + runIntegration = false + } + } + + stage('collect projects') { + buildableProjects = changes.intersect(projects) // only projects that have build jobs + echo "Collected the following projects with changes: $buildableProjects..." + for (int i=0; i < buildableProjects.size(); i++) { + def index = i + jobs["${buildableProjects[index]}"] = { -> + build job: "kyma/"+buildableProjects[index], + wait: true, + parameters: [ + string(name:'GIT_REVISION', value: "$commitID"), + string(name:'GIT_BRANCH', value: "${env.BRANCH_NAME}"), + string(name:'APP_VERSION', value: "$appVersion") + ] + } + } + } + } + } + } catch (ex) { + echo "Got exception: ${ex}" + currentBuild.result = "FAILURE" + def body = "${currentBuild.currentResult} ${env.JOB_NAME}${env.BUILD_DISPLAY_NAME}: on branch: ${env.BRANCH_NAME}. See details: ${env.BUILD_URL}" + emailext body: body, recipientProviders: [[$class: 'DevelopersRecipientProvider'], [$class: 'CulpritsRecipientProvider'], [$class: 'RequesterRecipientProvider']], subject: "${currentBuild.currentResult}: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'" + } + } +} + +// trigger jobs for projects that have changes, in parallel +stage('build projects') { + parallel jobs +} + +// trigger Kyma integration when changes are made to installation charts/code or resources +if (runIntegration) { + stage('launch Kyma integration') { + build job: 'kyma/integration', + wait: false, + parameters: [ + string(name:'GIT_REVISION', value: "$commitID"), + string(name:'GIT_BRANCH', value: "${env.BRANCH_NAME}"), + string(name:'APP_VERSION', value: "$appVersion") + ] + } +} + + + +/* -------- Helper Functions -------- */ + +/** + * Provides a list with the projects that have changes within the given projects list. + * If no changes found, all projects will be returned. + */ +String[] changedProjects() { + res = [] + def allProjects = projects + additionalProjects + echo "Looking for changes in the following projects: $allProjects." + + // get all changes + allChanges = changeset().split("\n") + + // if no changes build all projects + if (allChanges.size() == 0) { + echo "No changes found or could not be fetched, triggering all projects." + return allProjects + } + + // parse changeset and keep only relevant folders -> match with projects defined + for (int i=0; i < allProjects.size(); i++) { + for (int j=0; j < allChanges.size(); j++) { + if (allChanges[j].startsWith(allProjects[i]) && changeIsValidFileType(allChanges[j],allProjects[i]) && !res.contains(allProjects[i])) { + res.add(allProjects[i]) + break // already found a change in the current project, no need to continue iterating the changeset + } + if (projects[i] == "governance" && allChanges[j].endsWith(".md") && !res.contains(projects[i])) { + res.add(projects[i]) + break // already found a change in one of the .md files, no need to continue iterating the changeset + } + } + } + + return res +} + +boolean changeIsValidFileType(String change, String project){ + return !change.endsWith(".md") || "docs".equals(project); +} + +/** + * Gets the changes on the Project based on the branch or an empty string if changes could not be fetched. + */ +@NonCPS +String changeset() { + // on branch get changeset comparing with master + if (env.BRANCH_NAME != "master") { + echo "Fetching changes between origin/${env.BRANCH_NAME} and origin/master." + return sh (script: "git --no-pager diff --name-only origin/master...origin/${env.BRANCH_NAME} | grep -v 'vendor\\|node_modules' || echo ''", returnStdout: true) + } + // on master get changeset since last successful commit + else { + echo "Fetching changes on master since last successful build." + def successfulBuild = currentBuild.rawBuild.getPreviousSuccessfulBuild() + if (successfulBuild) { + def commit = commitHashForBuild(successfulBuild) + return sh (script: "git --no-pager diff --name-only $commit 2> /dev/null | grep -v 'vendor\\|node_modules' || echo ''", returnStdout: true) + } + } + return "" +} + +/** + * Gets the commit hash from a Jenkins build object + */ +@NonCPS +def commitHashForBuild(build) { + def scmAction = build?.actions.find { action -> action instanceof jenkins.scm.api.SCMRevisionAction } + return scmAction?.revision?.hash +} diff --git a/resources/README.md b/resources/README.md new file mode 100644 index 000000000000..04152e543919 --- /dev/null +++ b/resources/README.md @@ -0,0 +1,21 @@ +``` + _____ + | __ \ + | |__) |___ ___ ___ _ _ _ __ ___ ___ ___ + | _ // _ \/ __|/ _ \| | | | '__/ __/ _ \/ __| + | | \ \ __/\__ \ (_) | |_| | | | (_| __/\__ \ + |_| \_\___||___/\___/ \__,_|_| \___\___||___/ + +``` + +## Overview + +Resources are all components in Kyma that are available for local and cluster installation. You can find more details about each component in the corresponding README.md files. + +Resources currently include, but are not limited to, the following: + +- Elements which are essential for the installation of `core` components in Kyma, such as certificates, users, and permissions +- The `core` components required to run the Kyma +- Examples of the use of specific components +- The repository of bundles that are exposed in the Service Catalog as Service Classes through the Helm Broker +- Scripts for the installation of Helm, Istio deployment, as well as scripts for validating Pods, starting Kyma, and testing diff --git a/resources/cluster-essentials/.helmignore b/resources/cluster-essentials/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/cluster-essentials/.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/resources/cluster-essentials/Chart.yaml b/resources/cluster-essentials/Chart.yaml new file mode 100644 index 000000000000..8f63733ca53e --- /dev/null +++ b/resources/cluster-essentials/Chart.yaml @@ -0,0 +1,5 @@ +name: kyma-cluster-essentials +version: 0.0.1 +description: Kyma +keywords: + - kyma diff --git a/resources/cluster-essentials/README.md b/resources/cluster-essentials/README.md new file mode 100644 index 000000000000..2e5afc23e039 --- /dev/null +++ b/resources/cluster-essentials/README.md @@ -0,0 +1,14 @@ + +``` + _____ _ _ ______ _ _ _ + / ____| | | | | ____| | | (_) | | + | | | |_ _ ___| |_ ___ _ __ | |__ ___ ___ ___ _ __ | |_ _ __ _| |___ + | | | | | | / __| __/ _ \ '__| | __| / __/ __|/ _ \ '_ \| __| |/ _` | / __| + | |____| | |_| \__ \ || __/ | | |____\__ \__ \ __/ | | | |_| | (_| | \__ \ + \_____|_|\__,_|___/\__\___|_| |______|___/___/\___|_| |_|\__|_|\__,_|_|___/ + +``` + +## Overview + +The `cluster-essentials` folder contains chart with essential resources for the installation of the `core` components in Kyma. diff --git a/resources/cluster-essentials/templates/environment-mapping.crd.yaml b/resources/cluster-essentials/templates/environment-mapping.crd.yaml new file mode 100644 index 000000000000..f82debe0607f --- /dev/null +++ b/resources/cluster-essentials/templates/environment-mapping.crd.yaml @@ -0,0 +1,14 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: environmentmappings.remoteenvironment.kyma.cx +spec: + group: remoteenvironment.kyma.cx + version: v1alpha1 + scope: Namespaced + names: + plural: environmentmappings + singular: environmentmapping + kind: EnvironmentMapping + shortNames: + - em \ No newline at end of file diff --git a/resources/cluster-essentials/templates/environments.yaml b/resources/cluster-essentials/templates/environments.yaml new file mode 100644 index 000000000000..aca745f22a5d --- /dev/null +++ b/resources/cluster-essentials/templates/environments.yaml @@ -0,0 +1,26 @@ +# production env +apiVersion: v1 +kind: Namespace +metadata: + labels: + env: "true" + name: production +--- + +# stage env +apiVersion: v1 +kind: Namespace +metadata: + labels: + env: "true" + name: stage +--- + +# qa env +apiVersion: v1 +kind: Namespace +metadata: + labels: + env: "true" + name: qa +--- diff --git a/resources/cluster-essentials/templates/event-activation.crd.yaml b/resources/cluster-essentials/templates/event-activation.crd.yaml new file mode 100644 index 000000000000..aaba77c39527 --- /dev/null +++ b/resources/cluster-essentials/templates/event-activation.crd.yaml @@ -0,0 +1,40 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: eventactivations.remoteenvironment.kyma.cx +spec: + group: remoteenvironment.kyma.cx + version: v1alpha1 + scope: Namespaced + names: + plural: eventactivations + singular: eventactivation + kind: EventActivation + shortNames: + - ea + validation: + openAPIV3Schema: + properties: + spec: + properties: + source: + type: object + required: + - "environment" + - "type" + - "namespace" + properties: + environment: + type: string + minLength: 1 + pattern: '^[a-zA-Z]+([_\-\.]?[a-zA-Z0-9]+)*$' + type: + type: string + minLength: 1 + pattern: '^[a-zA-Z]+([_\-\.]?[a-zA-Z0-9]+)*$' + namespace: + type: string + minLength: 1 + pattern: '^[a-zA-Z]+([_\-\.]?[a-zA-Z0-9]+)*$' + displayName: + type: string diff --git a/resources/cluster-essentials/templates/eventing-subscription.crd.yaml b/resources/cluster-essentials/templates/eventing-subscription.crd.yaml new file mode 100644 index 000000000000..d299c3aac970 --- /dev/null +++ b/resources/cluster-essentials/templates/eventing-subscription.crd.yaml @@ -0,0 +1,56 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: subscriptions.eventing.kyma.cx +spec: + group: eventing.kyma.cx + version: v1alpha1 + scope: Namespaced + names: + plural: subscriptions + singular: subscription + kind: Subscription + shortNames: + - sub + validation: + openAPIV3Schema: + properties: + spec: + properties: + endpoint: + type: string + pattern: '^(https?)://.+$' + maxLength: 512 + include_subscription_name_header: + type: boolean + include_topic_header: + type: boolean + max_inflight: + type: integer + minimum: 1 + maximum: 400 + push_request_timeout_ms: + type: integer + minimum: 0 # 0 means apply default value, which is 1000ms + maximum: 5000 + event_type: + type: string + minLength: 1 + pattern: '^[a-zA-Z]+([_\-\.]?[a-zA-Z0-9]+)*$' + event_type_version: + type: string + minLength: 1 + pattern: '^[a-zA-Z0-9]+$' + source: + source_environment: + type: string + minLength: 1 + pattern: '^[a-zA-Z]+([_\-\.]?[a-zA-Z0-9]+)*$' + source_namespace: + type: string + minLength: 1 + pattern: '^[a-zA-Z]+([_\-\.]?[a-zA-Z0-9]+)*$' + source_type: + type: string + minLength: 1 + pattern: '^[a-zA-Z]+([_\-\.]?[a-zA-Z0-9]+)*$' diff --git a/resources/cluster-essentials/templates/nginx-ingress-cert.yaml b/resources/cluster-essentials/templates/nginx-ingress-cert.yaml new file mode 100644 index 000000000000..901fc8dbb585 --- /dev/null +++ b/resources/cluster-essentials/templates/nginx-ingress-cert.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +data: + "tls.crt": {{ .Values.global.tlsCrt | default "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUYyVENDQThHZ0F3SUJBZ0lEQVhvSk1BMEdDU3FHU0liM0RRRUJDd1VBTUVReEN6QUpCZ05WQkFZVEFrUkYKTVJFd0R3WURWUVFIREFoWFlXeHNaRzl5WmpFTU1Bb0dBMVVFQ2d3RFUwRlFNUlF3RWdZRFZRUUREQXRUUVZCTwpaWFJEUVY5SE1qQWVGdzB4TnpFeU1Ua3dOakUyTXpkYUZ3MHhPVEV5TVRrd05qRTJNemRhTURreEN6QUpCZ05WCkJBWVRBa1JGTVF3d0NnWURWUVFLRXdOVFFWQXhIREFhQmdOVkJBTU1FeW91ZVdaaFkzUnZjbmt1YzJGd0xtTnYKY25Bd2dnRWlNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0SUJEd0F3Z2dFS0FvSUJBUUN1c3dzL1RGN3lab0xZdGFPSApEb3pVMGRPTHN4YXhhcmZVMjRETjlHNmEyb2JoV1BvSXdEUHk3dm8weGNiV3cvYnVaQ2Vadm1uSXo5T21CRXhNCmVFTDdFNnpnWUdEVmZWYkp4SXNKZy9BazNSN251VnFXSXNZZlFhZzZ2MnJUb0NyQmxmQkRKWnl5L2RORm02UW4KZDRxa21DVUdpQkc4NGpjOGZUa1l1NGwwVzlwVm1KSFJGN2xpTHVqRTA5WW1XT28zeFZtZXFLdkgweElhZ3h0agp3d0NmMXV4MWl4YzUyYlZRZHBaZGIvVzhTY1A4NE5CRkZaYWNwTmlyUTdjSGc5NHFEV2cvM0J0Z2Y4eHN3L3kyCmtzY2lWMGJOaHR5emhraHJ6aFNXbUptVmJKcC9JZ1JzN05uNFR0VnNoMG50SVlTc2RSU1hKVUorSG5LKzRJKzkKd2U4YkFnTUJBQUdqZ2dIZE1JSUIyVEFkQmdOVkhTVUVGakFVQmdnckJnRUZCUWNEQVFZSUt3WUJCUVVIQXdJdwpId1lEVlIwakJCZ3dGb0FVT0NTdmpYVVMvRGcvTjRNUXI1QTgvQnNoV3Y4d1NRWUlLd1lCQlFVSEFRRUVQVEE3Ck1Ea0dDQ3NHQVFVRkJ6QUNoaTFvZEhSd09pOHZZV2xoTG5CcmFTNWpieTV6WVhBdVkyOXRMMkZwWVM5VFFWQk8KWlhSRFFWOUhNaTVqY25Rd2dkOEdBMVVkSHdTQjF6Q0IxRENCMGFDQnpxQ0J5NFl0YUhSMGNEb3ZMMk5rY0M1dwphMmt1WTI4dWMyRndMbU52YlM5alpIQXZVMEZRVG1WMFEwRmZSekl1WTNKc2hvR1piR1JoY0Rvdkx5OURUajFUClFWQk9SVlJEUVVjeUxDQkRUajFUUVZCT1JWUkRRVWN5TENCRFRqMURSRkFzSUVOT1BWQjFZbXhwWXlCTFpYa2cKVTJWeWRtbGpaWE1zSUVOT1BWTmxjblpwWTJWekxDQkRUajFEYjI1bWFXZDFjbUYwYVc5dUxDQkVRejFuYkc5aQpZV3dzSUVSRFBXTnZjbkFzSUVSRFBYTmhjRDlqWlhKMGFXWnBZMkYwWlhKbGRtOWpZWFJwYjI1c2FYTjBNQkVHCkExVWREZ1FLQkFoSHBZb25LTEV3cERBT0JnTlZIUThCQWY4RUJBTUNCYUF3T1FZRFZSMFJCREl3TUlJVEtpNTUKWm1GamRHOXllUzV6WVhBdVkyOXljSUVaU1RNeE1EUXlOMEJsZUdOb1lXNW5aUzV6WVhBdVkyOXljREFNQmdOVgpIUk1CQWY4RUFqQUFNQTBHQ1NxR1NJYjNEUUVCQ3dVQUE0SUNBUUJjVlB6MmxtTG5temJWaENDRVJUakpvbHhVCktDYWhQeExQYnpsdDBhQ3NzSnFFeWhCOHYyNzVrTURTRW1LSUxjL3NoNDMzSXgrdUZpRHlwMWlaYVZUTnNEYTgKSDhwMVpYenRKamoxQ1VQUHNBZHNtN2N4bFQ3WFFOdFluaXErRVJWVmFBTDV0OGxKTHpQYnQ5akZaQ2dEb2ticgpUSll0dmdTczdQQmVJTFhUOTNtb0luK0pNQ3VNRnZGT05VbUtRY1dUUnNyem0xeE9LbWRLUHczUnhKeGF2dW5ICmxtMXNWZ1JqT1lxbG1xeTlET0dIbEpwUUFaRStyeG1jRnQvOGgwQXNqTUdHUUdYVk8yNTJuUnhIb0ZNNHorOVkKaGhDRXRlcWFDNzlDSFA1R0Q0V3diRVgvQUhwL3djRDVqRy9aQ0EydG1taTJNTEtKYXVxMUhVUkp2czhYdFZRLwpzR1ZxRjFIaDUwcFZTMzZ4eWE1K2lRcXFxNVQ1NlZLUmdQSUhKeDFqZzBWb0U1WFVoTHZLaElCaVZ3dGNoVC9QCjBoMjZoRFlnUjNuY1RCdFBuOE53QzZrQlg1UVRLVVZXTHZTSTBJOEV6SHVvV1RoV1QzWTZmTmVYU3JjTlJlUHAKOXk1QlFIOW85cFJweTNNM3ZwQ3FGNWdWdzkzMlNpT3phbUc0QkRCaStxZ09JUUhrcXg2YWc3UlEzZUhvMjkxUQpyV3JGM21nZU54RjFLTWdqdnVYU0pJNzU1TlV6RU9MUEtxMWNsSmE0MGlPbFRkeGExQUJ3aWtWVU9ZUHpBZzdrCkFhVDVyNW8zaGVmYUZUSWV4T1oyQXJuYzQxb2FYN2c3OUVXemlLYlo3YnZrRlZLTXJiNUN5OWZFc25yMWY3TEgKWGpwUXkxdUxtMUxIWFN4NW9RPT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ==" }} + "tls.key": {{ .Values.global.tlsKey | default "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRQ3Vzd3MvVEY3eVpvTFkKdGFPSERvelUwZE9Mc3hheGFyZlUyNEROOUc2YTJvYmhXUG9Jd0RQeTd2bzB4Y2JXdy9idVpDZVp2bW5JejlPbQpCRXhNZUVMN0U2emdZR0RWZlZiSnhJc0pnL0FrM1I3bnVWcVdJc1lmUWFnNnYyclRvQ3JCbGZCREpaeXkvZE5GCm02UW5kNHFrbUNVR2lCRzg0amM4ZlRrWXU0bDBXOXBWbUpIUkY3bGlMdWpFMDlZbVdPbzN4Vm1lcUt2SDB4SWEKZ3h0and3Q2YxdXgxaXhjNTJiVlFkcFpkYi9XOFNjUDg0TkJGRlphY3BOaXJRN2NIZzk0cURXZy8zQnRnZjh4cwp3L3kya3NjaVYwYk5odHl6aGtocnpoU1dtSm1WYkpwL0lnUnM3Tm40VHRWc2gwbnRJWVNzZFJTWEpVSitIbksrCjRJKzl3ZThiQWdNQkFBRUNnZ0VBWHVKWjZvQkZWV3krQm92cERIS25wUE50N1lOUWdQNFFhR2pyOVhDY0hqa3cKWnZhWG51Y2RrQVdpR2NXTnNKbnc1UnU2c25OTUswaE5rTzdtcmVYbm8welZhV3lQUzRUdS9WbGxscjUxVU9CVQpaOUV4VlRmLzJYd2tyZnZIUHJmUVlTSlp3MjZMUkdBK1BxRkJNRHBxTCtsK3VxUnUxZU1iMHh2RVJoMmRrM2RsClNYSlRqUmJPd0VWU3dCTmg4Tk5JWnR3YlQrWGl3Wm5FcDFRTWF1MjBCZ2YxeXR6dTlIQUF2VkRVYkh2dFVYNGYKdVNHcFdWeVI3bjFzazRqdng0TmpzU0k3T0I5Ykg5Ulc4YUJ0L0piVVMxNTVRVWV1SUsrWjlvS09LbExRdDhIUwp5aTRHeVRtTndXdFQ2SzNtbUNQSTRwckNxYzh3Qzd1Q2lRKy9zbGl4OFFLQmdRRG9XOFBFc0FPQjVVUHpsQVpLClJhVStpSGJqTE13d3YwWWpDZXlXWk53bFNDUWc5U2g4bmVjMWpidXBMOSt2a2VXM1ZvWjNLSXBCWGhzME8xL0gKcmpnZjdSMWhROXExK0kyY1pzdmtPK0JZcnk4UGszSW9MS29rVnF1RVM3d2w0UmVFTThQNU0vV2VzRzNkd1grYwpHalkxSS90YjFLYlJBWmtma1JsZDFPZ1FhUUtCZ1FEQWVXOVVQaU9zYmY1clV1dkF0WFk0YXRBdkZlTmxCYmFOClgvZnNRa2h3UVZwWlJadFVKMGtwWWRlbFpqdnk5VVJ5Tk40RUl6bnF6VXYyakhYb3ZrajNMR3NIcHBUbzdyRkMKZUptZDU5RFB0cHNFZVh4My93RXMyVTBGUGlWU1BGOXMzemNPWHU0VGhnS1N5dkUrd0FJMXU0Ym84d3FIMFhZMgphZS8wTWJRUzR3S0JnSHVxVzBjVFpzcDdldTdvbVhCdUlNUm5YTjhMdUtFNUs5cjZMVERkL1V6YVdHQXlHcG80CmNCWFFtVkF5YXByR3k3d0lFUWphU3JyL212cnhuc2huc1BkTSs1WUl4eDZTYXN3UzZmRk83TmhHWklXbHM5d3QKNlYvdHdOdG9jM3Exa2c2S2ZvTGpaSlpRMENoMkU3VEJQcGlKemYzUjcvYk5JdkhxQjlZb3Vsb0JBb0dBV25lNQprRXMwMFZOU0JuZ1BQNkVMVkRGQWNTRkVwTE11dnZ5Z3QzZmdQSU03U0VLalk4YWphTVFJYkVIRlk4bE14ckY2CnVVOGxaZXZoREoyd3ZoalNpdnRodzFMVkl6Y3VNaVFsY0VjSnF0Z1Z2T0N0VkdtVHo5VERrdmRHNjRSZEJmdFYKRVhnbFJ4L3lEYlU5OTFTZ3RValRmS0xnREQ2ejVaZWRwWlhISWlrQ2dZRUFoL0lRMnhuS1hOUUZlL0d6cU1sYQpZZ3NqaWxhSkJlcW1qOTd6S2h5a3JvclZCTFhnU0ErSU5pMGpXY2pPUVZ5RmxNcjhBRFIvRm01NWNLTFFSOCtLCjNUOGFFUjJRRExTVVh5UWtkS0w0VVAvKzBXRmpSdGF6cktuV1JHZkl6Vmk2L253RXE2eVo4N3JQZGdwRmQzQmoKS0U0L1k0MFBSa21xY2wvc0FoUmdkK1U9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K" }} +kind: Secret +metadata: + name: nginx-ingress-cert + namespace: kyma-integration +type: kubernetes.io/tls diff --git a/resources/cluster-essentials/templates/remote-env-ca-secret.yaml b/resources/cluster-essentials/templates/remote-env-ca-secret.yaml new file mode 100644 index 000000000000..6409dc6b06bc --- /dev/null +++ b/resources/cluster-essentials/templates/remote-env-ca-secret.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +data: + ca.crt: {{ .Values.global.remoteEnvCa | default "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURPakNDQWlJQ0NRRFVmLzUxMTZMN2ZUQU5CZ2txaGtpRzl3MEJBUXNGQURCZk1ROHdEUVlEVlFRTERBWkQKTkdOdmNtVXhEREFLQmdOVkJBb01BMU5CVURFUU1BNEdBMVVFQnd3SFYyRnNaRzl5WmpFUU1BNEdBMVVFQ0F3SApWMkZzWkc5eVpqRUxNQWtHQTFVRUJoTUNSRVV4RFRBTEJnTlZCQU1NQkV0NWJXRXdIaGNOTVRnd056RXpNRGsxCk1qVXhXaGNOTVRrd056RXpNRGsxTWpVeFdqQmZNUTh3RFFZRFZRUUxEQVpETkdOdmNtVXhEREFLQmdOVkJBb00KQTFOQlVERVFNQTRHQTFVRUJ3d0hWMkZzWkc5eVpqRVFNQTRHQTFVRUNBd0hWMkZzWkc5eVpqRUxNQWtHQTFVRQpCaE1DUkVVeERUQUxCZ05WQkFNTUJFdDViV0V3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLCkFvSUJBUUQ0OUladXFvZ2NhcUFWU1Y3OUw3eEtNSTM2Tk15NmlnK2pUcXVlY045TFJoUWNhbEtES3hKMEJSZXQKYlNVaGZ0cjhxY0UzU2F4T3RQUHZUTG9paXhqbE1GYVE0NlpmQXg4SGdHQkVldmIvallvQnRBVFhMRDFLL1JQLwpYWG1iaDdtb3kwbXhoUEE1ZW0yTFU4czIyRUdqTjlMMFZqYm1xRVI2eFdsUmNjWjhCbUFHUVZPZ0lMSzk4SUdECkVON0VRU2Y2WnpMekNsQlMzQXhHcjYyc3VQODF5dVhRTHl0TkxZOXhiTlJQc1E3V25wUEhyWk0xM0NDYjR3cWIKNEc1TVh5TGowNzdSZFZGWlY4bDdQNkRRMEJiMkFZV2YyZWdZdjFpRU1SdW4ydjNiek40RFg2T3VwMnZSRC9SQwpzS2QvUXlxV1YxVTlGU1RnYlJLQUlLYjFJMXRaQWdNQkFBRXdEUVlKS29aSWh2Y05BUUVMQlFBRGdnRUJBTVhvCnRZK1dxSEdWWGhyU3RlYmtuQ0o1ZGQ4YkxRd3FFcUNCQkxEenNqUDQzUTFnM3lYVDdmVGwxeklkVU5ZaEQveDkKeTAyWUNESm5SWFI1dlJpdlI0N1RYdGRYSkZMOGQyalNCR0Y3cTJKNHFETmRITE5zRXptV1lIek5ZVVlxQkIrNQpYa2lxVUtnS3ZkYmFHQ3NIa2hsbXdVUzNJZHR4VlFHdFBET3paMy9aUndNcWxoaVBheUZIR0NwazdhR3ZTSEE2CnJVNFhZT3A4OHNQaHVxbXk3emFmVU5ObG10MlhTV2FOclMvTmYxV05IMUd0SDkydVVhTGg1M0JTUC9NQjUvL2EKdS8xdE5VT244VkpXVnRPSHRWZG1NT2tTZjErSDNnNEpPRCtucStBRDJaVGdCK0tSa1VRcGg2VjBiYzFIOUNuVwpLdHZsT1oxVzMvRUZqMUh3b3V3PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==" }} + ca.key: {{ .Values.global.remoteEnvCaKey | default "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2d0lCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktrd2dnU2xBZ0VBQW9JQkFRRDQ5SVp1cW9nY2FxQVYKU1Y3OUw3eEtNSTM2Tk15NmlnK2pUcXVlY045TFJoUWNhbEtES3hKMEJSZXRiU1VoZnRyOHFjRTNTYXhPdFBQdgpUTG9paXhqbE1GYVE0NlpmQXg4SGdHQkVldmIvallvQnRBVFhMRDFLL1JQL1hYbWJoN21veTBteGhQQTVlbTJMClU4czIyRUdqTjlMMFZqYm1xRVI2eFdsUmNjWjhCbUFHUVZPZ0lMSzk4SUdERU43RVFTZjZaekx6Q2xCUzNBeEcKcjYyc3VQODF5dVhRTHl0TkxZOXhiTlJQc1E3V25wUEhyWk0xM0NDYjR3cWI0RzVNWHlMajA3N1JkVkZaVjhsNwpQNkRRMEJiMkFZV2YyZWdZdjFpRU1SdW4ydjNiek40RFg2T3VwMnZSRC9SQ3NLZC9ReXFXVjFVOUZTVGdiUktBCklLYjFJMXRaQWdNQkFBRUNnZ0VCQU1nd0FLV2VsVUtjYnN1UEswWFdpOGJ3a2tvL0NOMTdSU1hoamRrakZEczQKUjlXdG5Wb0NXWjFYdHZscXlhL01qUlVjLzlTUWRuRDN5eXVDL0Mzb3dnVVJ3SnBUcEtYRUljQnJkMzI1ajBKaApzemE1WmljTVdPRWk3bUNxN3EybER5THZEUHdsVlBJelEwRUZDV1M1c3RZbmZvZWhpQllqK3FUcWQwelplMnlnCk1iWGhGcnRNWVU1SGZYRzdtalNNR285T0hmSzdLQm1nTVFaRmFTL1FobzdIdGNUd3FRVUpiOVFkdkRwMXAxYU8KdU4wYWtOZVNIejBxUlNkWlZjMjFiZ0p4SlE5VFpLTU9YalNiNW82aXhPOVkxNVg2L05NcGQwYkVPSVowSGl4NAp4RTdjemlLYVFwWVhvZHAxWXRWUU9mQ3huZ3MwcUN2TVJ1QkV0ejl1LzAwQ2dZRUEvY1h1MHUxUnBPM2huZnR6CjlHZU1lOUxiRFF6K1VTYVYzeTBBR0F2dW5DRFJsS3V2Rk9ZalA4VXd1U24xdmVhQkc5TzBlUXZmd080Z1BiVkYKSzd2dXNIZGp5RnhnV2tDMHdkaDZ2RW1IWkRDQkoyb3I2UmlYRTRld3BBRlpzbld0NFpVc21Rb0dIaWtLc1BBUApKdWJIZ29DWE5laW1DdC9MTHR5S0tvdVhXanNDZ1lFQSt5UEU3TGxObUpzZWZRdE1Id2pHeEdYTkt3eHhOWm04CjVHbENUbnJnaEd0Ynd4TGZ6aXM0S2hoZSs3TzVvR3Q4MzQ1QkJGb1hzeDFTZWpHelV4L0pTRkprNFRQSlM3bnIKZkxhVlhrVitWd2xpbGRaQ3ExZ1haRFRGbTFGYTFSdnlxMnlKbDdUQ3h3RzZZcTNseXFKdE5wTGNIVG90M1ZuVgpaSmlFMllqLzgzc0NnWUVBOWdwVmhvTTR5U01wdjdYdnNtSkN2anFzem5adWk5ZFJMMU85NTVLS0FTMkFoUmYvClk4L05GU0xtSG1Ba05iMnFKNFNXVVZYRjFwUUpCa3NkaVUxb0dnZG00dmROSTdQZ2pLQ0tQc3M3VFZRSnBHRTMKdVlOeDFpbVVoUzRaL1FCRFdaYjc2bFRob1pSQkhWTmlIV0ZheFpoUEFxRlJldkdIV1NQUmdYWmRnOGtDZ1lCUwplUktvY3NvS3ZaWXJsbFF2Tk9DaGlwVHYrQ3dOWUNscUVTOFhPcjhVZlhVckFMM0NwT3JTMkNJSklxSnBEaU41Ci91dFhGSWNINlM4MnRhK2dNcVlWUEFtbzh5cTkxWmFCNUN2MVEza1QrQjhKK2N6M0cvekNpWm9EUVpwRXRlTGgKYk1sYXFwLzBYcHJvNFJhL2pzdXl5bFBDdldKbWVLUGRYMER4ZDA0bWxRS0JnUUNhTlp0S1YrWDVyVVMxaDJnUQppdVBwR2hzajhzOEVxZkdETUZMZU45UHNzWTlKelpYaDdNZHppMmJtYWpqbUtOQnR4eWVLNU0xdElKeUtZdTBLCmZoK2FXSXZjc3FFUXQyb3gvb1AvcHZCc205OVAxSm1lamd0ZFYzL2pTbmx2K1oramc1Z0Mxays0eDFMdDZFVkEKSXRpYzhCeFVNTFNETm1aZHk5YXBWeWhsWXc9PQotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0tCg==" }} +kind: Secret +metadata: + name: nginx-auth-ca + namespace: kyma-integration \ No newline at end of file diff --git a/resources/cluster-essentials/templates/remote-environment.crd.yaml b/resources/cluster-essentials/templates/remote-environment.crd.yaml new file mode 100644 index 000000000000..a3f4b64e35e3 --- /dev/null +++ b/resources/cluster-essentials/templates/remote-environment.crd.yaml @@ -0,0 +1,88 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: remoteenvironments.remoteenvironment.kyma.cx +spec: + group: remoteenvironment.kyma.cx + version: v1alpha1 + scope: Cluster + names: + plural: remoteenvironments + singular: remoteenvironment + kind: RemoteEnvironment + shortNames: + - re + validation: + openAPIV3Schema: + properties: + spec: + properties: + accessLabel: + type: string + maxLength: 63 + pattern: '^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$' + source: + type: object + required: + - "environment" + - "type" + - "namespace" + properties: + environment: + type: string + minLength: 1 + pattern: '^[a-zA-Z]+([_\-\.]?[a-zA-Z0-9]+)*$' + type: + type: string + minLength: 1 + pattern: '^[a-zA-Z]+([_\-\.]?[a-zA-Z0-9]+)*$' + namespace: + type: string + minLength: 1 + pattern: '^[a-zA-Z]+([_\-\.]?[a-zA-Z0-9]+)*$' + services: + type: array + items: + type: object + required: + - "id" + - "displayName" + - "providerDisplayName" + - "entries" + properties: + id: + type: string + displayName: + type: string + longDescription: + type: string + providerDisplayName: + type: string + entries: + type: array + items: + type: object + required: + - "type" + properties: + type: + type: string + enum: + - "API" + - "Events" + gatewayUrl: + type: string + accessLabel: + type: string + maxLength: 63 + pattern: '^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$' + targetUrl: + type: string + oauthUrl: + type: string + credentialsSecretName: + type: string + tags: + type: array + items: + type: string diff --git a/resources/cluster-essentials/templates/service-binding-usage.crd.yaml b/resources/cluster-essentials/templates/service-binding-usage.crd.yaml new file mode 100644 index 000000000000..3cf600a74810 --- /dev/null +++ b/resources/cluster-essentials/templates/service-binding-usage.crd.yaml @@ -0,0 +1,48 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: servicebindingusages.servicecatalog.kyma.cx +spec: + group: servicecatalog.kyma.cx + version: v1alpha1 + scope: Namespaced + names: + plural: servicebindingusages + singular: servicebindingusage + kind: ServiceBindingUsage + shortNames: + - sbu + - svcbindingusage + validation: + openAPIV3Schema: + properties: + spec: + properties: + serviceBindingRef: + type: object + required: + - "name" + properties: + name: + type: string + usedBy: + type: object + required: + - "kind" + - "name" + properties: + kind: + type: string + enum: ["Deployment", "Function"] + name: + type: string + parameters: + type: object + properties: + envPrefix: + type: object + required: + - "name" + properties: + name: + type: string \ No newline at end of file diff --git a/resources/cluster-prerequisites/README.md b/resources/cluster-prerequisites/README.md new file mode 100644 index 000000000000..7fdf8b2c2397 --- /dev/null +++ b/resources/cluster-prerequisites/README.md @@ -0,0 +1,27 @@ +``` + _____ _ _ _ _ _ + / ____| | | | (_) (_) | + | | | |_ _ ___| |_ ___ _ __ _ __ _ __ ___ _ __ ___ __ _ _ _ _ ___ _| |_ ___ ___ + | | | | | | / __| __/ _ \ '__| | '_ \| '__/ _ \ '__/ _ \/ _` | | | | / __| | __/ _ \/ __| + | |____| | |_| \__ \ || __/ | | |_) | | | __/ | | __/ (_| | |_| | \__ \ | || __/\__ \ + \_____|_|\__,_|___/\__\___|_| | .__/|_| \___|_| \___|\__, |\__,_|_|___/_|\__\___||___/ + | | | | + |_| |_| +``` + +## Overview + +The `cluster-prerequisites` folder contains kubernetes resources which need to be installed before cluster setup. + +Currently, these resources include the following `yaml` files: + +- `default-sa-rbac-role.yaml` - This file binds the **cluster-admin** role with the default **ServiceAccount** to provide increased permissions that you require to complete the Kyma installation. + +- `limit-range.yaml` - This file defines the memory limit range applied for kyma system namespaces. +In case of an OOM error, adjust memory requirements for your components. Information about OOM error is on Pods, ReplicaSets, StatefulSets. + +- `resource-quotas.yaml` - This file defines resource quotas for the `kyma-system`, `kyma-integration` and `istio-system` Kyma system Namespaces. +If you receive an error during the Pod creation that relates to exceeding resource quota limits, adjust values in the file. + +- `resource-quotas-installer.yaml` - This file contains resource quotas for the `kyma-installer` Namespace. + diff --git a/resources/cluster-prerequisites/default-sa-rbac-role.yaml b/resources/cluster-prerequisites/default-sa-rbac-role.yaml new file mode 100644 index 000000000000..e5ae69d2ea58 --- /dev/null +++ b/resources/cluster-prerequisites/default-sa-rbac-role.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: default-sa-cluster-admin + labels: + app: kyma +subjects: +- kind: ServiceAccount + name: default + namespace: kube-system +roleRef: + kind: ClusterRole + apiGroup: rbac.authorization.k8s.io + name: cluster-admin \ No newline at end of file diff --git a/resources/cluster-prerequisites/install.sh b/resources/cluster-prerequisites/install.sh new file mode 100644 index 000000000000..812de63a274e --- /dev/null +++ b/resources/cluster-prerequisites/install.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +set -o errexit + +# prepareSystemNs is responsible for creating system namespace with all required components, such us secret for Docker registry, LimitRange etc. +function prepareSystemNs() { + nsName=$1 + kubectl create namespace ${nsName} + kubectl apply -f /kyma/resources/cluster-prerequisites/limit-range.yaml -n ${nsName} +} + +prepareSystemNs "istio-system" +prepareSystemNs "kyma-system" +prepareSystemNs "kyma-integration" + +kubectl label namespace kyma-system "istio-injection=enabled" +kubectl label namespace kyma-integration "istio-injection=enabled" + +kubectl apply -f /kyma/resources/cluster-prerequisites/remote-environments-minio-secret.yaml -n "kyma-integration" + +kubectl apply -f /kyma/resources/cluster-prerequisites/resource-quotas.yaml diff --git a/resources/cluster-prerequisites/limit-range.yaml b/resources/cluster-prerequisites/limit-range.yaml new file mode 100644 index 000000000000..897167e2b1d7 --- /dev/null +++ b/resources/cluster-prerequisites/limit-range.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: LimitRange +metadata: + name: kyma-default +spec: + limits: + - max: + memory: 1024Mi # Maximum memory that a container can request + default: + # If a container does not specify memory limit, this default value will be applied. + # If a container tries to allocate more memory, container will be OOM killed. + memory: 96Mi + defaultRequest: + # If a container does not specify memory request, this default value will be applied. + # The scheduler considers this value when scheduling a container to a node. + # If a node has not enough memory, such pod will not be created. + memory: 32Mi + type: Container \ No newline at end of file diff --git a/resources/cluster-prerequisites/remote-environments-minio-secret.yaml b/resources/cluster-prerequisites/remote-environments-minio-secret.yaml new file mode 100644 index 000000000000..537e69668d7c --- /dev/null +++ b/resources/cluster-prerequisites/remote-environments-minio-secret.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Secret +metadata: + name: remoteenvironments-minio-user +type: Opaque +data: + # WARNING - this values have to be the same as `accessKey` and `secretKey` in: resources/core/charts/minio/values.yaml + # tip: use `echo -n "admin" | base64` to encode plain values to base64 + accesskey: YWRtaW4= + secretkey: dG9wU2VjcmV0S2V5 diff --git a/resources/cluster-prerequisites/resource-quotas-installer.yaml b/resources/cluster-prerequisites/resource-quotas-installer.yaml new file mode 100644 index 000000000000..cf20f2a6a32b --- /dev/null +++ b/resources/cluster-prerequisites/resource-quotas-installer.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ResourceQuota +metadata: + name: kyma-default + namespace: kyma-installer +spec: + hard: + # The sum of all pod memory requests must not exceed this value. + requests.memory: 384Mi + + # The sum of all pod memory limit must not exceed this value. + limits.memory: 512Gi diff --git a/resources/cluster-prerequisites/resource-quotas.yaml b/resources/cluster-prerequisites/resource-quotas.yaml new file mode 100644 index 000000000000..10cdf25a7713 --- /dev/null +++ b/resources/cluster-prerequisites/resource-quotas.yaml @@ -0,0 +1,38 @@ +apiVersion: v1 +kind: ResourceQuota +metadata: + name: kyma-default + namespace: kyma-system +spec: + hard: + # The sum of all pod memory requests must not exceed this value. + requests.memory: 7Gi + + # The sum of all pod memory limit must not exceed this value. + limits.memory: 10Gi +--- +apiVersion: v1 +kind: ResourceQuota +metadata: + name: kyma-default + namespace: kyma-integration +spec: + hard: + # The sum of all pod memory requests must not exceed this value. + requests.memory: 3Gi + + # The sum of all pod memory limit must not exceed this value. + limits.memory: 3Gi +--- +apiVersion: v1 +kind: ResourceQuota +metadata: + name: kyma-default + namespace: istio-system +spec: + hard: + # The sum of all pod memory requests must not exceed this value. + requests.memory: 2Gi + + # The sum of all pod memory limit must not exceed this value. + limits.memory: 3Gi \ No newline at end of file diff --git a/resources/core/.helmignore b/resources/core/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/.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/resources/core/Chart.yaml b/resources/core/Chart.yaml new file mode 100644 index 000000000000..c7e4bb013116 --- /dev/null +++ b/resources/core/Chart.yaml @@ -0,0 +1,5 @@ +name: core +version: 0.0.1 +description: Kyma +keywords: + - kyma diff --git a/resources/core/README.md b/resources/core/README.md new file mode 100644 index 000000000000..92ecdee69598 --- /dev/null +++ b/resources/core/README.md @@ -0,0 +1,40 @@ +``` + _____ + / ____| + | | ___ _ __ ___ + | | / _ \| '__/ _ \ + | |___| (_) | | | __/ + \_____\___/|_| \___| + +``` + +## Overview + +According to the [Manifesto](https://kyma-project.github.io/community/), Kyma is a product with batteries included. The `core` directory contains all components required to run Kyma. For more details about each core component, see the corresponding README.md files. + +## Details + +This section describes how to add a new core component. It also describes how to configure or disable a component that already exists. + +### Add a new core component + +If you develop a new core component, add a new sub-chart to the `core` directory. Update the [`requirements.yaml`](requirements.yaml) file by adding the **name** and **condition** attributes for the created component. To learn more about the **condition** attribute, see the [tags and condition fields in helm charts](https://github.com/kubernetes/helm/blob/release-2.7/docs/charts.md#tags-and-condition-fields-in-requirementsyaml) documentation. + +### Inject sensitive data into a core component + +To inject sensitive data into a core component during the Kyma installation, follow these steps: +1. Create the `secrets.yaml` file locally. In the file, include the name of the component to inject sensitive data to: + + ``` + helm-broker: + config: + basic_auth_password: p4ssw0rd + ``` + + Use the same `secrets.yaml` file for all core components. The structure of the **config** section is different for each component. For more details, see the `values.yaml` files associated with specific components. + +2. Start a container during the [installation](../../docs/kyma/docs/031-gs-local-installation.md), and mount the `secrets.yaml` file in the `run.sh` script with the following command: + + ``` + ./run.sh -s ${PATH_TO_DIRECTORY_WITH_THE_SECRET_YAML_FILE}/secrets.yaml + ``` diff --git a/resources/core/charts/api-controller/Chart.yaml b/resources/core/charts/api-controller/Chart.yaml new file mode 100644 index 000000000000..7a366958e50e --- /dev/null +++ b/resources/core/charts/api-controller/Chart.yaml @@ -0,0 +1,5 @@ +name: api-controller +version: 0.0.3 +description: Api Controller +keywords: + - api-controller diff --git a/resources/core/charts/api-controller/README.md b/resources/core/charts/api-controller/README.md new file mode 100644 index 000000000000..c47daf5289be --- /dev/null +++ b/resources/core/charts/api-controller/README.md @@ -0,0 +1,14 @@ + +``` + _____ _____ _____ _ _ _ + /\ | __ \_ _| / ____| | | | | | + / \ | |__) || |______| | ___ _ __ | |_ _ __ ___ | | | ___ _ __ + / /\ \ | ___/ | |______| | / _ \| '_ \| __| '__/ _ \| | |/ _ \ '__| + / ____ \| | _| |_ | |___| (_) | | | | |_| | | (_) | | | __/ | + /_/ \_\_| |_____| \_____\___/|_| |_|\__|_| \___/|_|_|\___|_| + + +``` +## Overview + +API-Controller is a component allowing to expose a service through the Kyma Console. Manages Ingresses and JWT rules. diff --git a/resources/core/charts/api-controller/templates/deployment.yaml b/resources/core/charts/api-controller/templates/deployment.yaml new file mode 100644 index 000000000000..60e34a99c9bf --- /dev/null +++ b/resources/core/charts/api-controller/templates/deployment.yaml @@ -0,0 +1,21 @@ +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: api-controller + namespace: {{ .Release.Namespace }} + labels: + app: api-controller +spec: + replicas: 1 + template: + metadata: + labels: + app: api-controller + spec: + serviceAccount: api-controller-account + containers: + - image: {{ .Values.global.containerRegistry.path }}/{{ .Values.image.name }}:{{ .Values.image.tag }} + name: api-controller + # ports: + # - containerPort: 80 diff --git a/resources/core/charts/api-controller/templates/pre-install-rbac.yaml b/resources/core/charts/api-controller/templates/pre-install-rbac.yaml new file mode 100644 index 000000000000..58602bc44908 --- /dev/null +++ b/resources/core/charts/api-controller/templates/pre-install-rbac.yaml @@ -0,0 +1,36 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: api-controller-account + namespace: {{ .Release.Namespace }} + annotations: + "helm.sh/hook": pre-install + "helm.sh/hook-weight": "0" +--- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1beta1 +metadata: + name: api-controller-role + namespace: {{ .Release.Namespace }} +rules: + - apiGroups: ["apiextensions.k8s.io", "gateway.kyma.cx", "extensions", "authentication.istio.io"] + resources: ["customresourcedefinitions", "apis", "ingresses", "policies"] + verbs: ["*"] + - apiGroups: [""] + resources: ["services"] + verbs: ["get", "list", "watch"] +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1beta1 +metadata: + name: api-controller-role-binding + namespace: {{ .Release.Namespace }} +subjects: + - kind: ServiceAccount + name: api-controller-account # Service account assigned to the api-controller pod. + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: api-controller-role diff --git a/resources/core/charts/api-controller/templates/tests/test-account-and-role.yaml b/resources/core/charts/api-controller/templates/tests/test-account-and-role.yaml new file mode 100644 index 000000000000..bd6c36410acc --- /dev/null +++ b/resources/core/charts/api-controller/templates/tests/test-account-and-role.yaml @@ -0,0 +1,38 @@ +{{ if .Values.tests.enabled }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: api-controller-test-account + namespace: {{ .Release.Namespace }} + labels: + helm-chart-test: "true" +--- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1beta1 +metadata: + name: api-controller-test-role + namespace: {{ .Release.Namespace }} + labels: + helm-chart-test: "true" +rules: + - apiGroups: [ "", "apps", "gateway.kyma.cx"] + resources: ["deployments", "services", "apis"] + verbs: ["*"] +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1beta1 +metadata: + name: api-controller-test-role-binding + namespace: {{ .Release.Namespace }} + labels: + helm-chart-test: "true" +subjects: + - kind: ServiceAccount + name: api-controller-test-account # Service account assigned to the api-controller pod. + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: api-controller-test-role +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/api-controller/templates/tests/test-api-controller.yaml b/resources/core/charts/api-controller/templates/tests/test-api-controller.yaml new file mode 100644 index 000000000000..b26175b2c402 --- /dev/null +++ b/resources/core/charts/api-controller/templates/tests/test-api-controller.yaml @@ -0,0 +1,24 @@ +{{ if .Values.tests.enabled }} +--- +apiVersion: v1 +kind: Pod +metadata: + name: test-api-controller-acceptance + annotations: + "helm.sh/hook": test-success + labels: + "helm-chart-test": "true" +spec: + serviceAccount: api-controller-test-account + containers: + - name: test-api-controller-acceptance + image: {{ .Values.global.containerRegistry.path }}/{{ .Values.tests.image.name }}:{{ .Values.tests.image.tag }} + resources: + limits: + memory: 400Mi + env: + - name: DOMAIN_NAME + value: {{ .Values.global.domainName }} + restartPolicy: Never +--- +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/api-controller/values.yaml b/resources/core/charts/api-controller/values.yaml new file mode 100644 index 000000000000..2c77f392eadc --- /dev/null +++ b/resources/core/charts/api-controller/values.yaml @@ -0,0 +1,9 @@ +image: + name: "api-controller" + tag: "4.1.0" + +tests: + enabled: true + image: + name: "api-controller-acceptance-tests" + tag: "4.1.0" diff --git a/resources/core/charts/apiserver-proxy/Chart.yaml b/resources/core/charts/apiserver-proxy/Chart.yaml new file mode 100644 index 000000000000..df7c30f2c57c --- /dev/null +++ b/resources/core/charts/apiserver-proxy/Chart.yaml @@ -0,0 +1,6 @@ +name: apiserver-proxy +version: 0.0.1 +description: Proxy for Kyma apiserver +keywords: + - apiproxy + - apiserver diff --git a/resources/core/charts/apiserver-proxy/README.md b/resources/core/charts/apiserver-proxy/README.md new file mode 100644 index 000000000000..1cf073af7450 --- /dev/null +++ b/resources/core/charts/apiserver-proxy/README.md @@ -0,0 +1,23 @@ +``` +_ __ _ _ _____ _____ _____ _____ +| |/ / | | | | /\ | __ \_ _| / ____| | __ \ +| ' /_ _| |__ ___ _ __ _ __ ___| |_ ___ ___ / \ | |__) || | | (___ ___ _ ____ _____ _ __ | |__) | __ _____ ___ _ +| <| | | | '_ \ / _ \ '__| '_ \ / _ \ __/ _ \/ __| / /\ \ | ___/ | | \___ \ / _ \ '__\ \ / / _ \ '__| | ___/ '__/ _ \ \/ / | | | +| . \ |_| | |_) | __/ | | | | | __/ || __/\__ \ / ____ \| | _| |_ ____) | __/ | \ V / __/ | | | | | | (_) > <| |_| | +|_|\_\__,_|_.__/ \___|_| |_| |_|\___|\__\___||___/ /_/ \_\_| |_____| |_____/ \___|_| \_/ \___|_| |_| |_| \___/_/\_\\__, | + __/ | + |___/ +``` + +## Overview + +This API Server Proxy is an [Nginx](https://nginx.org/en/)-based, transparent proxy for the Kubernetes API. It is exposed for the external communication. + + +## Details + +Kyma requires all APIs, including those provided by the Kubernetes API server, to be exposed in a consistent manner through Istio. + +To expose an API through Istio, all of the Pods that run the service containers must contain an Envoy sidecar. You need an additional proxy, as you cannot inject an Envoy sidecar directly into the Kubernetes API server. As a workaround, deploy Nginx as a proxy for the Kubernetes API server. Istio injects an Envoy sidecar into the Pods that run Nginx. + +Installing the Helm chart creates an ingress, which exposes the API server under the `apiserver` subdomain in the configured domain. diff --git a/resources/core/charts/apiserver-proxy/templates/pre-install-proxy-config-map.yaml b/resources/core/charts/apiserver-proxy/templates/pre-install-proxy-config-map.yaml new file mode 100644 index 000000000000..40768ff5de9c --- /dev/null +++ b/resources/core/charts/apiserver-proxy/templates/pre-install-proxy-config-map.yaml @@ -0,0 +1,33 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.nginx.configmapName }} + namespace: {{.Release.Namespace }} + annotations: + "helm.sh/hook": pre-install + "helm.sh/hook-weight": "0" +data: + nginx.conf: | + load_module /usr/lib/nginx/modules/ndk_http_module.so; + + worker_processes 1; + events { + worker_connections 1024; + } + + http { + server { + listen 80; + server_name apiserver.{{ .Values.global.domainName }}; + + location ~ ^/ { + proxy_set_header "Host" $host; + proxy_pass https://kubernetes.default; + } + + location ~ ^/.+ { + proxy_set_header "Host" $host; + proxy_pass https://kubernetes.default; + } + } + } \ No newline at end of file diff --git a/resources/core/charts/apiserver-proxy/templates/proxy-deployment.yaml b/resources/core/charts/apiserver-proxy/templates/proxy-deployment.yaml new file mode 100644 index 000000000000..d9a84eb0a876 --- /dev/null +++ b/resources/core/charts/apiserver-proxy/templates/proxy-deployment.yaml @@ -0,0 +1,38 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: nginx-proxy + namespace: {{ .Release.Namespace }} +spec: + replicas: 1 + template: + metadata: + annotations: + sidecar.istio.io/inject: "true" + labels: + app: apiserver-proxy + spec: + containers: + - image: elvido/alpine-nginx-lua:3.6 + name: nginx-proxy + imagePullPolicy: IfNotPresent + ports: + - containerPort: {{ .Values.containerPort}} + volumeMounts: + - name: {{ .Values.nginx.configmapName}} + mountPath: /var/nginx/config + + - name: dnsmasq + image: "janeczku/go-dnsmasq:release-1.0.5" + args: + - --listen + - "127.0.0.1:53" + - --default-resolver + - --append-search-domains + - --hostsfile=/etc/hosts + - --verbose + + volumes: + - name: {{ .Values.nginx.configmapName}} + configMap: + name: {{ .Values.nginx.configmapName}} \ No newline at end of file diff --git a/resources/core/charts/apiserver-proxy/templates/proxy-ingress.yaml b/resources/core/charts/apiserver-proxy/templates/proxy-ingress.yaml new file mode 100644 index 000000000000..9dc4693b9850 --- /dev/null +++ b/resources/core/charts/apiserver-proxy/templates/proxy-ingress.yaml @@ -0,0 +1,18 @@ +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: apiserver-proxy-ingress + namespace: {{ .Release.Namespace }} + annotations: + kubernetes.io/ingress.class: "istio" +spec: + tls: + - secretName: {{ .Values.tls.secretName }} + rules: + - host: apiserver.{{ .Values.global.domainName }} + http: + paths: + - backend: + serviceName: apiserver-proxy-service + servicePort: {{ .Values.containerPort }} + path: /.* diff --git a/resources/core/charts/apiserver-proxy/templates/proxy-service.yaml b/resources/core/charts/apiserver-proxy/templates/proxy-service.yaml new file mode 100644 index 000000000000..48eb9b8e862f --- /dev/null +++ b/resources/core/charts/apiserver-proxy/templates/proxy-service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + name: apiserver-proxy-service + namespace: {{ .Release.Namespace }} +spec: + ports: + - name: http + port: {{.Values.containerPort}} + protocol: TCP + targetPort: {{.Values.containerPort}} + selector: + app: apiserver-proxy + diff --git a/resources/core/charts/apiserver-proxy/values.yaml b/resources/core/charts/apiserver-proxy/values.yaml new file mode 100644 index 000000000000..33e9c9a9e3bb --- /dev/null +++ b/resources/core/charts/apiserver-proxy/values.yaml @@ -0,0 +1,6 @@ +tls: + secretName: "istio-ingress-certs" + +nginx: + configmapName: nginx-proxy-conf +containerPort: 80 diff --git a/resources/core/charts/application-connector/Chart.yaml b/resources/core/charts/application-connector/Chart.yaml new file mode 100644 index 000000000000..f5cc669e8505 --- /dev/null +++ b/resources/core/charts/application-connector/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +description: Application Connector Helm chart. +name: application-connector +version: 0.0.1 +keywords: + - Application Connector diff --git a/resources/core/charts/application-connector/README.md b/resources/core/charts/application-connector/README.md new file mode 100644 index 000000000000..ce5eea6bae6d --- /dev/null +++ b/resources/core/charts/application-connector/README.md @@ -0,0 +1,51 @@ +``` + _ _ _ _ _____ _ + /\ | (_) | | (_) / ____| | | + / \ _ __ _ __ | |_ ___ __ _| |_ _ ___ _ __ | | ___ _ __ _ __ ___ ___| |_ ___ _ __ + / /\ \ | '_ \| '_ \| | |/ __/ _` | __| |/ _ \| '_ \ | | / _ \| '_ \| '_ \ / _ \/ __| __/ _ \| '__| + / ____ \| |_) | |_) | | | (_| (_| | |_| | (_) | | | | | |___| (_) | | | | | | | __/ (__| || (_) | | + /_/ \_\ .__/| .__/|_|_|\___\__,_|\__|_|\___/|_| |_| \_____\___/|_| |_|_| |_|\___|\___|\__\___/|_| + | | | | + |_| |_| +``` + +## Overview + +The Application Connector connects an external solution to Kyma. + +## Details + +The Application Connector Helm chart contains all the global components: +- Metadata service +- Connector service + +### Metadata service + +Metadata service is a global component responsible for managing metadata of remote APIs. + +This service has the following parameters: + +- **proxyPort** - This port is used for services created for the Gateway proxy. The default port is `8080`. +- **externalAPIPort** - This port exposes the metadata API to an external system. The default port is `8081`. +- **miniuURL** - The URL of a Minio service which stores specifications and documentation. +- **namespace** - The Namespace to which you deploy the Gateway. The default Namespace is `kyma-integration`. +- **requestTimeout** - A time-out for requests sent through the Gateway. Provide it in seconds. The default time-out is `1`. +- **requestLogging** - The flag to enable logging of incoming requests. The default value is `false`. + +### Connector service + +Connector service is a global component responsible for automatic certificate configuration for external systems. + +The Connector Service has the following parameters: +- **appName** - This is the name of the application used by Kubernetes Deployments and services. +- **externalAPIPort** - This port exposes the Connector Service API to an external system. +- **internalAPIPort** - This port exposes the Connector Service within the Kubernetes cluster. +- **namespace** - Namespace where the Connector Service is deployed. +- **tokenLength** - Length of registration tokens. +- **tokenExpirationMinutes** - Time after which tokens expire and are no longer valid. +- **domainName** - Domain name of the cluster, used for URL generating. +- **certificateServiceHost** - Host at which this service is accessible, used for URL generating. + +### Installation + +The Application Connector is a part of the Kyma core and it installs automatically. diff --git a/resources/core/charts/application-connector/charts/connector-service/.helmignore b/resources/core/charts/application-connector/charts/connector-service/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/application-connector/charts/connector-service/.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/resources/core/charts/application-connector/charts/connector-service/Chart.yaml b/resources/core/charts/application-connector/charts/connector-service/Chart.yaml new file mode 100644 index 000000000000..24ce07bf2333 --- /dev/null +++ b/resources/core/charts/application-connector/charts/connector-service/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +description: Connector Service +name: connector-service +version: 0.0.1 +keywords: + - connector-service diff --git a/resources/core/charts/application-connector/charts/connector-service/README.md b/resources/core/charts/application-connector/charts/connector-service/README.md new file mode 100644 index 000000000000..94fdc9771183 --- /dev/null +++ b/resources/core/charts/application-connector/charts/connector-service/README.md @@ -0,0 +1,32 @@ +``` + ____ _ + / ___|___ _ __ _ __ ___ ___| |_ ___ _ __ + | | / _ \| '_ \| '_ \ / _ \/ __| __/ _ \| '__| + | |__| (_) | | | | | | | __/ (__| || (_) | | + \____\___/|_| |_|_| |_|\___|\___|\__\___/|_| + / ___| ___ _ ____ _(_) ___ ___ + \___ \ / _ \ '__\ \ / / |/ __/ _ \ + ___) | __/ | \ V /| | (_| __/ + |____/ \___|_| \_/ |_|\___\___| +``` + +## Overview +The Connector Service is responsible for generating and sending back client certificates based on Certificate Signing Request (CSR). + +## Configuration +The Connector Service has the following parameters, that can be set through the chart: +- **appName** - This is the name of the application used by Kubernetes deployments and services. The default value is `connector-service`. +- **externalAPIPort** - This port exposes the Connector Service API to an external solution. The default port is `8081`. +- **internalAPIPort** - This port exposes the Connector Service within Kubernetes cluster. The default port is `8080`. +- **namespace** - Namespace where Connector Service is deployed. The default Namespace is `kyma-integration`. +- **tokenLength** - Length of registration tokens. The default value is `64`. +- **tokenExpirationMinutes** - Time after which tokens expire and are no longer valid. The default value is `60` minutes. +- **domainName** - Domain name of the cluster, used for generating URL. Default domain name is `.wormhole.cluster.kyma.cx`. +- **certificateServiceHost** - Host at which this service is accessible, used for generating URL. Default host is `cert-service.wormhole.cluster.kyma.cx`. + +The Connector Service also uses the following environmental variables for CSR-related information config: +- **COUNTRY** (two-letter-long country code) +- **ORGANIZATION** +- **ORGANIZATIONALUNIT** +- **LOCALITY** +- **PROVINCE** diff --git a/resources/core/charts/application-connector/charts/connector-service/templates/_helpers.tpl b/resources/core/charts/application-connector/charts/connector-service/templates/_helpers.tpl new file mode 100644 index 000000000000..f0d83d2edba6 --- /dev/null +++ b/resources/core/charts/application-connector/charts/connector-service/templates/_helpers.tpl @@ -0,0 +1,16 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "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). +*/}} +{{- define "fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/resources/core/charts/application-connector/charts/connector-service/templates/deployment.yaml b/resources/core/charts/application-connector/charts/connector-service/templates/deployment.yaml new file mode 100644 index 000000000000..99a6f187cada --- /dev/null +++ b/resources/core/charts/application-connector/charts/connector-service/templates/deployment.yaml @@ -0,0 +1,53 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: {{ .Chart.Name }} + namespace: {{ .Values.global.namespace }} + labels: + app: {{ .Chart.Name }} + release: {{ .Chart.Name }} + heritage: {{ .Release.Service }} +spec: + replicas: 1 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + template: + metadata: + annotations: + sidecar.istio.io/inject: "true" + labels: + app: {{ .Chart.Name }} + release: {{ .Chart.Name }} + spec: + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.global.containerRegistry.path }}/{{ .Values.deployment.image.name }}:{{ .Values.deployment.image.tag }}" + imagePullPolicy: {{ .Values.deployment.image.pullPolicy }} + args: + - "/connectorservice" + - '--appName={{ .Chart.Name }}' + - "--externalAPIPort={{ .Values.deployment.args.externalAPIPort }}" + - "--internalAPIPort={{ .Values.deployment.args.internalAPIPort }}" + - "--namespace={{ .Values.global.namespace }}" + - "--tokenLength={{ .Values.deployment.args.tokenLength }}" + - "--tokenExpirationMinutes={{ .Values.deployment.args.tokenExpirationMinutes }}" + - "--domainName={{ .Values.global.domainName }}" + - "--connectorServiceHost=connector-service.{{ .Values.global.domainName }}" + env: + - name: COUNTRY + value: "{{ .Values.deployment.envvars.country }}" + - name: ORGANIZATION + value: "{{ .Values.deployment.envvars.organization }}" + - name: ORGANIZATIONALUNIT + value: "{{ .Values.deployment.envvars.organizationalunit }}" + - name: LOCALITY + value: "{{ .Values.deployment.envvars.locality }}" + - name: PROVINCE + value: "{{ .Values.deployment.envvars.province }}" + ports: + - containerPort: {{ .Values.deployment.args.internalAPIPort }} + name: int-api-port + - containerPort: {{ .Values.deployment.args.externalAPIPort }} + name: ext-api-port diff --git a/resources/core/charts/application-connector/charts/connector-service/templates/ingress.yaml b/resources/core/charts/application-connector/charts/connector-service/templates/ingress.yaml new file mode 100644 index 000000000000..00c5d36db9dd --- /dev/null +++ b/resources/core/charts/application-connector/charts/connector-service/templates/ingress.yaml @@ -0,0 +1,24 @@ +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: istio + name: {{ .Chart.Name }} + namespace: {{ .Values.global.namespace }} + labels: + app: {{ template "name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + rules: + - host: connector-service.{{ .Values.global.domainName }} + http: + paths: + - path: /.* + backend: + serviceName: {{ .Chart.Name }}-external-api + servicePort: 8081 + + tls: + - secretName: {{.Values.global.istio.tls.secretName }} diff --git a/resources/core/charts/application-connector/charts/connector-service/templates/role-binding.yaml b/resources/core/charts/application-connector/charts/connector-service/templates/role-binding.yaml new file mode 100644 index 000000000000..d2e449823dd3 --- /dev/null +++ b/resources/core/charts/application-connector/charts/connector-service/templates/role-binding.yaml @@ -0,0 +1,31 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ .Chart.Name }}-role + namespace: {{ .Values.global.namespace }} + labels: + app: {{ .Chart.Name }} + release: {{ .Chart.Name }} + heritage: {{ .Release.Service }} +rules: +- apiGroups: ["*"] + resources: ["secrets"] + verbs: ["create", "get", "update", "delete"] +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ .Chart.Name }}-rolebinding + namespace: {{ .Values.global.namespace }} + labels: + app: {{ .Chart.Name }} + release: {{ .Chart.Name }} + heritage: {{ .Release.Service }} +subjects: +- kind: User + name: system:serviceaccount:{{ .Values.global.namespace }}:default + apiGroup: rbac.authorization.k8s.io +roleRef: + kind: Role + name: {{ .Chart.Name }}-role + apiGroup: rbac.authorization.k8s.io diff --git a/resources/core/charts/application-connector/charts/connector-service/templates/service.yaml b/resources/core/charts/application-connector/charts/connector-service/templates/service.yaml new file mode 100644 index 000000000000..e31c8154e5e4 --- /dev/null +++ b/resources/core/charts/application-connector/charts/connector-service/templates/service.yaml @@ -0,0 +1,37 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Chart.Name }}-external-api + namespace: {{ .Values.global.namespace }} + labels: + app: {{ .Chart.Name }} + release: {{ .Chart.Name }} + heritage: {{ .Release.Service }} +spec: + type: ClusterIP + ports: + - port: {{ .Values.service.externalapi.port }} + protocol: TCP + name: http + selector: + app: {{ .Chart.Name }} + release: {{ .Chart.Name }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Chart.Name }}-internal-api + namespace: {{ .Values.global.namespace }} + labels: + app: {{ .Chart.Name }} + release: {{ .Chart.Name }} + heritage: {{ .Release.Service }} +spec: + type: {{ .Values.service.internalapi.serviceType }} + ports: + - port: {{ .Values.service.internalapi.port }} + protocol: TCP + name: int-api-port + selector: + app: {{ .Chart.Name }} + release: {{ .Chart.Name }} diff --git a/resources/core/charts/application-connector/charts/connector-service/templates/tests/test-acceptance.yaml b/resources/core/charts/application-connector/charts/connector-service/templates/tests/test-acceptance.yaml new file mode 100644 index 000000000000..3a92c30a8717 --- /dev/null +++ b/resources/core/charts/application-connector/charts/connector-service/templates/tests/test-acceptance.yaml @@ -0,0 +1,23 @@ +{{ if not .Values.global.isLocalEnv }} +apiVersion: v1 +kind: Pod +metadata: + name: {{ .Chart.Name }}-tests + annotations: + "helm.sh/hook": test-success + labels: + "helm-chart-test": "true" +spec: + containers: + - name: {{ .Chart.Name }}-tests + image: "{{ .Values.global.containerRegistry.path }}/{{ .Values.tests.image.name }}:{{ .Values.tests.image.tag }}" + imagePullPolicy: {{ .Values.tests.image.pullPolicy }} + env: + - name: INTERNAL_API_URL + value: http://{{ .Chart.Name }}-internal-api.{{ .Values.global.namespace }}.svc.cluster.local:{{ .Values.service.internalapi.port }} + - name: EXTERNAL_API_URL + value: http://{{ .Chart.Name }}-external-api.{{ .Values.global.namespace }}.svc.cluster.local:{{ .Values.service.externalapi.port }} + - name: GATEWAY_URL + value: https://gateway.{{ .Values.global.domainName }} + restartPolicy: Never +{{ end }} diff --git a/resources/core/charts/application-connector/charts/connector-service/values.yaml b/resources/core/charts/application-connector/charts/connector-service/values.yaml new file mode 100644 index 000000000000..615b175b3056 --- /dev/null +++ b/resources/core/charts/application-connector/charts/connector-service/values.yaml @@ -0,0 +1,30 @@ +deployment: + image: + name: connector-service + tag: 0.2.63 + pullPolicy: IfNotPresent + args: + internalAPIPort: &internalAPIPort 8080 + externalAPIPort: &externalAPIPort 8081 + tokenLength: 64 + tokenExpirationMinutes: 60 + envvars: + country: DE + organization: Organization + organizationalunit: OrgUnit + locality: Waldorf + province: Waldorf + +service: + externalapi: + port: *externalAPIPort + nodePort: 32010 + internalapi: + serviceType: ClusterIP + port: *internalAPIPort + +tests: + image: + name: connector-service-tests + tag: 0.2.134 + pullPolicy: IfNotPresent diff --git a/resources/core/charts/application-connector/charts/metadata-service/Chart.yaml b/resources/core/charts/application-connector/charts/metadata-service/Chart.yaml new file mode 100644 index 000000000000..5a815355e33c --- /dev/null +++ b/resources/core/charts/application-connector/charts/metadata-service/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: Application Connector for Kyma +name: metadata-service +version: 0.0.1 diff --git a/resources/core/charts/application-connector/charts/metadata-service/templates/_helpers.tpl b/resources/core/charts/application-connector/charts/metadata-service/templates/_helpers.tpl new file mode 100644 index 000000000000..f0d83d2edba6 --- /dev/null +++ b/resources/core/charts/application-connector/charts/metadata-service/templates/_helpers.tpl @@ -0,0 +1,16 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "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). +*/}} +{{- define "fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/resources/core/charts/application-connector/charts/metadata-service/templates/deployment.yaml b/resources/core/charts/application-connector/charts/metadata-service/templates/deployment.yaml new file mode 100644 index 000000000000..3a5480f208ce --- /dev/null +++ b/resources/core/charts/application-connector/charts/metadata-service/templates/deployment.yaml @@ -0,0 +1,52 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: {{ .Chart.Name }} + namespace: {{ .Values.global.namespace }} + labels: + app: {{ .Chart.Name }} + release: {{ .Chart.Name }} + heritage: {{ .Release.Service }} +spec: + replicas: 1 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + template: + metadata: + annotations: + sidecar.istio.io/inject: "true" + labels: + app: {{ .Chart.Name }} + release: {{ .Chart.Name }} + spec: + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.global.containerRegistry.path }}/{{ .Values.deployment.image.name }}:{{ .Values.deployment.image.tag }}" + imagePullPolicy: {{ .Values.deployment.image.pullPolicy }} + args: + - "/metadata" + - '--appName={{ .Chart.Name }}' + - "--proxyPort={{ .Values.deployment.args.proxyPort }}" + - "--externalAPIPort={{ .Values.deployment.args.externalAPIPort }}" + - "--minioURL={{ .Values.deployment.args.minioURL }}" + - "--namespace={{ .Values.global.namespace }}" + - "--requestTimeout={{ .Values.deployment.args.requestTimeout }}" + - "--requestLogging={{ .Values.deployment.args.requestLogging }}" + env: + - name: MINIO_ACCESSKEYID + valueFrom: + secretKeyRef: + name: {{ .Values.minioUserSecretName }} + key: accesskey + - name: MINIO_ACCESSKEYSECRET + valueFrom: + secretKeyRef: + name: {{ .Values.minioUserSecretName }} + key: secretkey + ports: + - containerPort: {{ .Values.deployment.args.proxyPort }} + name: proxy-port + - containerPort: {{ .Values.deployment.args.externalAPIPort }} + name: ext-api-port diff --git a/resources/core/charts/application-connector/charts/metadata-service/templates/role-binding.yaml b/resources/core/charts/application-connector/charts/metadata-service/templates/role-binding.yaml new file mode 100644 index 000000000000..5e0173722e8f --- /dev/null +++ b/resources/core/charts/application-connector/charts/metadata-service/templates/role-binding.yaml @@ -0,0 +1,40 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ .Chart.Name }}-role + namespace: {{ .Values.global.namespace }} + labels: + app: {{ .Chart.Name }} + release: {{ .Chart.Name }} + heritage: {{ .Release.Service }} +rules: +- apiGroups: ["remoteenvironment.kyma.cx"] + resources: ["remoteenvironments"] + verbs: ["get", "update", "list"] +- apiGroups: ["*"] + resources: ["services"] + verbs: ["create", "get", "delete"] +- apiGroups: ["*"] + resources: ["secrets"] + verbs: ["create", "get", "update", "delete"] +- apiGroups: ["config.istio.io"] + resources: ["rules", "deniers", "checknothings", "egressrules"] + verbs: ["get", "update", "list", "create", "delete"] +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ .Chart.Name }}-rolebinding + namespace: {{ .Values.global.namespace }} + labels: + app: {{ .Chart.Name }} + release: {{ .Chart.Name }} + heritage: {{ .Release.Service }} +subjects: +- kind: User + name: system:serviceaccount:{{ .Values.global.namespace }}:default + apiGroup: rbac.authorization.k8s.io +roleRef: + kind: Role + name: {{ .Chart.Name }}-role + apiGroup: rbac.authorization.k8s.io diff --git a/resources/core/charts/application-connector/charts/metadata-service/templates/service.yaml b/resources/core/charts/application-connector/charts/metadata-service/templates/service.yaml new file mode 100644 index 000000000000..dff1985d9706 --- /dev/null +++ b/resources/core/charts/application-connector/charts/metadata-service/templates/service.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + "auth.istio.io/{{ .Values.service.externalapi.port }}": NONE + name: {{ .Chart.Name }}-external-api + namespace: {{ .Values.global.namespace }} + labels: + app: {{ .Chart.Name }} + release: {{ .Chart.Name }} + heritage: {{ .Release.Service }} +spec: + type: ClusterIP + ports: + - port: {{ .Values.service.externalapi.port }} + protocol: TCP + name: ext-api-port + selector: + app: {{ .Chart.Name }} + release: {{ .Chart.Name }} diff --git a/resources/core/charts/application-connector/charts/metadata-service/templates/tests/test-acceptance.yaml b/resources/core/charts/application-connector/charts/metadata-service/templates/tests/test-acceptance.yaml new file mode 100644 index 000000000000..6fdbd05f0952 --- /dev/null +++ b/resources/core/charts/application-connector/charts/metadata-service/templates/tests/test-acceptance.yaml @@ -0,0 +1,54 @@ +{{ if not .Values.global.isLocalEnv }} +apiVersion: v1 +kind: Pod +metadata: + name: {{ .Chart.Name }}-tests + namespace: {{ .Values.global.namespace }} + annotations: + "helm.sh/hook": test-success + labels: + "helm-chart-test": "true" +spec: + containers: + - name: {{ .Chart.Name }}-tests + image: {{ .Values.global.containerRegistry.path }}/{{ .Values.tests.image.name }}:{{ .Values.tests.image.tag }} + imagePullPolicy: IfNotPresent + env: + - name: METADATA_URL + value: http://{{ .Chart.Name }}-external-api.{{ .Values.global.namespace }}.svc.cluster.local:8081 + - name: NAMESPACE + value: {{ .Values.global.namespace }} + restartPolicy: Never +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ .Chart.Name }}-tests-role + namespace: {{ .Values.global.namespace }} + labels: + app: {{ .Chart.Name }}-tests + release: {{ .Chart.Name }}-tests + heritage: {{ .Release.Service }} +rules: +- apiGroups: ["remoteenvironment.kyma.cx"] + resources: ["remoteenvironments"] + verbs: ["get", "update", "create", "delete", "list"] +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ .Chart.Name }}-tests-rolebinding + namespace: {{ .Values.global.namespace }} + labels: + app: {{ .Chart.Name }}-tests + release: {{ .Chart.Name }}-tests + heritage: {{ .Release.Service }} +subjects: +- kind: User + name: system:serviceaccount:{{ .Values.global.namespace }}:default + apiGroup: rbac.authorization.k8s.io +roleRef: + kind: Role + name: {{ .Chart.Name }}-tests-role + apiGroup: rbac.authorization.k8s.io +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/application-connector/charts/metadata-service/values.yaml b/resources/core/charts/application-connector/charts/metadata-service/values.yaml new file mode 100644 index 000000000000..ceacdb8c2fdf --- /dev/null +++ b/resources/core/charts/application-connector/charts/metadata-service/values.yaml @@ -0,0 +1,24 @@ +deployment: + image: + name: application-connector + tag: 0.2.56 + pullPolicy: IfNotPresent + args: + proxyPort: &proxyPort 8080 + externalAPIPort: &externalAPIPort 8081 + minioURL: core-minio.kyma-system.svc.cluster.local:9000 + requestTimeout: 10 + requestLogging: false + +service: + externalapi: + port: *externalAPIPort + nodePort: 32020 + +minioUserSecretName: remoteenvironments-minio-user + +tests: + image: + name: application-connector-tests + tag: 0.2.56 + pullPolicy: IfNotPresent diff --git a/resources/core/charts/application-connector/requirements.yaml b/resources/core/charts/application-connector/requirements.yaml new file mode 100644 index 000000000000..d04ebc57a919 --- /dev/null +++ b/resources/core/charts/application-connector/requirements.yaml @@ -0,0 +1,2 @@ +dependencies: + - name: metadata-service diff --git a/resources/core/charts/application-connector/values.yaml b/resources/core/charts/application-connector/values.yaml new file mode 100644 index 000000000000..8eb01fad0d66 --- /dev/null +++ b/resources/core/charts/application-connector/values.yaml @@ -0,0 +1,2 @@ +global: + namespace: kyma-integration diff --git a/resources/core/charts/azure-broker/.helmignore b/resources/core/charts/azure-broker/.helmignore new file mode 100755 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/azure-broker/.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/resources/core/charts/azure-broker/Chart.yaml b/resources/core/charts/azure-broker/Chart.yaml new file mode 100755 index 000000000000..cb5319b6256f --- /dev/null +++ b/resources/core/charts/azure-broker/Chart.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: v1 +description: A Helm chart for Open Service Broker For Azure +name: azure-broker +home: https://github.com/azure/open-service-broker-azure +keywords: + - azure + - services + - service broker +sources: + - https://github.com/azure/open-service-broker-azure + - https://hub.docker.com/r/microsoft/azure-service-broker/ +tillerVersion: '>=2.7.0-rc1' +appVersion: v0.6.0-alpha +version: 0.4.1 diff --git a/resources/core/charts/azure-broker/README.md b/resources/core/charts/azure-broker/README.md new file mode 100644 index 000000000000..b9bc8626c808 --- /dev/null +++ b/resources/core/charts/azure-broker/README.md @@ -0,0 +1,23 @@ +``` + ____ _ + /\ | _ \ | | + / \ _____ _ _ __ ___ | |_) |_ __ ___ | | _____ _ __ + / /\ \ |_ / | | | '__/ _ \ | _ <| '__/ _ \| |/ / _ \ '__| + / ____ \ / /| |_| | | | __/ | |_) | | | (_) | < __/ | +/_/ \_\/___|\__,_|_| \___| |____/|_| \___/|_|\_\___|_| + +``` + +## Overview + +The [Azure Broker](https://github.com/Azure/open-service-broker-azure) is an open source, [Open Service Broker](https://www.openservicebrokerapi.org/)-compatible +API server that provisions managed services in the Microsoft Azure public cloud. +This chart is based on the [Azure Open Service Broker](https://github.com/Azure/open-service-broker-azure/tree/master/contrib/k8s/charts/open-service-broker-azure) chart and runs in Kyma. + + +## Details + +The [azure-broker-basic-auth](templates/azure-broker-basic-auth.yaml) file contains the user name and password for basic authentication used by the Service Catalog. +The [azure-broker-redis](templates/azure-broker-redis.yaml) file contains Redis specific data. + +For more information about the Service Brokers, see [this](../../../../docs/service-brokers/docs) repository. diff --git a/resources/core/charts/azure-broker/charts/redis/.helmignore b/resources/core/charts/azure-broker/charts/redis/.helmignore new file mode 100755 index 000000000000..6b8710a711f3 --- /dev/null +++ b/resources/core/charts/azure-broker/charts/redis/.helmignore @@ -0,0 +1 @@ +.git diff --git a/resources/core/charts/azure-broker/charts/redis/Chart.yaml b/resources/core/charts/azure-broker/charts/redis/Chart.yaml new file mode 100755 index 000000000000..c238dde37fcf --- /dev/null +++ b/resources/core/charts/azure-broker/charts/redis/Chart.yaml @@ -0,0 +1,18 @@ +appVersion: 3.2.9 +description: Open source, advanced key-value store. It is often referred to as a data + structure server since keys can contain strings, hashes, lists, sets and sorted + sets. +engine: gotpl +home: http://redis.io/ +icon: https://bitnami.com/assets/stacks/redis/img/redis-stack-220x234.png +keywords: +- redis +- keyvalue +- database +maintainers: +- email: containers@bitnami.com + name: bitnami-bot +name: redis +sources: +- https://github.com/bitnami/bitnami-docker-redis +version: 0.10.0 diff --git a/resources/core/charts/azure-broker/charts/redis/README.md b/resources/core/charts/azure-broker/charts/redis/README.md new file mode 100755 index 000000000000..ef866f7c98e7 --- /dev/null +++ b/resources/core/charts/azure-broker/charts/redis/README.md @@ -0,0 +1,129 @@ +``` + _____ _ _ +| __ \ | (_) +| |__) |___ __| |_ ___ +| _ // _ \/ _` | / __| +| | \ \ __/ (_| | \__ \ +|_| \_\___|\__,_|_|___/ +``` + +## Overview + +[Redis](http://redis.io/) is an advanced key-value cache and store. It is often referred to as a data structure server, since keys can contain strings, hashes, lists, sets, sorted sets, bitmaps, and hyperloglogs. + +## Prerequisites + +- Kubernetes 1.4+ with Beta APIs enabled +- PV provisioner support in the underlying infrastructure + +## Details + +To install Redis: + +```bash +$ helm install stable/redis +``` + +This chart bootstraps a [Redis](https://github.com/bitnami/bitnami-docker-redis) deployment on a [Kubernetes](http://kubernetes.io) cluster using the [Helm](https://helm.sh) package manager. + +### Install the chart + +To install the chart with the release name `my-release`: + +```bash +$ helm install --name my-release stable/redis +``` + +The command deploys Redis on the Kubernetes cluster in the default configuration. The [configuration](#configuration) section lists the parameters you can configure during installation. + +> **NOTE:** List all releases using `helm list`. + +### Uninstall the chart + +To delete the `my-release` deployment: + +```bash +$ helm delete my-release +``` + +The command removes all the Kubernetes components associated with the chart and deletes the release. + +### Configuration + +The following table lists the configurable parameters of the Redis chart and their default values. + +| Parameter | Description | Default | +| -------------------------- | ------------------------------------- | --------------------------------------------------------- | +| `image` | Redis image | `bitnami/redis:{VERSION}` | +| `imagePullPolicy` | Image pull policy | `IfNotPresent` | +| `usePassword` | Use password | `true` | +| `redisPassword` | Redis password | Randomly generated | +| `args` | Redis command-line args | [] | +| `persistence.enabled` | Use a PVC to persist data | `true` | +| `persistence.existingClaim`| Use an existing PVC to persist data | `nil` | +| `persistence.storageClass` | Storage class of backing PVC | `generic` | +| `persistence.accessMode` | Use volume as ReadOnly or ReadWrite | `ReadWriteOnce` | +| `persistence.size` | Size of data volume | `8Gi` | +| `resources` | CPU/Memory resource requests/limits | Memory: `256Mi` | +| `metrics.enabled` | Start a side-car prometheus exporter | `false` | +| `metrics.image` | Exporter image | `oliver006/redis_exporter` | +| `metrics.imageTag` | Exporter image | `v0.11` | +| `metrics.imagePullPolicy` | Exporter image pull policy | `IfNotPresent` | +| `metrics.resources` | Exporter resource requests/limit | Memory: `256Mi` | +| `nodeSelector` | Node labels for pod assignment | {} | +| `tolerations` | Toleration labels for pod assignment | [] | +| `networkPolicy.enabled` | Enable NetworkPolicy | `false` | +| `networkPolicy.allowExternal` | Do not require client label for connections | `true` | + +The above parameters map to the env variables defined in [bitnami/redis](http://github.com/bitnami/bitnami-docker-redis). For more information, refer to the [bitnami/redis](http://github.com/bitnami/bitnami-docker-redis) image documentation. + +Specify each parameter using the `--set key=value[,key=value]` argument to `helm install`. For example, + +```bash +$ helm install --name my-release \ + --set redisPassword=secretpassword \ + stable/redis +``` + +The above command sets the Redis server password to `secretpassword`. + +Alternatively, you can provide a YAML file that specifies the values for the parameters while installing the chart. For example: + +```bash +$ helm install --name my-release -f values.yaml stable/redis +``` + +> **NOTE:** You can use the default [values.yaml](values.yaml). + +### NetworkPolicy + +To enable network policy for Redis, install a networking plugin that implements the Kubernetes [NetworkPolicy spec](https://kubernetes.io/docs/tasks/administer-cluster/declare-network-policy#before-you-begin), and set `networkPolicy.enabled` to `true`. + +For Kubernetes v1.5 and v1.6, turn on NetworkPolicy by setting the DefaultDeny namespace annotation. + +> **NOTE:** This enforces policy for all pods in the namespace. + +``` + kubectl annotate namespace default "net.beta.kubernetes.io/network-policy={\"ingress\":{\"isolation\":\"DefaultDeny\"}}" +``` + +With NetworkPolicy enabled, only pods with the generated client label can connect to Redis. The output displays this label after a successful install. + +### Persistence + +The [Bitnami Redis](https://github.com/bitnami/bitnami-docker-redis) image stores the Redis data and configurations at the `/bitnami/redis` path of the container. + +By default, the chart mounts a [PersistentVolume](http://kubernetes.io/docs/user-guide/persistent-volumes/) volume at this location. The system creates the volume using dynamic volume provisioning. If a PersistentVolumeClaim already exists, specify it during installation. + +#### Existing PersistentVolumeClaim + +1. Create the PersistentVolume. +2. Create the PersistentVolumeClaim. +3. Install the chart. + +```bash +$ helm install --set persistence.existingClaim=PVC_NAME redis +``` + +### Metrics +Optionally, the chart can start a metrics exporter for [prometheus](https://prometheus.io). The system does not expose the metrics endpoint (port 9121), and it is expected that you collect the metrics from inside the Kubernetes cluster using something similar to the [example Prometheus scrape configuration](https://github.com/prometheus/prometheus/blob/master/documentation/examples/prometheus-kubernetes.yml). diff --git a/resources/core/charts/azure-broker/charts/redis/templates/NOTES.txt b/resources/core/charts/azure-broker/charts/redis/templates/NOTES.txt new file mode 100755 index 000000000000..1110b6ba8b49 --- /dev/null +++ b/resources/core/charts/azure-broker/charts/redis/templates/NOTES.txt @@ -0,0 +1,28 @@ +Redis can be accessed via port 6379 on the following DNS name from within your cluster: +{{ template "redis.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local + +{{- if .Values.usePassword }} +To get your password run: + + REDIS_PASSWORD=$(kubectl get secret --namespace {{ .Release.Namespace }} {{ template "redis.fullname" . }} -o jsonpath="{.data.redis-password}" | base64 --decode) +{{- end }} + +To connect to your Redis server: + +1. Run a Redis pod that you can use as a client: + + kubectl run {{ template "redis.fullname" . }}-client --rm --tty -i \ + {{ if .Values.usePassword }} --env REDIS_PASSWORD=$REDIS_PASSWORD{{ end }} + {{- if and (.Values.networkPolicy.enabled) (not .Values.networkPolicy.allowExternal) }}--labels="{{ template "redis.fullname" . }}-client=true" \{{- end }} + --image {{ .Values.image }} -- bash + +2. Connect using the Redis CLI: + + redis-cli -h {{ template "redis.fullname" . }}{{ if .Values.usePassword }} -a $REDIS_PASSWORD{{ end }} + +{{ if and (.Values.networkPolicy.enabled) (not .Values.networkPolicy.allowExternal) }} +Note: Since NetworkPolicy is enabled, only pods with label +{{ template "redis.fullname" . }}-client=true" +will be able to connect to redis. +{{- end }} + diff --git a/resources/core/charts/azure-broker/charts/redis/templates/_helpers.tpl b/resources/core/charts/azure-broker/charts/redis/templates/_helpers.tpl new file mode 100755 index 000000000000..f96369929fa2 --- /dev/null +++ b/resources/core/charts/azure-broker/charts/redis/templates/_helpers.tpl @@ -0,0 +1,27 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "redis.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). +*/}} +{{- define "redis.fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Return the appropriate apiVersion for networkpolicy. +*/}} +{{- define "networkPolicy.apiVersion" -}} +{{- if and (ge .Capabilities.KubeVersion.Minor "4") (le .Capabilities.KubeVersion.Minor "6") -}} +{{- print "extensions/v1beta1" -}} +{{- else if ge .Capabilities.KubeVersion.Minor "7" -}} +{{- print "networking.k8s.io/v1" -}} +{{- end -}} +{{- end -}} \ No newline at end of file diff --git a/resources/core/charts/azure-broker/charts/redis/templates/deployment.yaml b/resources/core/charts/azure-broker/charts/redis/templates/deployment.yaml new file mode 100755 index 000000000000..da4d2e0183e7 --- /dev/null +++ b/resources/core/charts/azure-broker/charts/redis/templates/deployment.yaml @@ -0,0 +1,95 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: {{ template "redis.fullname" . }} + labels: + app: {{ template "redis.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +spec: + template: + metadata: + labels: + app: {{ template "redis.fullname" . }} + spec: + {{- if .Values.nodeSelector }} + nodeSelector: +{{ toYaml .Values.nodeSelector | indent 8 }} + {{- end }} + {{- if .Values.tolerations }} + tolerations: +{{ toYaml .Values.tolerations | indent 8 }} + {{- end }} + containers: + - name: {{ template "redis.fullname" . }} + image: "{{ .Values.image }}" + imagePullPolicy: {{ default "" .Values.imagePullPolicy | quote }} + resources: + limits: + memory: 512Mi + requests: + memory: 256Mi + {{- if .Values.args }} + args: +{{ toYaml .Values.args | indent 10 }} + {{- end }} + env: + {{- if .Values.usePassword }} + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "redis.fullname" . }} + key: redis-password + {{- else }} + - name: ALLOW_EMPTY_PASSWORD + value: "yes" + {{- end }} + ports: + - name: redis + containerPort: 6379 + livenessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 30 + timeoutSeconds: 5 + readinessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 5 + timeoutSeconds: 1 + volumeMounts: + - name: redis-data + mountPath: /bitnami/redis +{{- if .Values.metrics.enabled }} + - name: metrics + image: "{{ .Values.metrics.image }}:{{ .Values.metrics.imageTag }}" + imagePullPolicy: {{ .Values.metrics.imagePullPolicy | quote }} + env: + - name: REDIS_ALIAS + value: {{ template "redis.fullname" . }} + {{- if .Values.usePassword }} + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "redis.fullname" . }} + key: redis-password + {{- end }} + ports: + - name: metrics + containerPort: 9121 + resources: +{{ toYaml .Values.metrics.resources | indent 10 }} +{{- end }} + volumes: + - name: redis-data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ .Values.persistence.existingClaim | default (include "redis.fullname" .) }} + {{- else }} + emptyDir: {} + {{- end -}} diff --git a/resources/core/charts/azure-broker/charts/redis/templates/networkpolicy.yaml b/resources/core/charts/azure-broker/charts/redis/templates/networkpolicy.yaml new file mode 100755 index 000000000000..eb6640f073ac --- /dev/null +++ b/resources/core/charts/azure-broker/charts/redis/templates/networkpolicy.yaml @@ -0,0 +1,30 @@ +{{- if .Values.networkPolicy.enabled }} +kind: NetworkPolicy +apiVersion: {{ template "networkPolicy.apiVersion" . }} +metadata: + name: "{{ template "redis.fullname" . }}" + labels: + app: {{ template "redis.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +spec: + podSelector: + matchLabels: + app: {{ template "redis.fullname" . }} + ingress: + # Allow inbound connections + - ports: + - port: 6379 + {{- if not .Values.networkPolicy.allowExternal }} + from: + - podSelector: + matchLabels: + {{ template "redis.fullname" . }}-client: "true" + {{- end }} + {{- if .Values.metrics.enabled }} + # Allow prometheus scrapes for metrics + - ports: + - port: 9121 + {{- end }} +{{- end }} diff --git a/resources/core/charts/azure-broker/charts/redis/templates/pvc.yaml b/resources/core/charts/azure-broker/charts/redis/templates/pvc.yaml new file mode 100755 index 000000000000..58c7e0164d67 --- /dev/null +++ b/resources/core/charts/azure-broker/charts/redis/templates/pvc.yaml @@ -0,0 +1,23 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +kind: PersistentVolumeClaim +apiVersion: v1 +metadata: + name: {{ template "redis.fullname" . }} + labels: + app: {{ template "redis.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" + annotations: + {{- if not .Values.global.isLocalEnv }} + volume.beta.kubernetes.io/storage-class: "managed-standard" + {{- else }} + volume.alpha.kubernetes.io/storage-class: default + {{- end }} +spec: + accessModes: + - {{ .Values.persistence.accessMode | quote }} + resources: + requests: + storage: {{ .Values.persistence.size | quote }} +{{- end }} diff --git a/resources/core/charts/azure-broker/charts/redis/templates/secrets.yaml b/resources/core/charts/azure-broker/charts/redis/templates/secrets.yaml new file mode 100755 index 000000000000..6ab6b053e52c --- /dev/null +++ b/resources/core/charts/azure-broker/charts/redis/templates/secrets.yaml @@ -0,0 +1,18 @@ +{{- if .Values.usePassword -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ template "redis.fullname" . }} + labels: + app: {{ template "redis.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +type: Opaque +data: + {{- if .Values.redisPassword }} + redis-password: {{ .Values.redisPassword | b64enc | quote }} + {{- else }} + redis-password: {{ randAlphaNum 10 | b64enc | quote }} + {{- end }} +{{- end -}} diff --git a/resources/core/charts/azure-broker/charts/redis/templates/svc.yaml b/resources/core/charts/azure-broker/charts/redis/templates/svc.yaml new file mode 100755 index 000000000000..5081e1ebae5b --- /dev/null +++ b/resources/core/charts/azure-broker/charts/redis/templates/svc.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ template "redis.fullname" . }} + labels: + app: {{ template "redis.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +{{- if .Values.metrics.enabled }} + annotations: +{{ toYaml .Values.metrics.annotations | indent 4 }} +{{- end }} +spec: + ports: + - name: redis + port: 6379 + targetPort: redis + selector: + app: {{ template "redis.fullname" . }} diff --git a/resources/core/charts/azure-broker/charts/redis/values.yaml b/resources/core/charts/azure-broker/charts/redis/values.yaml new file mode 100755 index 000000000000..92fc975321b7 --- /dev/null +++ b/resources/core/charts/azure-broker/charts/redis/values.yaml @@ -0,0 +1,74 @@ +## Bitnami Redis image version +## ref: https://hub.docker.com/r/bitnami/redis/tags/ +## +image: bitnami/redis:3.2.9-r2 + +## Specify a imagePullPolicy +## ref: http://kubernetes.io/docs/user-guide/images/#pre-pulling-images +## +imagePullPolicy: IfNotPresent + +## Use password authentication +usePassword: true + +## Redis password +## Defaults to a random 10-character alphanumeric string if not set and usePassword is true +## ref: https://github.com/bitnami/bitnami-docker-redis#setting-the-server-password-on-first-run +## +# redisPassword: + +## Redis command arguments +## +## Can be used to specify command line arguments, for example: +## +## args: +## - "redis-server" +## - "--maxmemory-policy volatile-ttl" +args: + +## Enable persistence using Persistent Volume Claims +## ref: http://kubernetes.io/docs/user-guide/persistent-volumes/ +## +persistence: + enabled: true + + ## A manually managed Persistent Volume and Claim + ## Requires persistence.enabled: true + ## If defined, PVC must be created manually before volume will be bound + # existingClaim: + + ## If defined, volume.beta.kubernetes.io/storage-class: + ## Default: volume.alpha.kubernetes.io/storage-class: default + ## + # storageClass: + accessMode: ReadWriteOnce + size: 8Gi + +metrics: + enabled: false + image: oliver006/redis_exporter + imageTag: v0.11 + imagePullPolicy: IfNotPresent + resources: {} + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9121" + +## Node labels and tolerations for pod assignment +## ref: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#nodeselector +## ref: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#taints-and-tolerations-beta-feature +nodeSelector: {} +tolerations: [] + +networkPolicy: + ## Enable creation of NetworkPolicy resources. + ## + enabled: false + + ## The Policy model to apply. When set to false, only pods with the correct + ## client label will have network access to the port PostgreSQL is listening + ## on. When true, Redis will accept connections from any source + ## (with the correct destination port). + ## + allowExternal: true + diff --git a/resources/core/charts/azure-broker/templates/_helpers.tpl b/resources/core/charts/azure-broker/templates/_helpers.tpl new file mode 100644 index 000000000000..f0d83d2edba6 --- /dev/null +++ b/resources/core/charts/azure-broker/templates/_helpers.tpl @@ -0,0 +1,16 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "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). +*/}} +{{- define "fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/resources/core/charts/azure-broker/templates/azure-broker-basic-auth.yaml b/resources/core/charts/azure-broker/templates/azure-broker-basic-auth.yaml new file mode 100644 index 000000000000..c4b3e36138ac --- /dev/null +++ b/resources/core/charts/azure-broker/templates/azure-broker-basic-auth.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ template "fullname" . }}-basic-auth + labels: + app: {{ template "fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +type: Opaque +data: + username: {{ b64enc .Values.basicAuth.username }} + password: {{ b64enc .Values.basicAuth.password }} diff --git a/resources/core/charts/azure-broker/templates/azure-broker-credentials.yaml b/resources/core/charts/azure-broker/templates/azure-broker-credentials.yaml new file mode 100644 index 000000000000..628c5879585a --- /dev/null +++ b/resources/core/charts/azure-broker/templates/azure-broker-credentials.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ template "fullname" . }}-credentials + labels: + app: {{ template "fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +stringData: + environment: AzureCloud + subscription_id: {{ .Values.subscription_id | default "Not provided" }} + tenant_id: {{ .Values.tenant_id | default "Not provided" }} + client_id: {{ .Values.client_id | default "Not provided" }} + client_secret: {{ .Values.client_secret | default "Not provided" }} diff --git a/resources/core/charts/azure-broker/templates/azure-broker-redis.yaml b/resources/core/charts/azure-broker/templates/azure-broker-redis.yaml new file mode 100644 index 000000000000..e9d174cc9a86 --- /dev/null +++ b/resources/core/charts/azure-broker/templates/azure-broker-redis.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ template "fullname" . }}-redis + labels: + app: {{ template "fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +stringData: + encryption-key: {{ randAlphaNum 32 }} + redis-password: {{ .Values.redis.redisPassword | quote }} diff --git a/resources/core/charts/azure-broker/templates/azure-service-classes-docu.yaml b/resources/core/charts/azure-broker/templates/azure-service-classes-docu.yaml new file mode 100644 index 000000000000..d162e8e129f7 --- /dev/null +++ b/resources/core/charts/azure-broker/templates/azure-service-classes-docu.yaml @@ -0,0 +1,24 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ template "fullname" . }}-docs + annotations: + "helm.sh/hook": post-install + "helm.sh/hook-weight": "1" + "helm.sh/hook-delete-policy": hook-succeeded +spec: + activeDeadlineSeconds: 180 + template: + metadata: + name: {{ template "fullname" . }}-docs + labels: + inject: docs-upload-config + spec: + restartPolicy: Never + containers: + - name: azure-redis-cache-docs + image: {{ .Values.global.containerRegistry.path }}/azure-redis-cache-docs:0.0.8 + - name: azure-mysql-docs + image: {{ .Values.global.containerRegistry.path }}/azure-mysql-docs:0.0.8 + - name: azure-sql-docs + image: {{ .Values.global.containerRegistry.path }}/azure-sql-docs:0.0.8 diff --git a/resources/core/charts/azure-broker/templates/broker.yaml b/resources/core/charts/azure-broker/templates/broker.yaml new file mode 100644 index 000000000000..ac514166cc5a --- /dev/null +++ b/resources/core/charts/azure-broker/templates/broker.yaml @@ -0,0 +1,15 @@ +{{- if .Release.IsUpgrade -}} +{{- if .Values.registerBroker }} +apiVersion: servicecatalog.k8s.io/v1beta1 +kind: ClusterServiceBroker +metadata: + name: {{ template "fullname" . }} +spec: + url: http://{{ template "fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local + authInfo: + basic: + secretRef: + name: "{{ template "fullname" . }}-basic-auth" + namespace: {{ .Release.Namespace | quote }} +{{- end }} +{{- end }} diff --git a/resources/core/charts/azure-broker/templates/deployment.yaml b/resources/core/charts/azure-broker/templates/deployment.yaml new file mode 100644 index 000000000000..5fcce62c0e86 --- /dev/null +++ b/resources/core/charts/azure-broker/templates/deployment.yaml @@ -0,0 +1,107 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: {{ template "fullname" . }} + labels: + app: {{ template "fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +spec: + replicas: 1 + selector: + matchLabels: + app: {{ template "fullname" . }} + template: + metadata: + labels: + app: {{ template "fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" + spec: + containers: + - name: open-service-broker-azure + image: {{ .Values.global.containerRegistry.path }}/azure-broker:0.0.22 + imagePullPolicy: IfNotPresent + resources: + limits: + memory: 512Mi + requests: + memory: 256Mi + env: + - name: ENVIRONMENT + valueFrom: + secretKeyRef: + name: {{ template "fullname" . }}-credentials + key: environment + - name: AZURE_SUBSCRIPTION_ID + valueFrom: + secretKeyRef: + name: {{ template "fullname" . }}-credentials + key: subscription_id + - name: AZURE_TENANT_ID + valueFrom: + secretKeyRef: + name: {{ template "fullname" . }}-credentials + key: tenant_id + - name: AZURE_CLIENT_ID + valueFrom: + secretKeyRef: + name: {{ template "fullname" . }}-credentials + key: client_id + - name: AZURE_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ template "fullname" . }}-credentials + key: client_secret + - name: REDIS_HOST + {{- if .Values.redis.embedded }} + value: {{ .Release.Name }}-redis + {{- else }} + value: {{ required "A value is required for redis.host" .Values.redis.host }} + - name: REDIS_PORT + value: {{ .Values.redis.port | quote }} + - name: REDIS_ENABLE_TLS + value: {{ .Values.redis.enableTls | quote }} + {{- end }} + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "fullname" . }}-redis + key: redis-password + - name: AES256_KEY + valueFrom: + secretKeyRef: + name: {{ template "fullname" . }}-redis + key: encryption-key + - name: BASIC_AUTH_USERNAME + valueFrom: + secretKeyRef: + name: {{ template "fullname" . }}-basic-auth + key: username + - name: BASIC_AUTH_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "fullname" . }}-basic-auth + key: password + - name: MIN_STABILITY + value: {{ .Values.modules.minStability }} + ports: + - containerPort: 8080 + readinessProbe: + tcpSocket: + port: 8080 + failureThreshold: 1 + initialDelaySeconds: 10 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 2 + livenessProbe: + tcpSocket: + port: 8080 + failureThreshold: 3 + initialDelaySeconds: 30 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 2 diff --git a/resources/core/charts/azure-broker/templates/service.yaml b/resources/core/charts/azure-broker/templates/service.yaml new file mode 100644 index 000000000000..9c12351f6d6a --- /dev/null +++ b/resources/core/charts/azure-broker/templates/service.yaml @@ -0,0 +1,23 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ template "fullname" . }} + annotations: + "auth.istio.io/80": NONE + labels: + app: {{ template "fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +spec: + type: {{ .Values.service.type }} + selector: + app: {{ template "fullname" . }} + ports: + - name: http + protocol: TCP + port: 80 + {{- if eq .Values.service.type "NodePort" }} + nodePort: {{ .Values.service.nodePort.port }} + {{- end }} + targetPort: 8080 diff --git a/resources/core/charts/azure-broker/values.yaml b/resources/core/charts/azure-broker/values.yaml new file mode 100755 index 000000000000..f854d626d551 --- /dev/null +++ b/resources/core/charts/azure-broker/values.yaml @@ -0,0 +1,49 @@ +## Whether to register this broker with the Kubernetes Service Catalog. If true, +## the Kubernetes Service Catalog must already be installed on the cluster. +## Marking this option false is useful for scenarios wherein one wishes to host +## the broker in a separate cluster than the Service Catalog (or other client) +## that will access it. +registerBroker: true + +service: + ## Type of service; valid values are "ClusterIP", "LoadBalancer", and + ## "NodePort". "ClusterIP" is sufficient in the average case where a service + ## catalog installation in the same cluster is the only client that needs to + ## communicate with this broker. i.e. The broker does not need to receive + ## inbound requests from outside the cluster. + type: ClusterIP + ## Further configuration if service is of type "NodePort" + nodePort: + ## Available port in allowable range (e.g. 30000 - 32767 on minikube) + port: 30080 + +## Basic auth credentials that can later be used to access this broker +# TODO implement secure way of passing this value (integration with vault) +basicAuth: + username: admin + password: p4ssw0rd + +modules: + ## Minimum stability required for a module's services and plans to be listed + ## in the broker's catalog. For production, use STABLE only! + minStability: EXPERIMENTAL + +## Redis configuration +redis: + ## Should a containerized Redis server be included in the Helm release? + embedded: true + + ## Required if not embedded + host: + + ## If not embedded, specifies the port for the client to connect to. + ## 6380 is the port often used for Redis secured using TLS. + port: 6380 + + # can't be generated here and this value is used in two places: here and in sub-chart (refactoring needed) + # TODO implement secure way of passing this value (integration with vault) + redisPassword: p4ssw0rd + + ## If not emnedded, specifies whether to use a secure connection to the + ## remote Redis host + enableTls: true diff --git a/resources/core/charts/cluster-users/Chart.yaml b/resources/core/charts/cluster-users/Chart.yaml new file mode 100644 index 000000000000..c10bee8d5abd --- /dev/null +++ b/resources/core/charts/cluster-users/Chart.yaml @@ -0,0 +1,7 @@ +name: cluster-users +version: 0.0.1 +description: Users of the Kyma K8S cluster +keywords: + - users + - authentication + - kyma diff --git a/resources/core/charts/cluster-users/README.md b/resources/core/charts/cluster-users/README.md new file mode 100644 index 000000000000..a3267c4af438 --- /dev/null +++ b/resources/core/charts/cluster-users/README.md @@ -0,0 +1,39 @@ +``` + _____ _ _ _ _ + / ____| | | | | | | | + | | | |_ _ ___| |_ ___ _ __ | | | |___ ___ _ __ ___ + | | | | | | / __| __/ _ \ '__| | | | / __|/ _ \ '__/ __| + | |____| | |_| \__ \ || __/ | | |__| \__ \ __/ | \__ \ + \_____|_|\__,_|___/\__\___|_| \____/|___/\___|_| |___/ + + ``` + +## Overview + +The `cluster-users` sub-chart uses role-based access control (RBAC) to define roles and accesses in Kyma. Kyma does not have a user base, but it has an admin defined as **admin@kyma.cx**. The admin's password is **nimda123**. Additionally, admin roles are bound to group of users named **kyma-admins**. + +## Details + +This section describes how to add a new user and define the user's role. It also describes the relation between the `apiGroup` and the user's role. + +### Add a new user and define the user's role + +1. To add a new user, register it in the [dex config map](../../../dex/templates/dex-config-map.yaml). + + For more details about the `dex` component, see the [README.md](../../../dex/README.md) documentation. + +2. Define the user's role inside the [`rbac-roles.yaml`](templates/rbac-roles.yaml) file within a namespace with a `ClusterRole`. + +3. Bind the user to the user's role in the [`rbac-roles.yaml`](templates/rbac-roles.yaml) file within a namespace with a `ClusterRoleBinding`. + +> **NOTE:** The `dex` component defines the user. The `cluster-users` component defines the user's role and assigns the role to the user. + +### Add a role binding to user group + +For a newly created or existing role in the [`rbac-roles.yaml`](templates/rbac-roles.yaml) file: + +1. Bind the desired group to the role in the [`rbac-roles.yaml`](templates/rbac-roles.yaml) file within a namespace with a `ClusterRoleBinding` by adding a new entry in the `subject` section with the kind `Group`. Note that the name of the group can be anything. However, the name has to reflect user groups in the Identity Provider. You can find the existing binding to the group `kyma-admins` in the [`rbac-roles.yaml`](templates/rbac-roles.yaml) file. + +### API groups + +One of the parameters that you set when defining the role is the `apiGroup`. The `apiGroup` attribute defines the Kubernetes APIs that the user has access to. When you define a new API group on the cluster, add the group to the user's role in the [`rbac-roles.yaml`](templates/rbac-roles.yaml) file to give the user access to the API group. Control the access by limiting it to specific API resources and methods. diff --git a/resources/core/charts/cluster-users/templates/rbac-roles.yaml b/resources/core/charts/cluster-users/templates/rbac-roles.yaml new file mode 100644 index 000000000000..0142c4a3de9a --- /dev/null +++ b/resources/core/charts/cluster-users/templates/rbac-roles.yaml @@ -0,0 +1,112 @@ +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1beta1 +metadata: + name: kube-system-view + namespace: {{ .Release.Namespace }} + labels: + app: kyma + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + annotations: + "helm.sh/hook-weight": "0" +subjects: +- kind: ServiceAccount + name: default + namespace: kube-system +roleRef: + kind: ClusterRole + apiGroup: rbac.authorization.k8s.io + name: view + +# Cluster viewer role +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRole +metadata: + name: kyma-view + namespace: {{ .Release.Namespace }} + labels: + app: kyma + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + annotations: + "helm.sh/hook-weight": "0" +rules: +- apiGroups: ["", "api.kyma.cx", "apps", "extensions", "gateway.kyma.cx", "kubeless.io", "rbac.authorization.k8s.io", "remoteenvironment.kyma.cx", "servicecatalog.k8s.io", "servicecatalog.kyma.cx", "settings.k8s.io", "kyma.cx", "authentication.istio.io", "config.istio.io", "eventing.kyma.cx", "ui.kyma.cx"] + resources: ["*"] + verbs: ["get, list"] +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: kyma-view-binding + namespace: {{ .Release.Namespace }} + labels: + app: kyma + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + annotations: + "helm.sh/hook-weight": "1" +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kyma-view +subjects: +- kind: User + name: user1@kyma.cx + apiGroup: rbac.authorization.k8s.io +- kind: User + name: user2@kyma.cx + apiGroup: rbac.authorization.k8s.io +{{- range .Values.bindings.kymaView.groups }} +- kind: Group + name: {{ . }} + apiGroup: rbac.authorization.k8s.io +{{ end }} + +# Cluster admin role +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRole +metadata: + name: kyma-admin + namespace: {{ .Release.Namespace }} + labels: + app: kyma + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + annotations: + "helm.sh/hook-weight": "0" +rules: +- apiGroups: ["", "api.kyma.cx", "apps", "extensions", "gateway.kyma.cx", "kubeless.io", "rbac.authorization.k8s.io", "remoteenvironment.kyma.cx", "servicecatalog.k8s.io", "servicecatalog.kyma.cx", "settings.k8s.io", "kyma.cx", "authentication.istio.io", "config.istio.io", "eventing.kyma.cx", "ui.kyma.cx"] + resources: ["*"] + verbs: ["*"] +- apiGroups: ["apiextensions.k8s.io"] + resources: ["customresourcedefinitions"] + verbs: ["get", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: kyma-admin-binding + namespace: {{ .Release.Namespace }} + labels: + app: kyma + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + annotations: + "helm.sh/hook-weight": "1" +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kyma-admin +subjects: +- kind: User + name: admin@kyma.cx + apiGroup: rbac.authorization.k8s.io +{{ if .Values.users.adminGroup }} +- kind: Group + name: {{ .Values.users.adminGroup }} + apiGroup: rbac.authorization.k8s.io +{{ end }} +{{- range .Values.bindings.kymaAdmin.groups }} +- kind: Group + name: {{ . }} + apiGroup: rbac.authorization.k8s.io +{{- end }} diff --git a/resources/core/charts/cluster-users/values.yaml b/resources/core/charts/cluster-users/values.yaml new file mode 100644 index 000000000000..b4c49ea5234e --- /dev/null +++ b/resources/core/charts/cluster-users/values.yaml @@ -0,0 +1,5 @@ +bindings: + kymaAdmin: + groups: [] + kymaView: + groups: [] diff --git a/resources/core/charts/configurations-generator/Chart.yaml b/resources/core/charts/configurations-generator/Chart.yaml new file mode 100644 index 000000000000..01103f7be61e --- /dev/null +++ b/resources/core/charts/configurations-generator/Chart.yaml @@ -0,0 +1,6 @@ +name: configurations-generator +version: 0.1.0 +description: Generator of configurations used in Kyma. +keywords: + - configurations-generator +appVersion: 0.2.0 \ No newline at end of file diff --git a/resources/core/charts/configurations-generator/README.md b/resources/core/charts/configurations-generator/README.md new file mode 100644 index 000000000000..653fd3cd0985 --- /dev/null +++ b/resources/core/charts/configurations-generator/README.md @@ -0,0 +1,18 @@ +``` + ____ __ _ _ _ + / ___|___ _ __ / _(_) __ _ _ _ _ __ __ _| |_(_) ___ _ __ ___ +| | / _ \| '_ \| |_| |/ _` | | | | '__/ _` | __| |/ _ \| '_ \/ __| +| |__| (_) | | | | _| | (_| | |_| | | | (_| | |_| | (_) | | | \__ \ + \____\___/|_| |_|_| |_|\__, |\__,_|_| \__,_|\__|_|\___/|_| |_|___/ + |___/ + ____ _ + / ___| ___ _ __ ___ _ __ __ _| |_ ___ _ __ +| | _ / _ \ '_ \ / _ \ '__/ _` | __/ _ \| '__| +| |_| | __/ | | | __/ | | (_| | || (_) | | + \____|\___|_| |_|\___|_| \__,_|\__\___/|_| + +``` + +## Overview + +This is a generator of configurations used in Kyma. diff --git a/resources/core/charts/configurations-generator/templates/deployment.yaml b/resources/core/charts/configurations-generator/templates/deployment.yaml new file mode 100644 index 000000000000..0ae5c195fe2f --- /dev/null +++ b/resources/core/charts/configurations-generator/templates/deployment.yaml @@ -0,0 +1,30 @@ +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: configurations-generator + labels: + app: configurations-generator + chart: {{ .Chart.Name }}-{{ .Chart.Version }} +spec: + replicas: 1 + template: + metadata: + labels: + app: configurations-generator + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + annotations: + sidecar.istio.io/inject: "true" + spec: + containers: + - image: {{ .Values.global.containerRegistry.path }}/{{ .Chart.Name }}:{{ .Chart.Version }} + name: configurations-generator + command: [ + "/app", + "-kube-config-custer-name={{ .Values.kubeConfig.clusterName }}", + "-kube-config-url={{ .Values.kubeConfig.url }}", + "-kube-config-ca={{ .Values.kubeConfig.ca }}" + ] + ports: + - name: http + containerPort: 8000 diff --git a/resources/core/charts/configurations-generator/templates/ingress.yaml b/resources/core/charts/configurations-generator/templates/ingress.yaml new file mode 100644 index 000000000000..6c669ddebc92 --- /dev/null +++ b/resources/core/charts/configurations-generator/templates/ingress.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: configurations-generator + namespace: {{ .Release.Namespace }} + annotations: + kubernetes.io/ingress.class: "istio" + labels: + app: configurations-generator + chart: {{ .Chart.Name }}-{{ .Chart.Version }} +spec: + tls: + - secretName: {{.Values.global.istio.tls.secretName }} + rules: + - host: configurations-generator.{{ .Values.global.domainName }} + http: + paths: + - backend: + serviceName: configurations-generator + servicePort: 8000 + path: /.* diff --git a/resources/core/charts/configurations-generator/templates/jwt-rule.yaml b/resources/core/charts/configurations-generator/templates/jwt-rule.yaml new file mode 100644 index 000000000000..c5f37cb637ba --- /dev/null +++ b/resources/core/charts/configurations-generator/templates/jwt-rule.yaml @@ -0,0 +1,18 @@ +# Example of JWT authentication rule securing /weather enpoint in sample service +apiVersion: "config.istio.io/v1alpha2" +kind: rule +metadata: + name: configurations-generator-jwt + # namespace must be settled in istio-system + namespace: istio-system + labels: + app: configurations-generator + chart: {{ .Chart.Name }}-{{ .Chart.Version }} +spec: + # more information about vocabulary available for match condition can be found in istio docu (https://istio.io/docs/reference/config/mixer/attribute-vocabulary.html) + match: ( destination.service == "configurations-generator.{{ .Release.Namespace }}.svc.cluster.local" ) + actions: + # handler and instances must not be changed + - handler: handler.jwt + instances: + - jwt.auth.istio-system \ No newline at end of file diff --git a/resources/core/charts/configurations-generator/templates/route-rule.yaml b/resources/core/charts/configurations-generator/templates/route-rule.yaml new file mode 100644 index 000000000000..89d29c0e4a4a --- /dev/null +++ b/resources/core/charts/configurations-generator/templates/route-rule.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: config.istio.io/v1alpha2 +kind: RouteRule +metadata: + name: configurations-generator-cors + labels: + app: configurations-generator + chart: {{ .Chart.Name }}-{{ .Chart.Version }} +spec: + destination: + name: configurations-generator + corsPolicy: + allowOrigin: + - "*" + allowMethods: + - GET + - OPTIONS + allowHeaders: + - "authorization" \ No newline at end of file diff --git a/resources/core/charts/configurations-generator/templates/service.yaml b/resources/core/charts/configurations-generator/templates/service.yaml new file mode 100644 index 000000000000..7fabc1a22d34 --- /dev/null +++ b/resources/core/charts/configurations-generator/templates/service.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: configurations-generator + labels: + app: configurations-generator + chart: {{ .Chart.Name }}-{{ .Chart.Version }} +spec: + type: NodePort + ports: + - name: http + port: 8000 + targetPort: 8000 + selector: + app: configurations-generator diff --git a/resources/core/charts/configurations-generator/values.yaml b/resources/core/charts/configurations-generator/values.yaml new file mode 100644 index 000000000000..ed6abda57a89 --- /dev/null +++ b/resources/core/charts/configurations-generator/values.yaml @@ -0,0 +1,4 @@ +kubeConfig: + clusterName: minikube + url: + ca: diff --git a/resources/core/charts/console/.helmignore b/resources/core/charts/console/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/console/.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/resources/core/charts/console/Chart.yaml b/resources/core/charts/console/Chart.yaml new file mode 100644 index 000000000000..61c32282b926 --- /dev/null +++ b/resources/core/charts/console/Chart.yaml @@ -0,0 +1,3 @@ +name: console +description: Console UI for Kyma +version: 0.2.0 diff --git a/resources/core/charts/console/README.md b/resources/core/charts/console/README.md new file mode 100644 index 000000000000..e4799fae2748 --- /dev/null +++ b/resources/core/charts/console/README.md @@ -0,0 +1,24 @@ +``` + _____ _ + / ____| | | + | | ___ _ __ ___ ___ | | ___ + | | / _ \| '_ \/ __|/ _ \| |/ _ \ + | |___| (_) | | | \__ \ (_) | | __/ + \_____\___/|_| |_|___/\___/|_|\___| + +``` +## Overview + +The Console is a web-based UI for Kyma. +It allows users to manage specific functionality within Kyma along with basic Kubernetes resources. +The Console provides an extensibility mechanism which allows you to seamlessly integrate UI parts to achieve additional functionality. + +## Details + +This section provides details related to the configuration of the Console. + +### Configuration + +The deployment of the Console includes a [ConfigMap](templates/configmap.yaml). +The ConfigMap introduces a `config.js` file that is mounted as the asset of the Console application and injected as a configuration file. +Use this mechanism to overwrite the default configuration with custom values resulting from the Helm chart installation. diff --git a/resources/core/charts/console/templates/_helpers.tpl b/resources/core/charts/console/templates/_helpers.tpl new file mode 100644 index 000000000000..f0d83d2edba6 --- /dev/null +++ b/resources/core/charts/console/templates/_helpers.tpl @@ -0,0 +1,16 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "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). +*/}} +{{- define "fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/resources/core/charts/console/templates/configmap.yaml b/resources/core/charts/console/templates/configmap.yaml new file mode 100644 index 000000000000..cb14d58bb004 --- /dev/null +++ b/resources/core/charts/console/templates/configmap.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Chart.Name }}-config + namespace: {{ .Release.Namespace }} + labels: + app: console + chart: {{ .Chart.Name }}-{{ .Chart.Version }} +data: + config.js: | + window.clusterConfig = { + authRedirectUri: 'https://console.{{ js .Values.global.domainName }}', + domain: '{{ js .Values.global.domainName }}', + consoleClientId: '{{ js .Values.cluster.consoleClientId }}', + scope: '{{ js .Values.cluster.scope }}', + orgId: '{{ js .Values.cluster.orgId }}', + orgName: '{{ js .Values.cluster.orgName }}', + gateway_kyma_cx_api_version: 'v1alpha2' + }; + \ No newline at end of file diff --git a/resources/core/charts/console/templates/deployment.yaml b/resources/core/charts/console/templates/deployment.yaml new file mode 100644 index 000000000000..9e649f05df3d --- /dev/null +++ b/resources/core/charts/console/templates/deployment.yaml @@ -0,0 +1,34 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: {{ template "fullname" . }} + labels: + app: {{ template "name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + replicas: {{ .Values.replicaCount }} + template: + metadata: + labels: + app: {{ template "name" . }} + release: {{ .Release.Name }} + spec: + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.global.containerRegistry.path }}/console:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: {{ .Values.service.internalPort }} + volumeMounts: + - name: config + mountPath: /usr/share/nginx/html/assets/config + volumes: + - name: config + configMap: + name: {{ .Chart.Name }}-config + items: + - key: config.js + path: config.js + diff --git a/resources/core/charts/console/templates/idppreset.crd.yaml b/resources/core/charts/console/templates/idppreset.crd.yaml new file mode 100644 index 000000000000..fa4b75a12319 --- /dev/null +++ b/resources/core/charts/console/templates/idppreset.crd.yaml @@ -0,0 +1,36 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: "idppresets.ui.kyma.cx" +spec: + group: ui.kyma.cx + version: v1alpha1 + scope: Cluster + names: + plural: "idppresets" + singular: "idppreset" + kind: IDPPreset + shortNames: + - "idp" + validation: + openAPIV3Schema: + required: + - "spec" + properties: + spec: + type: object + required: + - "name" + - "issuer" + - "jwksUri" + properties: + name: + type: string + minLength: 1 + issuer: + type: string + minLength: 1 + jwksUri: + type: string + minLength: 1 + pattern: '^(https?)://.+$' \ No newline at end of file diff --git a/resources/core/charts/console/templates/ingress.yaml b/resources/core/charts/console/templates/ingress.yaml new file mode 100644 index 000000000000..925f4a5d92ed --- /dev/null +++ b/resources/core/charts/console/templates/ingress.yaml @@ -0,0 +1,24 @@ +{{- $serviceName := include "fullname" . -}} +{{- $servicePort := .Values.service.externalPort -}} +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: {{ template "fullname" . }} + labels: + app: {{ template "name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} + annotations: + kubernetes.io/ingress.class: "istio" +spec: + tls: + - secretName: {{.Values.global.istio.tls.secretName }} + rules: + - host: console.{{ .Values.global.domainName }} + http: + paths: + - path: /.* + backend: + serviceName: {{ $serviceName }} + servicePort: {{ $servicePort }} diff --git a/resources/core/charts/console/templates/kubernetes-dashboard-admin.yaml b/resources/core/charts/console/templates/kubernetes-dashboard-admin.yaml new file mode 100644 index 000000000000..7bc53084f87c --- /dev/null +++ b/resources/core/charts/console/templates/kubernetes-dashboard-admin.yaml @@ -0,0 +1,14 @@ +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: kubernetes-dashboard-kyma-admin + labels: + app: kyma +subjects: +- kind: ServiceAccount + name: kubernetes-dashboard + namespace: kube-system +roleRef: + kind: ClusterRole + apiGroup: rbac.authorization.k8s.io + name: kyma-admin \ No newline at end of file diff --git a/resources/core/charts/console/templates/microfrontend.crd.yaml b/resources/core/charts/console/templates/microfrontend.crd.yaml new file mode 100644 index 000000000000..84cfd0388ac7 --- /dev/null +++ b/resources/core/charts/console/templates/microfrontend.crd.yaml @@ -0,0 +1,48 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: "microfrontends.ui.kyma.cx" +spec: + group: ui.kyma.cx + version: v1alpha1 + scope: Namespaced + names: + plural: "microfrontends" + singular: "microfrontend" + kind: MicroFrontend + shortNames: + - "mf" + validation: + openAPIV3Schema: + required: + - "spec" + properties: + spec: + type: object + required: + - "navigation" + properties: + appName: + type: string + displayName: + type: string + version: + type: string + pattern: "[a-zA-Z0-9]+" + location: + type: string + pattern: '^(https?)://.+$' + maxLength: 512 + navigation: + type: object + required: + - "category" + properties: + category: + type: string + location: + type: string + order: + type: integer + minimum: 1 + maximum: 100 \ No newline at end of file diff --git a/resources/core/charts/console/templates/service.yaml b/resources/core/charts/console/templates/service.yaml new file mode 100644 index 000000000000..3094ebdbaed8 --- /dev/null +++ b/resources/core/charts/console/templates/service.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ template "fullname" . }} + annotations: + "auth.istio.io/{{ .Values.service.externalPort }}": NONE + labels: + app: {{ template "name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + ports: + - port: {{ .Values.service.externalPort }} + name: http2 + targetPort: {{ .Values.service.internalPort }} + selector: + app: {{ template "name" . }} + release: {{ .Release.Name }} + diff --git a/resources/core/charts/console/values.yaml b/resources/core/charts/console/values.yaml new file mode 100644 index 000000000000..ac1b1a26a4d7 --- /dev/null +++ b/resources/core/charts/console/values.yaml @@ -0,0 +1,29 @@ +# Default values for console. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +replicaCount: 1 +image: + tag: 0.1.70 + pullPolicy: IfNotPresent +service: + name: nginx + externalPort: 80 + internalPort: 80 + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi +cluster: + consoleClientId: console + scope: audience:server:client_id:kyma-client audience:server:client_id:console openid profile email groups + # Organization data for which the Kyma cluster is installed + orgId: 'my-org-123' + orgName: 'My Organization' diff --git a/resources/core/charts/docs/.helmignore b/resources/core/charts/docs/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/docs/.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/resources/core/charts/docs/Chart.yaml b/resources/core/charts/docs/Chart.yaml new file mode 100644 index 000000000000..083c80f3c7ff --- /dev/null +++ b/resources/core/charts/docs/Chart.yaml @@ -0,0 +1,3 @@ +name: docs +description: Documentation and UI for it +version: 0.0.1 diff --git a/resources/core/charts/docs/README.md b/resources/core/charts/docs/README.md new file mode 100644 index 000000000000..133eda8867b8 --- /dev/null +++ b/resources/core/charts/docs/README.md @@ -0,0 +1,15 @@ +``` + _____ + | __ \ + | | | | ___ ___ ___ + | | | |/ _ \ / __/ __| + | |__| | (_) | (__\__ \ + |_____/ \___/ \___|___/ + +``` + +## Overview + +This chart consists of: +* `content-ui` chart - UI application that displays documentation as part of the Console UI. +* `documentation` chart - It contains a Job that after main installation uploads documentation to Minio. \ No newline at end of file diff --git a/resources/core/charts/docs/charts/content-ui/.helmignore b/resources/core/charts/docs/charts/content-ui/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/docs/charts/content-ui/.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/resources/core/charts/docs/charts/content-ui/Chart.yaml b/resources/core/charts/docs/charts/content-ui/Chart.yaml new file mode 100644 index 000000000000..5994c6c63f50 --- /dev/null +++ b/resources/core/charts/docs/charts/content-ui/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: content-ui +description: Documentation UI embedded in the Console +version: 0.1.0 +appVersion: 0.1.32 diff --git a/resources/core/charts/docs/charts/content-ui/templates/_helpers.tpl b/resources/core/charts/docs/charts/content-ui/templates/_helpers.tpl new file mode 100644 index 000000000000..f0d83d2edba6 --- /dev/null +++ b/resources/core/charts/docs/charts/content-ui/templates/_helpers.tpl @@ -0,0 +1,16 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "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). +*/}} +{{- define "fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/resources/core/charts/docs/charts/content-ui/templates/configmap.yaml b/resources/core/charts/docs/charts/content-ui/templates/configmap.yaml new file mode 100644 index 000000000000..421d6f06849d --- /dev/null +++ b/resources/core/charts/docs/charts/content-ui/templates/configmap.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Chart.Name }} + namespace: {{ .Release.Namespace }} + labels: + app: {{ .Chart.Name }} + chart: {{ .Chart.Name }}-{{ .Chart.Version }} +data: + config.js: | + window.clusterConfig = { + graphqlApiUrl: 'https://ui-api.{{ .Values.global.domainName }}/graphql' + }; \ No newline at end of file diff --git a/resources/core/charts/docs/charts/content-ui/templates/deployment.yaml b/resources/core/charts/docs/charts/content-ui/templates/deployment.yaml new file mode 100644 index 000000000000..aa4c86d96d06 --- /dev/null +++ b/resources/core/charts/docs/charts/content-ui/templates/deployment.yaml @@ -0,0 +1,33 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: {{ template "fullname" . }} + labels: + app: {{ template "name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + replicas: {{ .Values.replicaCount }} + template: + metadata: + labels: + app: {{ template "name" . }} + release: {{ .Release.Name }} + spec: + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.global.containerRegistry.path }}/{{ .Chart.Name }}:{{ .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: {{ .Values.service.internalPort }} + volumeMounts: + - name: config + mountPath: /var/public/config + volumes: + - name: config + configMap: + name: {{ .Chart.Name }} + items: + - key: config.js + path: config.js \ No newline at end of file diff --git a/resources/core/charts/docs/charts/content-ui/templates/ingress.yaml b/resources/core/charts/docs/charts/content-ui/templates/ingress.yaml new file mode 100644 index 000000000000..11d27ff85221 --- /dev/null +++ b/resources/core/charts/docs/charts/content-ui/templates/ingress.yaml @@ -0,0 +1,28 @@ +{{- if .Values.ingress.enabled -}} +{{- $serviceName := include "fullname" . -}} +{{- $servicePort := .Values.service.externalPort -}} +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: {{ template "fullname" . }} + labels: + app: {{ template "name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} + annotations: + {{- range $key, $value := .Values.ingress.annotations }} + {{ $key }}: {{ $value | quote }} + {{- end }} +spec: + rules: + - host: {{ .Values.name }}.{{ .Values.global.domainName }} + http: + paths: + - path: /.* + backend: + serviceName: {{ $serviceName }} + servicePort: {{ $servicePort }} + tls: + - secretName: {{.Values.global.istio.tls.secretName }} +{{- end -}} diff --git a/resources/core/charts/docs/charts/content-ui/templates/service.yaml b/resources/core/charts/docs/charts/content-ui/templates/service.yaml new file mode 100644 index 000000000000..8f03ee36b81e --- /dev/null +++ b/resources/core/charts/docs/charts/content-ui/templates/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + "auth.istio.io/{{ .Values.service.externalPort }}": NONE + name: {{ template "fullname" . }} + labels: + app: {{ template "name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + ports: + - port: {{ .Values.service.internalPort }} + targetPort: {{ .Values.service.externalPort }} + protocol: TCP + name: http2 + selector: + app: {{ template "name" . }} \ No newline at end of file diff --git a/resources/core/charts/docs/charts/content-ui/values.yaml b/resources/core/charts/docs/charts/content-ui/values.yaml new file mode 100644 index 000000000000..e3c5fc916fe2 --- /dev/null +++ b/resources/core/charts/docs/charts/content-ui/values.yaml @@ -0,0 +1,13 @@ +name: docs +replicaCount: 1 +image: + pullPolicy: IfNotPresent +service: + internalPort: 80 + externalPort: 80 + protocol: TCP +ingress: + enabled: true + annotations: + kubernetes.io/ingress.class: istio +resources: {} diff --git a/resources/core/charts/docs/charts/documentation/.helmignore b/resources/core/charts/docs/charts/documentation/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/docs/charts/documentation/.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/resources/core/charts/docs/charts/documentation/Chart.yaml b/resources/core/charts/docs/charts/documentation/Chart.yaml new file mode 100644 index 000000000000..ecf531c09692 --- /dev/null +++ b/resources/core/charts/docs/charts/documentation/Chart.yaml @@ -0,0 +1,4 @@ +name: documentation +description: Documentation for Kyma that is displayed in Kyma Console +version: 0.0.10 +appVersion: 0.0.10 diff --git a/resources/core/charts/docs/charts/documentation/templates/_helpers.tpl b/resources/core/charts/docs/charts/documentation/templates/_helpers.tpl new file mode 100644 index 000000000000..f0d83d2edba6 --- /dev/null +++ b/resources/core/charts/docs/charts/documentation/templates/_helpers.tpl @@ -0,0 +1,16 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "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). +*/}} +{{- define "fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/resources/core/charts/docs/charts/documentation/templates/docs-job.yaml b/resources/core/charts/docs/charts/documentation/templates/docs-job.yaml new file mode 100644 index 000000000000..4eb846aa38cd --- /dev/null +++ b/resources/core/charts/docs/charts/documentation/templates/docs-job.yaml @@ -0,0 +1,40 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ template "fullname" . }} + annotations: + "helm.sh/hook": post-install + "helm.sh/hook-weight": "1" + "helm.sh/hook-delete-policy": hook-succeeded +spec: + activeDeadlineSeconds: 180 + template: + metadata: + name: {{ template "fullname" . }}-docs + labels: + inject: docs-upload-config + spec: + restartPolicy: Never + containers: + - name: kyma + image: {{ .Values.global.containerRegistry.path }}/kyma-docs:{{ .Chart.AppVersion }} + - name: service-catalog + image: {{ .Values.global.containerRegistry.path }}/service-catalog-docs:{{ .Chart.AppVersion }} + - name: service-brokers + image: {{ .Values.global.containerRegistry.path }}/service-brokers-docs:{{ .Chart.AppVersion }} + - name: application-connector + image: {{ .Values.global.containerRegistry.path }}/application-connector-docs:{{ .Chart.AppVersion }} + - name: event-bus + image: {{ .Values.global.containerRegistry.path }}/event-bus-docs:{{ .Chart.AppVersion }} + - name: service-mesh + image: {{ .Values.global.containerRegistry.path }}/service-mesh-docs:{{ .Chart.AppVersion }} + - name: serverless + image: {{ .Values.global.containerRegistry.path }}/serverless-docs:{{ .Chart.AppVersion }} + - name: monitoring + image: {{ .Values.global.containerRegistry.path }}/monitoring-docs:{{ .Chart.AppVersion }} + - name: tracing + image: {{ .Values.global.containerRegistry.path }}/tracing-docs:{{ .Chart.AppVersion }} + - name: api-gateway + image: {{ .Values.global.containerRegistry.path }}/api-gateway-docs:{{ .Chart.AppVersion }} + - name: authorization-and-authentication + image: {{ .Values.global.containerRegistry.path }}/authorization-and-authentication-docs:{{ .Chart.AppVersion }} diff --git a/resources/core/charts/docs/charts/documentation/values.yaml b/resources/core/charts/docs/charts/documentation/values.yaml new file mode 100644 index 000000000000..2e7af0c3534c --- /dev/null +++ b/resources/core/charts/docs/charts/documentation/values.yaml @@ -0,0 +1,3 @@ +# Default values for service-instances-ui. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. \ No newline at end of file diff --git a/resources/core/charts/environments/.helmignore b/resources/core/charts/environments/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/environments/.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/resources/core/charts/environments/Chart.yaml b/resources/core/charts/environments/Chart.yaml new file mode 100644 index 000000000000..0ca855293cfc --- /dev/null +++ b/resources/core/charts/environments/Chart.yaml @@ -0,0 +1,4 @@ +name: environments +description: Environments controller for Kyma +version: 2.0.6 +apiVersion: v1 diff --git a/resources/core/charts/environments/README.md b/resources/core/charts/environments/README.md new file mode 100644 index 000000000000..43927bca4cc0 --- /dev/null +++ b/resources/core/charts/environments/README.md @@ -0,0 +1,14 @@ +``` + ______ _ _ + | ____| (_) | | + | |__ _ ____ ___ _ __ ___ _ __ _ __ ___ ___ _ __ | |_ ___ + | __| | '_ \ \ / / | '__/ _ \| '_ \| '_ ` _ \ / _ \ '_ \| __/ __| + | |____| | | \ V /| | | | (_) | | | | | | | | | __/ | | | |_\__ \ + |______|_| |_|\_/ |_|_| \___/|_| |_|_| |_| |_|\___|_| |_|\__|___/ +``` + +## Overview + +Environments is the name of the controller that injects limit ranges and resource quotas into user-created Namespaces. It also adds two default roles, `kyma-reader-role` and `kyma-admin-role`, which it then copies into user-created Environments. + +Read the [environments](../../../../docs/kyma/docs/005-environments.md) document to learn more about Environments in Kyma. diff --git a/resources/core/charts/environments/templates/0-service-account.yaml b/resources/core/charts/environments/templates/0-service-account.yaml new file mode 100644 index 000000000000..63aca25de198 --- /dev/null +++ b/resources/core/charts/environments/templates/0-service-account.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ template "name" . }}-service-account diff --git a/resources/core/charts/environments/templates/1-role.yaml b/resources/core/charts/environments/templates/1-role.yaml new file mode 100644 index 000000000000..a0fb5b62eb20 --- /dev/null +++ b/resources/core/charts/environments/templates/1-role.yaml @@ -0,0 +1,8 @@ +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1beta1 +metadata: + name: {{ template "name" . }}-role +rules: +- apiGroups: ["*"] + resources: ["*"] + verbs: ["*"] diff --git a/resources/core/charts/environments/templates/2-role-binding.yaml b/resources/core/charts/environments/templates/2-role-binding.yaml new file mode 100644 index 000000000000..04bf7c9eee8c --- /dev/null +++ b/resources/core/charts/environments/templates/2-role-binding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: {{ template "name" . }}-role-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ template "name" . }}-role +subjects: +- kind: ServiceAccount + name: {{ template "name" . }}-service-account + namespace: {{ .Release.Namespace}} diff --git a/resources/core/charts/environments/templates/3-bootstrap-roles.yaml b/resources/core/charts/environments/templates/3-bootstrap-roles.yaml new file mode 100644 index 000000000000..16f5012dfb4d --- /dev/null +++ b/resources/core/charts/environments/templates/3-bootstrap-roles.yaml @@ -0,0 +1,22 @@ +--- +kind: Role +apiVersion: rbac.authorization.k8s.io/v1beta1 +metadata: + name: kyma-admin-role + labels: + env: "true" +rules: +- apiGroups: ["*"] + resources: ["*"] + verbs: ["*"] +--- +kind: Role +apiVersion: rbac.authorization.k8s.io/v1beta1 +metadata: + name: kyma-reader-role + labels: + env: "true" +rules: +- apiGroups: ["*"] + resources: ["*"] + verbs: ["get", "list", "watch"] \ No newline at end of file diff --git a/resources/core/charts/environments/templates/4-deployment.yaml b/resources/core/charts/environments/templates/4-deployment.yaml new file mode 100644 index 000000000000..12f563aedab8 --- /dev/null +++ b/resources/core/charts/environments/templates/4-deployment.yaml @@ -0,0 +1,40 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: {{ template "fullname" . }} + labels: + app: {{ template "name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + replicas: 1 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + template: + metadata: + labels: + app: {{ template "name" . }} + release: {{ .Release.Name }} + spec: + serviceAccountName: {{ template "name" . }}-service-account + containers: + - name: {{ .Chart.Name }} + image: {{ .Values.global.containerRegistry.path }}/environments:0.1.2 + resources: +{{ toYaml .Values.resources | indent 12 }} + env: + - name: APP_NAMESPACE + value: {{ .Release.Namespace }} + - name: APP_LIMIT_RANGE_MEMORY_DEFAULT_REQUEST + value: "{{ .Values.limitRanges.defaultRequest }}" + - name: APP_LIMIT_RANGE_MEMORY_DEFAULT + value: "{{ .Values.limitRanges.default }}" + - name: APP_LIMIT_RANGE_MEMORY_MAX + value: "{{ .Values.limitRanges.max }}" + - name: APP_RESOURCE_QUOTA_LIMITS_MEMORY + value: "{{ .Values.resourceQuota.limitsMemory}}" + - name: APP_RESOURCE_QUOTA_REQUESTS_MEMORY + value: "{{ .Values.resourceQuota.requestsMemory}}" diff --git a/resources/core/charts/environments/templates/_helpers.tpl b/resources/core/charts/environments/templates/_helpers.tpl new file mode 100644 index 000000000000..f0d83d2edba6 --- /dev/null +++ b/resources/core/charts/environments/templates/_helpers.tpl @@ -0,0 +1,16 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "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). +*/}} +{{- define "fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/resources/core/charts/environments/templates/tests/test-environments.yaml b/resources/core/charts/environments/templates/tests/test-environments.yaml new file mode 100644 index 000000000000..530c99cdb870 --- /dev/null +++ b/resources/core/charts/environments/templates/tests/test-environments.yaml @@ -0,0 +1,62 @@ +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRole +metadata: + name: test-{{ template "fullname" . }} + labels: + helm-chart-test: "true" +rules: +- apiGroups: [""] + resources: ["namespaces"] + verbs: ["create", "get", "delete"] +- apiGroups: ["", "rbac.authorization.k8s.io"] + resources: ["limitranges", "resourcequotas", "secrets", "roles"] + verbs: ["get"] +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: test-{{ template "fullname" . }} + labels: + helm-chart-test: "true" +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: test-{{ template "fullname" . }} + labels: + helm-chart-test: "true" +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: test-{{ template "fullname" . }} +subjects: +- kind: ServiceAccount + name: test-{{ template "fullname" . }} + namespace: {{ .Release.Namespace }} +--- +apiVersion: v1 +kind: Pod +metadata: + name: test-{{ template "fullname" . }} + annotations: + "helm.sh/hook": test-success + labels: + "helm-chart-test": "true" +spec: + serviceAccountName: test-{{ template "fullname" . }} + containers: + - name: test-{{ template "fullname" . }} + image: {{ .Values.global.containerRegistry.path }}/test-environments:0.1.0 + env: + - name: EXPECTED_LIMIT_RANGE_MEMORY_DEFAULT_REQUEST + value: "{{ .Values.limitRanges.defaultRequest }}" + - name: EXPECTED_LIMIT_RANGE_MEMORY_DEFAULT + value: "{{ .Values.limitRanges.default }}" + - name: EXPECTED_LIMIT_RANGE_MEMORY_MAX + value: "{{ .Values.limitRanges.max }}" + - name: EXPECTED_RESOURCE_QUOTA_LIMITS_MEMORY + value: "{{ .Values.resourceQuota.limitsMemory}}" + - name: EXPECTED_RESOURCE_QUOTA_REQUESTS_MEMORY + value: "{{ .Values.resourceQuota.requestsMemory}}" + restartPolicy: Never +--- \ No newline at end of file diff --git a/resources/core/charts/environments/values.yaml b/resources/core/charts/environments/values.yaml new file mode 100644 index 000000000000..f3b364475a1e --- /dev/null +++ b/resources/core/charts/environments/values.yaml @@ -0,0 +1,20 @@ +limitRanges: + # If a container does not specify memory request, this default value will be applied. + # The scheduler considers this value when scheduling a container to a node. + # If a node has not enough memory, such pod will not be created. The value + defaultRequest: 32Mi + + # If a container does not specify memory limit, this default value will be applied. + # If a container tries to allocate more memory, container will be OOM killed. + default: 512Mi + + # Maximum memory that a container can request + max: 1Gi + +# resourceQuota defines quotas per environment. Values are defined as a standard Kubernetes quantity, +# for example: 1Gi, 512Mi +resourceQuota: + # Sum of all container memory requests must not exceed this value. + requestsMemory: 2.8Gi + # Sum of all container memory limits must not exceed this value. + limitsMemory: 3Gi diff --git a/resources/core/charts/event-bus/.helmignore b/resources/core/charts/event-bus/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/event-bus/.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/resources/core/charts/event-bus/Chart.yaml b/resources/core/charts/event-bus/Chart.yaml new file mode 100644 index 000000000000..c8e0e66ba516 --- /dev/null +++ b/resources/core/charts/event-bus/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: Event Bus Helm chart +name: event-bus +version: 0.1.0 diff --git a/resources/core/charts/event-bus/README.md b/resources/core/charts/event-bus/README.md new file mode 100644 index 000000000000..434a3c9573f0 --- /dev/null +++ b/resources/core/charts/event-bus/README.md @@ -0,0 +1,42 @@ +``` + ______ _ ____ + | ____| | | | _ \ + | |____ _____ _ __ | |_ | |_) |_ _ ___ + | __\ \ / / _ \ '_ \| __| | _ <| | | / __| + | |___\ V / __/ | | | |_ | |_) | |_| \__ \ + |______\_/ \___|_| |_|\__| |____/ \__,_|___/ +``` + +## Prerequisites + +This sub-chart requires: +* Kubernetes 1.9 +* The mutual Transport Layer Security (TLS) between clients +* The Event Bus deployments requires: + * A Kubernetes cluster with Istio installed + * The istio-side-car injection enabled + +For more details, see the [Istio documentation](https://istio.io/docs/). + +## Details +Configure these options for each business requirement: + +| Component | Configuration | Description | +|---------------------------| --------:| -----------: | +| **nats-streaming** | +| | `global.persistence.size` | The size of the storage volume. | +| | `global.persistence.maxAge`| The maximum period of time for storing an event (`0` for unlimited). | +| |`global.natsStreaming.resources`| Refer to Kubernetes resource requests and limits for details. | +| **publish** | +| |`global.publish.maxRequests`| The maximum number of concurrent events to publish. | +| |`global.publish.resources`| Refer to Kubernetes resource requests and limits for details. | +| **push**| +| | `global.push.http.subscriptionNameHeader` | The HTTP header that contains the push subscription name. | +| | `global.push.http.topicHeader` | The HTTP header that contains the `event-type` details. | +| |`global.push.resources`| Refer to Kubernetes resource requests and limits for details. | +| **sub-validator** | +| | `global.subValidator.resyncPeriod`| The period after which the synchronization of EventActivation and Subscription Kubernetes custom resources takes place. | +| |`global.subValidator.resources`| Refer to Kubernetes resource requests and limits for details. | + + +For details on the Kubernetes resource requests and limits, see the [Manage Compute Resources Container](https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/) document. diff --git a/resources/core/charts/event-bus/charts/nats-streaming/.helmignore b/resources/core/charts/event-bus/charts/nats-streaming/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/event-bus/charts/nats-streaming/.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/resources/core/charts/event-bus/charts/nats-streaming/Chart.yaml b/resources/core/charts/event-bus/charts/nats-streaming/Chart.yaml new file mode 100644 index 000000000000..f91dbfcd2fa5 --- /dev/null +++ b/resources/core/charts/event-bus/charts/nats-streaming/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: NATS Streaming Helm chart +name: nats-streaming +version: 0.1.0 diff --git a/resources/core/charts/event-bus/charts/nats-streaming/templates/_helpers.tpl b/resources/core/charts/event-bus/charts/nats-streaming/templates/_helpers.tpl new file mode 100644 index 000000000000..b6f1ca4082f0 --- /dev/null +++ b/resources/core/charts/event-bus/charts/nats-streaming/templates/_helpers.tpl @@ -0,0 +1,20 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the nats-streaming subchart. +*/}} +{{- define "nats-streaming.name" -}} +{{- printf "nats-streaming" -}} +{{- end -}} + +{{- /* +Credit: @technosophos +https://github.com/technosophos/common-chart/ +nats-streaming.labels.standard prints the standard Helm labels. +The standard labels are frequently used in metadata. +*/ -}} +{{- define "nats-streaming.labels.standard" -}} +app: {{ template "nats-streaming.name" . }} +heritage: {{ .Release.Service | quote }} +release: {{ .Release.Name | quote }} +chart: {{ .Chart.Name }}-{{ .Chart.Version }} +{{- end -}} diff --git a/resources/core/charts/event-bus/charts/nats-streaming/templates/configmap.yaml b/resources/core/charts/event-bus/charts/nats-streaming/templates/configmap.yaml new file mode 100644 index 000000000000..10aeb54b4f9f --- /dev/null +++ b/resources/core/charts/event-bus/charts/nats-streaming/templates/configmap.yaml @@ -0,0 +1,13 @@ +{{- if .Values.configurationFiles }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ template "nats-streaming.fullname" . }} + labels: +{{ include "nats-streaming.labels.standard" . | indent 4 }} +data: +{{- range $key, $val := .Values.configurationFiles }} + {{ $key }}: | +{{ $val | indent 4}} +{{- end }} +{{- end -}} \ No newline at end of file diff --git a/resources/core/charts/event-bus/charts/nats-streaming/templates/service.yaml b/resources/core/charts/event-bus/charts/nats-streaming/templates/service.yaml new file mode 100644 index 000000000000..b7c6d05c682b --- /dev/null +++ b/resources/core/charts/event-bus/charts/nats-streaming/templates/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ template "nats-streaming.fullname" . }} + labels: +{{ include "nats-streaming.labels.standard" . | indent 4 }} +spec: + ports: + - name: client + port: {{ .Values.global.natsStreaming.ports.client }} + targetPort: client + selector: + app: {{ template "nats-streaming.name" . }} + release: {{ .Release.Name }} diff --git a/resources/core/charts/event-bus/charts/nats-streaming/templates/statefulset.yaml b/resources/core/charts/event-bus/charts/nats-streaming/templates/statefulset.yaml new file mode 100644 index 000000000000..aa8716484b35 --- /dev/null +++ b/resources/core/charts/event-bus/charts/nats-streaming/templates/statefulset.yaml @@ -0,0 +1,107 @@ +apiVersion: apps/v1beta2 +kind: StatefulSet +metadata: + name: {{ template "nats-streaming.fullname" . }} + labels: +{{ include "nats-streaming.labels.standard" . | indent 4 }} +spec: + selector: + matchLabels: + app: {{ template "nats-streaming.name" . }} + release: {{ .Release.Name }} + serviceName: {{ template "nats-streaming.fullname" . }} + replicas: {{ .Values.replicaCount }} + updateStrategy: + type: RollingUpdate + template: + metadata: + annotations: + sidecar.istio.io/inject: "true" + labels: + app: {{ template "nats-streaming.name" . }} + release: {{ .Release.Name }} + spec: + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + {{- if .Values.nats.debug }} + - -D + {{- end }} + {{- if .Values.nats.trace }} + - -V + {{- end }} + {{- if .Values.natsStreaming.debug }} + - -SD + {{- end }} + {{- if .Values.natsStreaming.trace }} + - -SV + {{- end }} + - --cluster_id={{ .Values.global.natsStreaming.clusterID }} + - --http_port={{ .Values.global.natsStreaming.ports.monitoring }} + - --max_age={{ .Values.global.natsStreaming.persistence.maxAge }} + {{ if .Values.persistence.enabled }} + - --store=FILE + - --dir=/var/lib/nats-streaming/{{ template "nats-streaming.fullname" . }}/$(POD_NAME) + {{- else }} + - --store=MEMORY + {{- end }} + - --port={{ .Values.global.natsStreaming.ports.client }} + {{- if index .Values "configurationFiles" "gnatsd.conf" }} + - --config=/etc/nats-streaming/{{ template "nats-streaming.fullname" . }}/gnatsd.conf + {{- end }} + {{- if index .Values "configurationFiles" "stan.conf" }} + - --stan_config=/etc/nats-streaming/{{ template "nats-streaming.fullname" . }}/stan.conf + {{- end }} + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + ports: + {{- range $key, $value := .Values.global.natsStreaming.ports }} + - name: {{ $key }} + containerPort: {{ $value }} + {{- end }} + {{- if or .Values.persistence.enabled .Values.configurationFiles }} + volumeMounts: + {{- end }} + {{- if .Values.persistence.enabled }} + - name: datadir + mountPath: /var/lib/nats-streaming/{{ template "nats-streaming.fullname" . }} + {{- end }} + {{- if .Values.configurationFiles }} + - name: config-volume + mountPath: /etc/nats-streaming/{{ template "nats-streaming.fullname" . }} + {{- end }} + resources: +{{ toYaml .Values.global.natsStreaming.resources | indent 10 -}} + {{- if .Values.nodeSelector }} + nodeSelector: +{{ toYaml .Values.nodeSelector | indent 8 }} + {{- end }} + {{- if .Values.configurationFiles }} + volumes: + - name: config-volume + configMap: + name: {{ template "nats-streaming.fullname" . }} + {{- end }} + {{- if .Values.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: datadir + spec: + {{- if .Values.persistence.storageClass }} + {{- if (eq "-" .Values.persistence.storageClass) }} + storageClassName: "" + {{- else }} + storageClassName: {{ .Values.persistence.storageClass }} + {{- end }} + {{- end }} + accessModes: + - {{ .Values.persistence.accessMode | quote }} + resources: + requests: + storage: {{ .Values.global.natsStreaming.persistence.size | quote }} + {{- end }} diff --git a/resources/core/charts/event-bus/charts/nats-streaming/values.yaml b/resources/core/charts/event-bus/charts/nats-streaming/values.yaml new file mode 100644 index 000000000000..8c52c7d476be --- /dev/null +++ b/resources/core/charts/event-bus/charts/nats-streaming/values.yaml @@ -0,0 +1,31 @@ +# Default values for event-bus-nats-streaming. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +replicaCount: 1 # more than 1 replica is not currently supported +image: + repository: nats-streaming + tag: 0.9.2 + pullPolicy: IfNotPresent +persistence: + # If persistence.enabled is false, the MEMORY store will be used, otherwise the FILE store will be used + # Specify storage class to use + # Default: use default storage class on the cluster + # If set to "-", storageClassName: "", which disables dynamic provisioning + # If defined, storageClassName: + # storageClass: "-" + enabled: true + accessMode: ReadWriteOnce +nats: + debug: true + trace: false +natsStreaming: + debug: true + trace: false +# Custom configuration files used to override default NATS and NATS Streaming settings +# Note that store type, ports, debug and tracing have dedicated configuration options here in values.yaml +# and cannot be overriden through the configuration files. +configurationFiles: + gnatsd.conf: | + # configuration file used to override default NATS server settings + stan.conf: | + # content of configuration file used to override default NATS Streaming server settings diff --git a/resources/core/charts/event-bus/charts/publish/Chart.yaml b/resources/core/charts/event-bus/charts/publish/Chart.yaml new file mode 100644 index 000000000000..b266faab2fe2 --- /dev/null +++ b/resources/core/charts/event-bus/charts/publish/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: Publish Helm chart. +name: publish +version: 0.1.0 \ No newline at end of file diff --git a/resources/core/charts/event-bus/charts/publish/templates/_helpers.tpl b/resources/core/charts/event-bus/charts/publish/templates/_helpers.tpl new file mode 100644 index 000000000000..136ae66cd120 --- /dev/null +++ b/resources/core/charts/event-bus/charts/publish/templates/_helpers.tpl @@ -0,0 +1,28 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the publish subchart. +*/}} +{{- define "publish.name" -}} +{{- printf "publish" -}} +{{- end -}} + +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the publish subchart. +*/}} +{{- define "publish.fullname" -}} +{{- printf "%s-%s" .Release.Name "publish" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- /* +Credit: @technosophos +https://github.com/technosophos/common-chart/ +publish.labels.standard prints the standard Helm labels. +The standard labels are frequently used in metadata. +*/ -}} +{{- define "publish.labels.standard" -}} +app: {{ template "publish.name" . }} +heritage: {{ .Release.Service | quote }} +release: {{ .Release.Name | quote }} +chart: {{ .Chart.Name }}-{{ .Chart.Version }} +{{- end -}} diff --git a/resources/core/charts/event-bus/charts/publish/templates/deployment.yaml b/resources/core/charts/event-bus/charts/publish/templates/deployment.yaml new file mode 100644 index 000000000000..6e5f3814d4ad --- /dev/null +++ b/resources/core/charts/event-bus/charts/publish/templates/deployment.yaml @@ -0,0 +1,51 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: {{ template "publish.fullname" . }} + labels: +{{ include "publish.labels.standard" . | indent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + template: + metadata: + annotations: + sidecar.istio.io/inject: "true" + labels: + app: {{ template "publish.name" . }} + release: {{ .Release.Name }} + spec: + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.global.containerRegistry.path }}/event-bus-publish:{{ .Values.global.eventBusVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - --client_id=$(POD_NAME) + - --max_requests={{ .Values.global.publish.maxRequests }} + - --nats_streaming_cluster_id={{ .Values.global.natsStreaming.clusterID }} + - --nats_url=nats://{{ printf "$(%s_NATS_STREAMING_SERVICE_HOST):%s" (upper .Release.Name| replace "-" "_") (toString .Values.global.natsStreaming.ports.client)}} + - --port={{ .Values.port }} + - --trace_api_url={{ .Values.global.trace.apiURL }} + - --trace_service_name={{ .Values.trace.serviceName }} + - --trace_operation_name={{ .Values.trace.operationName }} + ports: + - name: http + containerPort: {{ .Values.port }} + livenessProbe: + exec: + command: + - curl + - -f + - http://localhost:{{ .Values.port }}/v1/status/ready + initialDelaySeconds: 60 + timeoutSeconds: 10 + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + resources: +{{ toYaml .Values.global.publish.resources | indent 12 }} diff --git a/resources/core/charts/event-bus/charts/publish/templates/service.yaml b/resources/core/charts/event-bus/charts/publish/templates/service.yaml new file mode 100644 index 000000000000..e7aaa79e5ad9 --- /dev/null +++ b/resources/core/charts/event-bus/charts/publish/templates/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ template "publish.fullname" . }} + labels: +{{ include "publish.labels.standard" . | indent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.port }} + selector: + app: {{ template "publish.name" . }} + release: {{ .Release.Name }} diff --git a/resources/core/charts/event-bus/charts/publish/values.yaml b/resources/core/charts/event-bus/charts/publish/values.yaml new file mode 100644 index 000000000000..2034ae96b3ec --- /dev/null +++ b/resources/core/charts/event-bus/charts/publish/values.yaml @@ -0,0 +1,12 @@ +# Default values for publish. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +replicaCount: 1 +image: + pullPolicy: IfNotPresent +port: 8080 +service: + type: ClusterIP +trace: + serviceName: publish-service + operationName: send-to-internal-broker diff --git a/resources/core/charts/event-bus/charts/push/Chart.yaml b/resources/core/charts/event-bus/charts/push/Chart.yaml new file mode 100644 index 000000000000..af2900515298 --- /dev/null +++ b/resources/core/charts/event-bus/charts/push/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: push Helm chart +name: push +version: 0.1.0 diff --git a/resources/core/charts/event-bus/charts/push/templates/_helpers.tpl b/resources/core/charts/event-bus/charts/push/templates/_helpers.tpl new file mode 100644 index 000000000000..2388e8228e6f --- /dev/null +++ b/resources/core/charts/event-bus/charts/push/templates/_helpers.tpl @@ -0,0 +1,27 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the push subchart. +*/}} +{{- define "push.name" -}} +{{- printf "push" -}} +{{- end -}} + +{{/* +Expand the name of the push subchart. +*/}} +{{- define "push.fullname" -}} +{{- printf "%s-%s" .Release.Name "push" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- /* +Credit: @technosophos +https://github.com/technosophos/common-chart/ +push.labels.standard prints the standard Helm labels. +The standard labels are frequently used in metadata. +*/ -}} +{{- define "push.labels.standard" -}} +app: {{ template "push.name" . }} +heritage: {{ .Release.Service | quote }} +release: {{ .Release.Name | quote }} +chart: {{ .Chart.Name }}-{{ .Chart.Version }} +{{- end -}} diff --git a/resources/core/charts/event-bus/charts/push/templates/deployment.yaml b/resources/core/charts/event-bus/charts/push/templates/deployment.yaml new file mode 100644 index 000000000000..849c632a4bfd --- /dev/null +++ b/resources/core/charts/event-bus/charts/push/templates/deployment.yaml @@ -0,0 +1,78 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Release.Name }}-push-sa +--- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ .Release.Name }}-subscriptions-controller +rules: +- apiGroups: ["eventing.kyma.cx"] + resources: ["subscriptions"] + verbs: ["get", "watch", "list"] +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ .Release.Name }}-subscriptions-controller +subjects: +- kind: ServiceAccount + name: {{ .Release.Name}}-push-sa + namespace: {{ .Release.Namespace }} +roleRef: + kind: ClusterRole + name: {{ .Release.Name }}-subscriptions-controller + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: {{ template "push.fullname" . }} + labels: +{{ include "push.labels.standard" . | indent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + template: + metadata: + annotations: + sidecar.istio.io/inject: "true" + labels: + app: {{ template "push.name" . }} + release: {{ .Release.Name }} + spec: + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.global.containerRegistry.path }}/event-bus-push:{{ .Values.global.eventBusVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - --client_id=$(POD_NAME) + - --nats_url=nats://{{ printf "$(%s_NATS_STREAMING_SERVICE_HOST):%s" (upper .Release.Name| replace "-" "_") (toString .Values.global.natsStreaming.ports.client)}} + - --cluster_id={{ .Values.global.natsStreaming.clusterID }} + - --tls_skip_verify={{ .Values.http.tlsSkipVerify }} + - --subscription_name_header={{ .Values.global.push.http.subscriptionNameHeader }} + - --topic_header={{ .Values.global.push.http.topicHeader }} + - --trace_api_url={{ .Values.global.trace.apiURL }} + - --trace_service_name={{ .Values.trace.serviceName }} + - --trace_operation_name={{ .Values.trace.operationName }} + - --check_events_activation={{ .Values.check.eventsActivation }} + ports: + - name: http + containerPort: {{ .Values.port }} + livenessProbe: + httpGet: + path: /v1/status/ready + port: http + initialDelaySeconds: 60 + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + resources: +{{ toYaml .Values.global.push.resources | indent 12 }} + serviceAccount: {{ .Release.Name }}-push-sa diff --git a/resources/core/charts/event-bus/charts/push/values.yaml b/resources/core/charts/event-bus/charts/push/values.yaml new file mode 100644 index 000000000000..7980d52cdac5 --- /dev/null +++ b/resources/core/charts/event-bus/charts/push/values.yaml @@ -0,0 +1,14 @@ +# Default values for push. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +replicaCount: 1 +image: + pullPolicy: IfNotPresent +port: 8080 +http: + tlsSkipVerify: true +trace: + serviceName: webhook-service + operationName: deliver-to-endpoint +check: + eventsActivation: true diff --git a/resources/core/charts/event-bus/charts/sub-validator/.helmignore b/resources/core/charts/event-bus/charts/sub-validator/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/event-bus/charts/sub-validator/.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/resources/core/charts/event-bus/charts/sub-validator/Chart.yaml b/resources/core/charts/event-bus/charts/sub-validator/Chart.yaml new file mode 100644 index 000000000000..1b3cf73b430f --- /dev/null +++ b/resources/core/charts/event-bus/charts/sub-validator/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: Subscription validator Helm chart. +name: sub-validator +version: 0.1.0 diff --git a/resources/core/charts/event-bus/charts/sub-validator/templates/_helpers.tpl b/resources/core/charts/event-bus/charts/sub-validator/templates/_helpers.tpl new file mode 100644 index 000000000000..ca4f2e255a9b --- /dev/null +++ b/resources/core/charts/event-bus/charts/sub-validator/templates/_helpers.tpl @@ -0,0 +1,27 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the sub-validator subchart. +*/}} +{{- define "sub-validator.name" -}} +{{- printf "sub-validator" -}} +{{- end -}} + +{{/* +Expand the name of the sub-validator subchart. +*/}} +{{- define "sub-validator.fullname" -}} +{{- printf "%s-%s" .Release.Name "sub-validator" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- /* +Credit: @technosophos +https://github.com/technosophos/common-chart/ +sub-validator.labels.standard prints the standard Helm labels. +The standard labels are frequently used in metadata. +*/ -}} +{{- define "sub-validator.labels.standard" -}} +app: {{ template "sub-validator.name" . }} +heritage: {{ .Release.Service | quote }} +release: {{ .Release.Name | quote }} +chart: {{ .Chart.Name }}-{{ .Chart.Version }} +{{- end -}} diff --git a/resources/core/charts/event-bus/charts/sub-validator/templates/deployment.yaml b/resources/core/charts/event-bus/charts/sub-validator/templates/deployment.yaml new file mode 100644 index 000000000000..503e283e54d1 --- /dev/null +++ b/resources/core/charts/event-bus/charts/sub-validator/templates/deployment.yaml @@ -0,0 +1,93 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Release.Name }}-sub-validator-sa +--- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ .Release.Name }}-sub-validator-subs-controller +rules: +- apiGroups: ["eventing.kyma.cx"] + resources: ["subscriptions"] + verbs: ["get", "watch", "list", "update"] +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ .Release.Name }}-sub-validator-subs-controller +subjects: +- kind: ServiceAccount + name: {{ .Release.Name}}-sub-validator-sa + namespace: {{ .Release.Namespace }} +roleRef: + kind: ClusterRole + name: {{ .Release.Name }}-sub-validator-subs-controller + apiGroup: rbac.authorization.k8s.io +--- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ .Release.Name }}-sub-validator-eas-controller +rules: +- apiGroups: ["remoteenvironment.kyma.cx"] + resources: ["eventactivations"] + verbs: ["get", "watch", "list"] +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ .Release.Name }}-sub-validator-eas-controller +subjects: +- kind: ServiceAccount + name: {{ .Release.Name}}-sub-validator-sa + namespace: {{ .Release.Namespace }} +roleRef: + kind: ClusterRole + name: {{ .Release.Name }}-sub-validator-eas-controller + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: {{ template "sub-validator.fullname" . }} + labels: +{{ include "sub-validator.labels.standard" . | indent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + template: + metadata: + labels: + app: {{ template "sub-validator.name" . }} + release: {{ .Release.Name }} + annotations: + sidecar.istio.io/inject: "true" + spec: + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.global.containerRegistry.path }}/event-bus-sub-validator:{{ .Values.global.eventBusVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - --resyncPeriod={{ .Values.global.subValidator.resyncPeriod }} + ports: + - name: http + containerPort: {{ .Values.port }} + livenessProbe: + httpGet: + path: /v1/status/live + port: http + initialDelaySeconds: 60 + periodSeconds: 5 + readinessProbe: + httpGet: + path: /v1/status/ready + port: http + initialDelaySeconds: 60 + periodSeconds: 5 + resources: +{{ toYaml .Values.global.subValidator.resources | indent 12 }} + serviceAccount: {{ .Release.Name }}-sub-validator-sa diff --git a/resources/core/charts/event-bus/charts/sub-validator/values.yaml b/resources/core/charts/event-bus/charts/sub-validator/values.yaml new file mode 100644 index 000000000000..af2067c1e846 --- /dev/null +++ b/resources/core/charts/event-bus/charts/sub-validator/values.yaml @@ -0,0 +1,7 @@ +# Default values for sub-validator. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +replicaCount: 1 +image: + pullPolicy: IfNotPresent +port: 8080 diff --git a/resources/core/charts/event-bus/requirements.yaml b/resources/core/charts/event-bus/requirements.yaml new file mode 100644 index 000000000000..fa36ac178e21 --- /dev/null +++ b/resources/core/charts/event-bus/requirements.yaml @@ -0,0 +1,4 @@ +dependencies: + - name: nats-streaming + - name: publish + - name: push diff --git a/resources/core/charts/event-bus/templates/_helpers.tpl b/resources/core/charts/event-bus/templates/_helpers.tpl new file mode 100644 index 000000000000..ebf0d6153efd --- /dev/null +++ b/resources/core/charts/event-bus/templates/_helpers.tpl @@ -0,0 +1,7 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the nats-streaming subchart. +*/}} +{{- define "nats-streaming.fullname" -}} +{{- printf "%s-%s" .Release.Name "nats-streaming" | trunc 63 | trimSuffix "-" -}} +{{- end -}} \ No newline at end of file diff --git a/resources/core/charts/event-bus/templates/tests/test-e2e-tester.yaml b/resources/core/charts/event-bus/templates/tests/test-e2e-tester.yaml new file mode 100644 index 000000000000..aefebe4539fc --- /dev/null +++ b/resources/core/charts/event-bus/templates/tests/test-e2e-tester.yaml @@ -0,0 +1,106 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Values.e2eTests.nameTester }} + labels: + helm-chart-test: "true" +--- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: test-core-event-bus-subs + labels: + helm-chart-test: "true" +rules: +- apiGroups: ["eventing.kyma.cx"] + resources: ["subscriptions"] + verbs: ["create","get", "watch", "list", "delete"] +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: test-core-event-bus-subs + labels: + helm-chart-test: "true" +subjects: +- kind: ServiceAccount + name: {{ .Values.e2eTests.nameTester }} + namespace: {{ .Release.Namespace }} +roleRef: + kind: ClusterRole + name: test-core-event-bus-subs + apiGroup: rbac.authorization.k8s.io +--- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: test-core-event-bus-eas + labels: + helm-chart-test: "true" +rules: +- apiGroups: ["remoteenvironment.kyma.cx"] + resources: ["eventactivations"] + verbs: ["create", "get", "watch", "list", "delete"] +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: test-core-event-bus-eas + labels: + helm-chart-test: "true" +subjects: +- kind: ServiceAccount + name: {{ .Values.e2eTests.nameTester }} + namespace: {{ .Release.Namespace }} +roleRef: + kind: ClusterRole + name: test-core-event-bus-eas + apiGroup: rbac.authorization.k8s.io +--- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: test-core-event-bus-k8s + labels: + helm-chart-test: "true" +rules: +- apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["*"] +- apiGroups: [""] + resources: ["pods", "pods/status", "services"] + verbs: ["*"] +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: test-core-event-bus-k8s + labels: + helm-chart-test: "true" +subjects: +- kind: ServiceAccount + name: {{ .Values.e2eTests.nameTester }} + namespace: {{ .Release.Namespace }} +roleRef: + kind: ClusterRole + name: test-core-event-bus-k8s + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: v1 +kind: Pod +metadata: + name: {{ .Values.e2eTests.nameTester }} + labels: + helm-chart-test: "true" + annotations: +# sidecar.istio.io/inject: "true" # needed if the tester could run with side cars + helm.sh/hook: test-success +spec: + serviceAccount: {{ .Values.e2eTests.nameTester }} + containers: + - image: "{{ .Values.global.containerRegistry.path }}/event-bus-e2e-tester:{{ .Values.global.eventBusVersion }}" + imagePullPolicy: IfNotPresent + name: {{ .Values.e2eTests.nameTester }} + args: + - -subscriber-image={{ .Values.global.containerRegistry.path }}/event-bus-e2e-subscriber:{{ .Values.global.eventBusVersion }} + restartPolicy: Never diff --git a/resources/core/charts/event-bus/values.yaml b/resources/core/charts/event-bus/values.yaml new file mode 100644 index 000000000000..558454c8cb89 --- /dev/null +++ b/resources/core/charts/event-bus/values.yaml @@ -0,0 +1,39 @@ +# Default values for event-bus. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +global: + natsStreaming: + clusterID: kyma-core-nats-streaming + ports: + client: 4222 + monitoring: 8222 + persistence: + # Max duration for which an event can be stored (0 for unlimited) + maxAge: "24h" + size: "1Gi" + resources: + limits: + memory: "32M" + publish: + maxRequests: 16 + resources: + limits: + memory: "32M" + push: + http: + subscriptionNameHeader: "Kyma-Subscription" + topicHeader: "Kyma-Topic" + resources: + limits: + memory: "32M" + subValidator: + resyncPeriod: "1m" + resources: + limits: + memory: "32M" + trace: + apiURL: http://zipkin.kyma-system:9411/api/v1/spans + eventBusVersion: "0.2.91" +e2eTests: + nameTester: "test-core-event-bus-tester" + nameSubscriber: "test-core-event-bus-subscriber" diff --git a/resources/core/charts/helm-broker/.helmignore b/resources/core/charts/helm-broker/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/helm-broker/.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/resources/core/charts/helm-broker/Chart.yaml b/resources/core/charts/helm-broker/Chart.yaml new file mode 100644 index 000000000000..1d1cb9d46486 --- /dev/null +++ b/resources/core/charts/helm-broker/Chart.yaml @@ -0,0 +1,4 @@ +name: helm-broker +description: Helm service-broker deployment chart. +appVersion: 0.2.37 +version: 0.1.4 diff --git a/resources/core/charts/helm-broker/README.md b/resources/core/charts/helm-broker/README.md new file mode 100644 index 000000000000..a3fd7f158712 --- /dev/null +++ b/resources/core/charts/helm-broker/README.md @@ -0,0 +1,15 @@ +``` + _ _ _ ____ _ + | | | | | | | _ \ | | + | |__| | ___| |_ __ ___ | |_) |_ __ ___ | | _____ _ __ + | __ |/ _ \ | '_ ` _ \ | _ <| '__/ _ \| |/ / _ \ '__| + | | | | __/ | | | | | | | |_) | | | (_) | < __/ | + |_| |_|\___|_|_| |_| |_| |____/|_| \___/|_|\_\___|_| + +``` + +## Overview + +Helm Broker provides bundles in the [Service Catalog](../service-catalog/README.md). A bundle is an abstraction layer over a Helm chart which enables you to provide more information about the Helm chart. For example, a bundle can provide plan definitions or binding details. Service Catalog requires this information. Bundles are services available in Service Catalog. + +Helm Broker implements the [Service Broker API](https://github.com/openservicebrokerapi/servicebroker/blob/master/spec.md). For more information about the Service Brokers, see the [Service Brokers overview](../../../../docs/service-brokers/docs/001-overview-service-brokers.md) documentation. Find the details about the Helm Broker in the [docs](../../../../docs/service-brokers/docs) repository. diff --git a/resources/core/charts/helm-broker/charts/etcd/.helmignore b/resources/core/charts/helm-broker/charts/etcd/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/helm-broker/charts/etcd/.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/resources/core/charts/helm-broker/charts/etcd/Chart.yaml b/resources/core/charts/helm-broker/charts/etcd/Chart.yaml new file mode 100644 index 000000000000..5f1baa3daf7d --- /dev/null +++ b/resources/core/charts/helm-broker/charts/etcd/Chart.yaml @@ -0,0 +1,10 @@ +# Version from not yet merged PR: https://github.com/kubernetes/charts/pull/4678 +name: etcd +home: https://github.com/coreos/etcd +version: 0.4.0 +appVersion: 3.2.14 +description: Distributed reliable key-value store for the most critical data of a distributed system. +icon: https://raw.githubusercontent.com/coreos/etcd/master/logos/etcd-horizontal-color.png +sources: +- https://github.com/coreos/etcd + diff --git a/resources/core/charts/helm-broker/charts/etcd/README.md b/resources/core/charts/helm-broker/charts/etcd/README.md new file mode 100644 index 000000000000..4ae4424f5af5 --- /dev/null +++ b/resources/core/charts/helm-broker/charts/etcd/README.md @@ -0,0 +1,170 @@ +# Etcd Helm Chart + +Credit to https://github.com/ingvagabund. This is an implementation of that work + +* https://github.com/kubernetes/contrib/pull/1295 + +## Prerequisites Details +* Kubernetes 1.5 (for `StatefulSets` support) +* PV support on the underlying infrastructure + +## StatefulSet Details +* https://kubernetes.io/docs/concepts/abstractions/controllers/statefulsets/ + +## StatefulSet Caveats +* https://kubernetes.io/docs/concepts/abstractions/controllers/statefulsets#limitations + +## Todo +* Implement SSL + +## Chart Details +This chart will do the following: + +* Implemented a dynamically scalable etcd cluster using Kubernetes StatefulSets + +## Installing the Chart + +To install the chart with the release name `my-release`: + +```bash +$ helm repo add incubator http://storage.googleapis.com/kubernetes-charts-incubator +$ helm install --name my-release incubator/etcd +``` + +## Configuration + +The following tables lists the configurable parameters of the etcd chart and their default values. + +| Parameter | Description | Default | +| ----------------------- | ---------------------------------- | ---------------------------------------------------------- | +| `Name` | Spark master name | `etcd` | +| `Image` | Container image name | `gcr.io/google_containers/etcd-amd64` | +| `ImageTag` | Container image tag | `2.2.5` | +| `ImagePullPolicy` | Container pull policy | `Always` | +| `Replicas` | k8s statefulset replicas | `3` | +| `Component` | k8s selector key | `etcd` | +| `Cpu` | container requested cpu | `100m` | +| `Memory` | container requested memory | `512Mi` | +| `ClientPort` | k8s service port | `2379` | +| `PeerPorts` | Container listening port | `2380` | +| `Storage` | Persistent volume size | `1Gi` | +| `StorageClass` | Persistent volume storage class | `anything` | + +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 incubator/etcd +``` + +> **Tip**: You can use the default [values.yaml](values.yaml) + +# Deep dive + +## Cluster Health + +``` +$ for i in <0..n>; do kubectl exec -- sh -c 'etcdctl cluster-health'; done +``` +eg. +``` +$ for i in {0..9}; do kubectl exec named-lynx-etcd-$i --namespace=etcd -- sh -c 'etcdctl cluster-health'; done +member 7878c44dabe58db is healthy: got healthy result from http://named-lynx-etcd-7.named-lynx-etcd:2379 +member 19d2ab7b415341cc is healthy: got healthy result from http://named-lynx-etcd-4.named-lynx-etcd:2379 +member 6b627d1b92282322 is healthy: got healthy result from http://named-lynx-etcd-3.named-lynx-etcd:2379 +member 6bb377156d9e3fb3 is healthy: got healthy result from http://named-lynx-etcd-0.named-lynx-etcd:2379 +member 8ebbb00c312213d6 is healthy: got healthy result from http://named-lynx-etcd-8.named-lynx-etcd:2379 +member a32e3e8a520ff75f is healthy: got healthy result from http://named-lynx-etcd-5.named-lynx-etcd:2379 +member dc83003f0a226816 is healthy: got healthy result from http://named-lynx-etcd-2.named-lynx-etcd:2379 +member e3dc94686f60465d is healthy: got healthy result from http://named-lynx-etcd-6.named-lynx-etcd:2379 +member f5ee1ca177a88a58 is healthy: got healthy result from http://named-lynx-etcd-1.named-lynx-etcd:2379 +cluster is healthy +``` + +## Failover + +If any etcd member fails it gets re-joined eventually. +You can test the scenario by killing process of one of the replicas: + +```shell +$ ps aux | grep etcd-1 +$ kill -9 ETCD_1_PID +``` + +```shell +$ kubectl get pods -l "component=${RELEASE-NAME}-etcd" +NAME READY STATUS RESTARTS AGE +etcd-0 1/1 Running 0 54s +etcd-2 1/1 Running 0 51s +``` + +After a while: + +```shell +$ kubectl get pods -l "component=${RELEASE-NAME}-etcd" +NAME READY STATUS RESTARTS AGE +etcd-0 1/1 Running 0 1m +etcd-1 1/1 Running 0 20s +etcd-2 1/1 Running 0 1m +``` + +You can check state of re-joining from ``etcd-1``'s logs: + +```shell +$ kubectl logs etcd-1 +Waiting for etcd-0.etcd to come up +Waiting for etcd-1.etcd to come up +ping: bad address 'etcd-1.etcd' +Waiting for etcd-1.etcd to come up +Waiting for etcd-2.etcd to come up +Re-joining etcd member +Updated member with ID 7fd61f3f79d97779 in cluster +2016-06-20 11:04:14.962169 I | etcdmain: etcd Version: 2.2.5 +2016-06-20 11:04:14.962287 I | etcdmain: Git SHA: bc9ddf2 +... +``` + +## Scaling using kubectl + +This is for reference. Scaling should be managed by `helm upgrade` + +The etcd cluster can be scale up by running ``kubectl patch`` or ``kubectl edit``. For instance, + +```sh +$ kubectl get pods -l "component=${RELEASE-NAME}-etcd" +NAME READY STATUS RESTARTS AGE +etcd-0 1/1 Running 0 7m +etcd-1 1/1 Running 0 7m +etcd-2 1/1 Running 0 6m + +$ kubectl patch statefulset/etcd -p '{"spec":{"replicas": 5}}' +"etcd" patched + +$ kubectl get pods -l "component=${RELEASE-NAME}-etcd" +NAME READY STATUS RESTARTS AGE +etcd-0 1/1 Running 0 8m +etcd-1 1/1 Running 0 8m +etcd-2 1/1 Running 0 8m +etcd-3 1/1 Running 0 4s +etcd-4 1/1 Running 0 1s +``` + +Scaling-down is similar. For instance, changing the number of replicas to ``4``: + +```sh +$ kubectl edit statefulset/etcd +statefulset "etcd" edited + +$ kubectl get pods -l "component=${RELEASE-NAME}-etcd" +NAME READY STATUS RESTARTS AGE +etcd-0 1/1 Running 0 8m +etcd-1 1/1 Running 0 8m +etcd-2 1/1 Running 0 8m +etcd-3 1/1 Running 0 4s +``` + +Once a replica is terminated (either by running ``kubectl delete pod etcd-ID`` or scaling down), +content of ``/var/run/etcd/`` directory is cleaned up. +If any of the etcd pods restarts (e.g. caused by etcd failure or any other), +the directory is kept untouched so the pod can recover from the failure. diff --git a/resources/core/charts/helm-broker/charts/etcd/templates/_helpers.tpl b/resources/core/charts/helm-broker/charts/etcd/templates/_helpers.tpl new file mode 100644 index 000000000000..fe486ea82a75 --- /dev/null +++ b/resources/core/charts/helm-broker/charts/etcd/templates/_helpers.tpl @@ -0,0 +1,32 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "etcd.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 "etcd.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 "etcd.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/resources/core/charts/helm-broker/charts/etcd/templates/configmap.yaml b/resources/core/charts/helm-broker/charts/etcd/templates/configmap.yaml new file mode 100644 index 000000000000..2d36f3922b6d --- /dev/null +++ b/resources/core/charts/helm-broker/charts/etcd/templates/configmap.yaml @@ -0,0 +1,176 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ template "etcd.fullname" . }} + labels: + app: {{ template "etcd.name" . }} + chart: {{ template "etcd.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +data: + init.sh: | + #!/bin/sh + set -ex + + HOSTNAME=$(hostname) + IP=$(ip r get 1 | awk '{print $NF;exit}') + PROTO=http{{- if .Values.etcd.peerTLS }}s{{- end }} + + # store member id into PVC for later member replacement + collect_member() { + while ! etcdctl member list &>/dev/null; do sleep 1; done + etcdctl member list | grep http://${HOSTNAME}.${SET_NAME}.${POD_NAMESPACE}:2379 | cut -d':' -f1 | cut -d'[' -f1 > /etcd/member_id + exit 0 + } + + # endpoint of the first etcd member + ep() { + echo "http://${SET_NAME}-0.${SET_NAME}.${POD_NAMESPACE}:2379" + } + + # wait for the given hostname to become available to the network + await_host() { + echo "Waiting for [${1}] to come up " + while true; do + echo -n '.' + ping -W 1 -c 1 ${1} > /dev/null && break + sleep 1s + done + echo -n " done" + } + + # add a new member to the etcd cluster + add_member() { + EPS=$(ep) + MEMBER_LIST=$(etcdctl --endpoints ${EPS} member list) + + if echo "${MEMBER_LIST}" | grep ${HOSTNAME}; then + echo "Removing old member [${HOSTNAME}] from etcd cluster" + MEMBER_ID=$(echo "$MEMBER_LIST" | grep ${HOSTNAME} | cut -d':' -f1 | cut -d'[' -f1) + etcdctl --endpoints ${EPS} member remove ${MEMBER_ID} + fi + + if [ -e $ETCD_DATA_DIR ]; then + echo "Removing old data dir [${ETCD_DATA_DIR}] " + rm -Rf $ETCD_DATA_DIR + fi + + echo "Adding [${HOSTNAME}] as a new member" + STR_ENV="ETCD_" + ETCD_PREFIX=$(etcdctl --endpoints ${EPS} member add ${HOSTNAME} ${PROTO}://${HOSTNAME}.${SET_NAME}.${POD_NAMESPACE}:2380 | \ + { + while read i + do + if test "${i#*$STR_ENV}" != "$i"; then + ETCD_PREFIX="$ETCD_PREFIX $i" + fi + done + echo "export $ETCD_PREFIX" | sed 's/"//g' + }) + eval \${ETCD_PREFIX} + + collect_member & + echo "Start etcd daemon" + exec etcd \ + --listen-peer-urls ${PROTO}://${IP}:2380 \ + --initial-advertise-peer-urls ${PROTO}://${HOSTNAME}.${SET_NAME}.${POD_NAMESPACE}:2380 \ + --listen-client-urls http://${IP}:2379,http://127.0.0.1:2379 \ + {{- if .Values.etcd.peerTLS }} + --peer-auto-tls \ + {{- end }} + --advertise-client-urls http://${HOSTNAME}.${SET_NAME}.${POD_NAMESPACE}:2379 + } + + # -etcd- + SET_ID=${HOSTNAME##*-} + + # are we rejoining after a failure? + if [ -e /etcd/member_id ]; then + echo "Found old member ID, attempting to rejoin the etcd cluster" + + MEMBER_ID=$(cat /etcd/member_id) + + # if we are the first member we can't resolve over the default endpoint + # attempt to resolve over the defined service instead + if [ "${SET_ID}" -eq 0 ]; then + EPS="http://${SET_NAME}.${POD_NAMESPACE}:2379" + else + EPS=$(ep) + fi + + await_host ${HOSTNAME}.${SET_NAME}.${POD_NAMESPACE} + + # attempt to resolve a member list from the selected endpoint + # if this fails we assume that the previous cluster is gone + set +e + MEMBER_LIST=$(etcdctl --endpoints ${EPS} member list) + if [ "$?" -ne 0 ]; then + echo "Removing old data dir [${ETCD_DATA_DIR}] from cluster that is gone" + echo "On next restart this will cause the creation of a new cluster" + rm -Rf /etcd/* + exit 0 + fi + set -e + + if [ ! -z ${MEMBER_ID+x} ] && echo "$MEMBER_LIST" | grep ${MEMBER_ID}; then + echo "The member ID is still listed in the cluster, join with member ID and existing data" + etcdctl --endpoints ${EPS} member update ${MEMBER_ID} ${PROTO}://${HOSTNAME}.${SET_NAME}.${POD_NAMESPACE}:2380 + exec etcd --name ${HOSTNAME} \ + --listen-peer-urls ${PROTO}://${IP}:2380 \ + --initial-advertise-peer-urls ${PROTO}://${IP}:2380 \ + --listen-client-urls http://${IP}:2379,http://127.0.0.1:2379 \ + {{- if .Values.etcd.peerTLS }} + --peer-auto-tls \ + {{- end }} + --advertise-client-urls http://${HOSTNAME}.${SET_NAME}.${POD_NAMESPACE}:2379 + else + echo "The member ID is not known to the cluster" + add_member + fi + fi + + # adding a new member to existing cluster + if [ "${SET_ID}" -ge 1 ]; then + await_host ${HOSTNAME}.${SET_NAME}.${POD_NAMESPACE} + add_member + fi + + # creating the first member of a new cluster + await_host ${HOSTNAME}.${SET_NAME}.${POD_NAMESPACE} + + PEERS="${HOSTNAME}=${PROTO}://${HOSTNAME}.${SET_NAME}.${POD_NAMESPACE}:2380" + + collect_member & + echo "Add [${HOSTNAME}] as member to a NEW cluster" + rm -Rf /etcd/* + exec etcd --name ${HOSTNAME} \ + --initial-advertise-peer-urls ${PROTO}://${HOSTNAME}.${SET_NAME}.${POD_NAMESPACE}:2380 \ + --listen-peer-urls ${PROTO}://${IP}:2380 \ + --listen-client-urls http://${IP}:2379,http://127.0.0.1:2379 \ + --advertise-client-urls http://${HOSTNAME}.${SET_NAME}.${POD_NAMESPACE}:2379 \ + --initial-cluster ${PEERS} \ + {{- if .Values.etcd.peerTLS }} + --peer-auto-tls \ + {{- end }} + --initial-cluster-state new + pre-stop.sh: | + #!/bin/sh + + # Cleaning up after a controlled stop, like a scale down of the cluster + # is needed so the member is removed and could be started fresh + PROTO=http{{- if .Values.etcd.peerTLS }}s{{- end }} + EPS="http://${SET_NAME}-0.${SET_NAME}.${POD_NAMESPACE}:2379" + HOSTNAME=$(hostname) + ETCD_DIR="/etcd" + + member_hash() { + etcdctl member list | grep ${PROTO}://${HOSTNAME}.${SET_NAME}.${POD_NAMESPACE}:2380 | cut -d':' -f1 | cut -d'[' -f1 + } + + echo "Removing [${HOSTNAME}] from etcd cluster" + + etcdctl --endpoints ${EPS} member remove $(member_hash) + if [ $? -eq 0 ]; then + # Remove everything otherwise the cluster will no longer scale-up + rm -rf $ETCD_DIR + fi diff --git a/resources/core/charts/helm-broker/charts/etcd/templates/service.yaml b/resources/core/charts/helm-broker/charts/etcd/templates/service.yaml new file mode 100644 index 000000000000..f0902403780b --- /dev/null +++ b/resources/core/charts/helm-broker/charts/etcd/templates/service.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + service.alpha.kubernetes.io/tolerate-unready-endpoints: "true" +metadata: + name: {{ template "etcd.fullname" . }} + labels: + app: {{ template "etcd.name" . }} + chart: {{ template "etcd.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + ports: + - port: 2380 + name: etcd-server + - port: 2379 + name: etcd-client + clusterIP: None + selector: + app: {{ template "etcd.name" . }} + release: {{ .Release.Name }} diff --git a/resources/core/charts/helm-broker/charts/etcd/templates/statefulset.yaml b/resources/core/charts/helm-broker/charts/etcd/templates/statefulset.yaml new file mode 100644 index 000000000000..8f63c696af4a --- /dev/null +++ b/resources/core/charts/helm-broker/charts/etcd/templates/statefulset.yaml @@ -0,0 +1,107 @@ +apiVersion: apps/v1beta1 +kind: StatefulSet +metadata: + name: {{ template "etcd.fullname" . }} + labels: + app: {{ template "etcd.name" . }} + chart: {{ template "etcd.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + serviceName: {{ template "etcd.fullname" . }} + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app: {{ template "etcd.name" . }} + release: {{ .Release.Name }} + template: + metadata: + name: {{ template "etcd.fullname" . }} + labels: + app: {{ template "etcd.name" . }} + release: {{ .Release.Name }} + spec: + affinity: + podAntiAffinity: + {{- if eq .Values.antiAffinity "hard" }} + requiredDuringSchedulingIgnoredDuringExecution: + - topologyKey: "kubernetes.io/hostname" + labelSelector: + matchLabels: + app: {{ template "etcd.name" . }} + release: {{ .Release.Name | quote }} + {{- else if eq .Values.antiAffinity "soft" }} + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 1 + podAffinityTerm: + topologyKey: "kubernetes.io/hostname" + labelSelector: + matchLabels: + app: {{ template "etcd.name" . }} + release: {{ .Release.Name | quote }} + {{- end }} +{{- if .Values.nodeSelector }} + nodeSelector: +{{ toYaml .Values.nodeSelector | indent 8 }} +{{- end }} +{{- if .Values.tolerations }} + tolerations: +{{ toYaml .Values.tolerations | indent 8 }} +{{- end }} + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: peer + containerPort: 2380 + - name: client + containerPort: 2379 + env: + - name: SET_NAME + value: {{ template "etcd.fullname" . }} + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: ETCD_DATA_DIR + value: /etcd/default.etcd + resources: +{{ toYaml .Values.resources | indent 12 }} + volumeMounts: + - name: datadir + mountPath: /etcd + - name: config + mountPath: /var/config + lifecycle: + preStop: + exec: + command: ["/var/config/pre-stop.sh"] + command: ["/var/config/init.sh"] + volumes: + - name: config + configMap: + name: {{ template "etcd.fullname" . }} + defaultMode: 0744 + + volumeClaimTemplates: + - metadata: + name: datadir + annotations: + {{- if .Values.persistentVolume.annotations }} +{{ toYaml .Values.persistentVolume.annotations | indent 10 }} + {{- end }} + labels: + app: {{ template "etcd.name" . }} + chart: {{ template "etcd.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} + spec: + accessModes: +{{ toYaml .Values.persistentVolume.accessModes | indent 10 }} + resources: + requests: + storage: "{{ .Values.persistentVolume.size }}" + {{- if not .Values.global.isLocalEnv }} + storageClassName: "{{ .Values.persistentVolume.storageClass }}" + {{- end }} \ No newline at end of file diff --git a/resources/core/charts/helm-broker/charts/etcd/values.yaml b/resources/core/charts/helm-broker/charts/etcd/values.yaml new file mode 100644 index 000000000000..2e43f9c037e8 --- /dev/null +++ b/resources/core/charts/helm-broker/charts/etcd/values.yaml @@ -0,0 +1,40 @@ +# Replicas scales the cluster with the "helm upgrade" command +# The recommended etcd cluster size is 3, 5 or 7, +# which is decided by the fault tolerance requirement. +# A 7-member cluster can provide enough fault tolerance in most cases. +# While larger cluster provides better fault tolerance the write performance +# reduces since data needs to be replicated to more machines. +replicaCount: 3 + +image: + repository: k8s.gcr.io/etcd-amd64 + tag: 3.2.14 + ImagePullPolicy: IfNotPresent + +etcd: + peerTLS: false + +resources: {} + # If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 200m + # memory: 1Gi + # requests: + # cpu: 100m + # memory: 512Mi + +persistentVolume: + size: 1G + storageClass: "-" + annotations: {} + accessModes: + - ReadWriteOnce + +## ref: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#nodeselector +nodeSelector: {} + +## ref: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#taints-and-tolerations-beta-feature +tolerations: [] + +antiAffinity: "soft" diff --git a/resources/core/charts/helm-broker/templates/_helpers.tpl b/resources/core/charts/helm-broker/templates/_helpers.tpl new file mode 100644 index 000000000000..3ee370174e50 --- /dev/null +++ b/resources/core/charts/helm-broker/templates/_helpers.tpl @@ -0,0 +1,30 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Define template equivalent to "name" template, but used for reposerver deployment/service +*/}} +{{- define "reposerver-name" -}} +{{- $rName := printf "%s-%s" .Chart.Name "reposerver" -}} +{{- default $rName .Values.ReposerverNameOverride | 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). +*/}} +{{- define "fullname" -}} +{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Define template equivalent to "fullname" template, but used for reposerver deployment/service +*/}} +{{- define "reposerver-fullname" -}} +{{- printf "%s-%s-%s" .Release.Name .Chart.Name "reposerver" | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/resources/core/charts/helm-broker/templates/cluster-role-binding.yaml b/resources/core/charts/helm-broker/templates/cluster-role-binding.yaml new file mode 100644 index 000000000000..30d4ae76172b --- /dev/null +++ b/resources/core/charts/helm-broker/templates/cluster-role-binding.yaml @@ -0,0 +1,17 @@ +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1beta1 +metadata: + name: {{ template "fullname" . }} + labels: + app: {{ template "name" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +subjects: +- kind: ServiceAccount + name: {{ template "fullname" . }} + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ template "fullname" . }}-reader diff --git a/resources/core/charts/helm-broker/templates/cluster-role.yaml b/resources/core/charts/helm-broker/templates/cluster-role.yaml new file mode 100644 index 000000000000..33c65a20157f --- /dev/null +++ b/resources/core/charts/helm-broker/templates/cluster-role.yaml @@ -0,0 +1,14 @@ +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1beta1 +metadata: + # "namespace" omitted since ClusterRoles are not namespaced + name: {{ template "fullname" . }}-reader + labels: + app: {{ template "name" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +rules: +- apiGroups: [""] + resources: ["secrets", "configmaps", "services"] + verbs: ["get"] diff --git a/resources/core/charts/helm-broker/templates/configmap.yaml b/resources/core/charts/helm-broker/templates/configmap.yaml new file mode 100644 index 000000000000..39100db53382 --- /dev/null +++ b/resources/core/charts/helm-broker/templates/configmap.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: helm-config-map + namespace: {{ .Release.Namespace }} + labels: + app: {{ template "fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" +{{ if .Values.config }} +data: + config.yaml: |- +{{ .Values.config | toYaml | indent 4 }} +{{ end }} diff --git a/resources/core/charts/helm-broker/templates/deploy.yaml b/resources/core/charts/helm-broker/templates/deploy.yaml new file mode 100644 index 000000000000..7b394e5a5a90 --- /dev/null +++ b/resources/core/charts/helm-broker/templates/deploy.yaml @@ -0,0 +1,158 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: {{ template "fullname" . }} + labels: + app: {{ template "name" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +spec: + replicas: 1 + selector: + matchLabels: + app: {{ template "fullname" . }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + template: + metadata: + labels: + app: {{ template "fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" + spec: + serviceAccountName: {{ template "fullname" . }} + + initContainers: + - name: "init-{{ .Chart.Name }}" + image: "{{ .Values.global.containerRegistry.path }}/alpine-net:{{ .Values.initImage.tag }}" + imagePullPolicy: {{ .Values.initImage.pullPolicy }} + command: ['sh', '-c', 'until $(curl --output /dev/null --silent --fail http://core-helm-broker-etcd.kyma-system.svc.cluster.local:2379/health); do echo waiting for etcd service; sleep 2; done;'] + + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.global.containerRegistry.path }}/helm-broker:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + env: + - name: APP_TMP_DIR + value: /tmp + - name: APP_PORT + value: "{{ .Values.service.internalPort }}" + - name: APP_CONFIG_FILE_NAME + value: /etc/config/helm-broker/config.yaml + - name: APP_HELM_TILLER_HOST + value: "tiller-deploy.kube-system.svc.cluster.local:44134" + volumeMounts: + - mountPath: /tmp + name: tmp-empty-dir + + - mountPath: /etc/config/helm-broker + name: config-volume + + ports: + - containerPort: {{ .Values.service.internalPort }} + # Temporary solution for readiness probe + # Ref: https://github.com/istio/istio/issues/2628 + readinessProbe: + exec: + command: + - curl + - localhost:{{ .Values.service.internalPort }}/statusz + failureThreshold: 3 + initialDelaySeconds: 10 + periodSeconds: 3 + successThreshold: 1 + timeoutSeconds: 2 + livenessProbe: + exec: + command: + - curl + - localhost:{{ .Values.service.internalPort }}/statusz + periodSeconds: 10 + timeoutSeconds: 2 + successThreshold: 1 + + volumes: + - name: tmp-empty-dir + emptyDir: + medium: "Memory" # mount a tmpfs (RAM-backed filesystem). Default emptyDir volumes are stored on whatever medium is backing the node - that might be disk or SSD or network storage, depending on your environment. + - name: config-volume + configMap: + name: helm-config-map + +--- + +{{ if .Values.embeddedRepository.provision }} +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: {{ template "reposerver-fullname" . }} + labels: + app: {{ template "reposerver-name" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +spec: + replicas: 1 + selector: + matchLabels: + app: {{ template "reposerver-name" . }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + template: + metadata: + labels: + app: {{ template "reposerver-name" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" + spec: + serviceAccountName: {{ template "fullname" . }} + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.global.containerRegistry.path }}/helm-broker-reposerver:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + + volumeMounts: + - mountPath: /data + name: bundle-repository + + ports: + - containerPort: 8080 + # Temporary solution for readiness probe + # Ref: https://github.com/istio/istio/issues/2628 + readinessProbe: + exec: + command: + - curl + - localhost:{{ .Values.embeddedRepository.service.internalPort }}/index.yaml + failureThreshold: 3 + initialDelaySeconds: 10 + periodSeconds: 3 + successThreshold: 1 + timeoutSeconds: 2 + livenessProbe: + exec: + command: + - curl + - localhost:{{ .Values.embeddedRepository.service.internalPort }}/index.yaml + periodSeconds: 10 + timeoutSeconds: 2 + successThreshold: 1 + + volumes: + - name: tmp-empty-dir + emptyDir: + medium: "Memory" # mount a tmpfs (RAM-backed filesystem). Default emptyDir volumes are stored on whatever medium is backing the node - that might be disk or SSD or network storage, depending on your environment. + - name: config-volume + configMap: + name: helm-config-map + - name: bundle-repository + persistentVolumeClaim: + claimName: {{ .Values.bundlesStorage.claimName }} +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/helm-broker/templates/sa.yaml b/resources/core/charts/helm-broker/templates/sa.yaml new file mode 100644 index 000000000000..a6e83668d1dd --- /dev/null +++ b/resources/core/charts/helm-broker/templates/sa.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ template "fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + app: {{ template "name" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" diff --git a/resources/core/charts/helm-broker/templates/service-broker.yaml b/resources/core/charts/helm-broker/templates/service-broker.yaml new file mode 100644 index 000000000000..6a7166d8bb34 --- /dev/null +++ b/resources/core/charts/helm-broker/templates/service-broker.yaml @@ -0,0 +1,13 @@ +{{- if .Release.IsUpgrade -}} +apiVersion: servicecatalog.k8s.io/v1beta1 +kind: ClusterServiceBroker +metadata: + name: {{ template "fullname" . }} + labels: + app: {{ template "name" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +spec: + url: http://{{ template "fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local +{{- end -}} diff --git a/resources/core/charts/helm-broker/templates/svc.yaml b/resources/core/charts/helm-broker/templates/svc.yaml new file mode 100644 index 000000000000..ef4a4439c012 --- /dev/null +++ b/resources/core/charts/helm-broker/templates/svc.yaml @@ -0,0 +1,47 @@ +kind: Service +apiVersion: v1 +metadata: + name: {{ template "fullname" . }} + annotations: + "auth.istio.io/{{.Values.service.externalPort}}": NONE + labels: + app: {{ template "name" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +spec: + type: {{ .Values.service.type }} + selector: + app: {{ template "fullname" . }} + release: {{ .Release.Name }} + ports: + - protocol: TCP + name: http-hb-port + port: {{ .Values.service.externalPort }} + targetPort: {{ .Values.service.internalPort }} + +--- +{{ if .Values.embeddedRepository.provision }} +kind: Service +apiVersion: v1 +metadata: + name: {{ template "reposerver-fullname" . }} + annotations: + "auth.istio.io/{{.Values.embeddedRepository.service.externalPort}}": NONE + labels: + app: {{ template "reposerver-name" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +spec: + type: {{ .Values.embeddedRepository.service.type }} + selector: + app: {{ template "reposerver-name" . }} + release: {{ .Release.Name }} + ports: + - protocol: TCP + name: http-hb-reposerver-port + port: {{ .Values.embeddedRepository.service.externalPort }} + targetPort: {{ .Values.embeddedRepository.service.internalPort }} + +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/helm-broker/values.yaml b/resources/core/charts/helm-broker/values.yaml new file mode 100644 index 000000000000..f6facb61f270 --- /dev/null +++ b/resources/core/charts/helm-broker/values.yaml @@ -0,0 +1,56 @@ +initImage: + tag: 0.2.32 + # valid values are "IfNotPresent", "Never", and "Always" + pullPolicy: "IfNotPresent" +image: + tag: 0.2.37 + # valid values are "IfNotPresent", "Never", and "Always" + pullPolicy: "IfNotPresent" +service: + type: NodePort + externalPort: 80 + internalPort: 8080 + +embeddedRepository: + # Defines, if embedded bundle repository should be provisioned. + # To provision, specify this value to true + provision: true + # Defines service for embedded repository + service: + type: NodePort + externalPort: 80 + internalPort: 8080 + +config: + repository: + baseURL: "http://core-helm-broker-reposerver" + storage: + - driver: etcd + provide: + instance: ~ + instanceOperation: ~ + entityInstanceBindData: ~ + + etcd: + endpoints: + - http://core-helm-broker-etcd.kyma-system.svc.cluster.local:2379 + - driver: memory + provide: + chart: ~ + bundle: ~ + + +etcd: + Component: "hb-etcd" + ImageTag: "3.1.10" + ImagePullPolicy: "IfNotPresent" + Memory: "32Mi" + Storage: "1Gi" + + # By default etcd subchart will be release with name "core-etcd", and it's to much generic + # but currently there is no way to pass to the etcd subchart a parent Release.Name, see https://github.com/kubernetes/helm/issues/2506 + # so to make it more verbose I've added nameOverride and now the release name will be "core-helm-broker-etcd" + nameOverride: "helm-broker-etcd" + +bundlesStorage: + claimName: "helm-broker-bundles-storage" \ No newline at end of file diff --git a/resources/core/charts/jaeger/.helmignore b/resources/core/charts/jaeger/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/jaeger/.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/resources/core/charts/jaeger/Chart.yaml b/resources/core/charts/jaeger/Chart.yaml new file mode 100644 index 000000000000..f901ae2078e1 --- /dev/null +++ b/resources/core/charts/jaeger/Chart.yaml @@ -0,0 +1,8 @@ +name: jaeger +version: 0.1.0 +description: Helm chart for Jaeger all-in-one deployment +keywords: + - Istio + - tracing +sources: + - https://github.com/jaegertracing/jaeger diff --git a/resources/core/charts/jaeger/README.md b/resources/core/charts/jaeger/README.md new file mode 100644 index 000000000000..0a78a18c8f70 --- /dev/null +++ b/resources/core/charts/jaeger/README.md @@ -0,0 +1,18 @@ +``` + _ + | | + | | __ _ ___ __ _ ___ _ __ + _ | |/ _` |/ _ \/ _` |/ _ \ '__| + | |__| | (_| | __/ (_| | __/ | + \____/ \__,_|\___|\__, |\___|_| + __/ | + |___/ +``` + +## Overview +[Jaeger](http://jaeger.readthedocs.io/en/latest/) is a monitoring and tracing tool for microservices-based distributed systems. + +## Details +Jaeger installs as an Istio sub-chart. For dependency declaration, see the [requirements.yaml](../../requirements.yaml) file. The Envoy sidecar uses Jaeger to trace the request flow in the Istio service mesh. The communication from Istio and Envoy uses the Zipkin protocol. Jaeger provides compatibility with the Zipkin protocol. This allows you to use Zipkin protocol and clients in Istio, Envoy, and the Kyma services. + +For more details, see the [Istio Distributed Tracing](https://istio.io/docs/tasks/telemetry/distributed-tracing.html) documentation. diff --git a/resources/core/charts/jaeger/templates/_helpers.tpl b/resources/core/charts/jaeger/templates/_helpers.tpl new file mode 100644 index 000000000000..1192bc08d236 --- /dev/null +++ b/resources/core/charts/jaeger/templates/_helpers.tpl @@ -0,0 +1,32 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "jaeger.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 "jaeger.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 "jaeger.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/resources/core/charts/jaeger/templates/deployment.yaml b/resources/core/charts/jaeger/templates/deployment.yaml new file mode 100644 index 000000000000..c765ea0453bf --- /dev/null +++ b/resources/core/charts/jaeger/templates/deployment.yaml @@ -0,0 +1,47 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: {{ template "jaeger.fullname" . }} + labels: + app: {{ template "jaeger.name" . }} + chart: {{ template "jaeger.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app: {{ template "jaeger.name" . }} + release: {{ .Release.Name }} + template: + metadata: + labels: + app: {{ template "jaeger.name" . }} + release: {{ .Release.Name }} + jaeger-infra: jaeger-pod + spec: + containers: + - env: + - name: COLLECTOR_ZIPKIN_HTTP_PORT + value: "{{ .Values.zipkin.httpPort }}" + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + name: {{ .Chart.Name }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: {{ .Values.zipkin.thriftCompactPort }} + protocol: UDP + - containerPort: {{ .Values.jaeger.thriftCompactPort }} + protocol: UDP + - containerPort: {{ .Values.jaeger.thriftBinaryPort }} + protocol: UDP + - containerPort: {{ .Values.jaeger.uiPort }} + protocol: TCP + - containerPort: {{ .Values.zipkin.httpPort }} + protocol: TCP + readinessProbe: + httpGet: + path: "/" + port: {{ .Values.jaeger.uiPort }} + initialDelaySeconds: 5 + resources: +{{ toYaml .Values.resources | indent 12 }} diff --git a/resources/core/charts/jaeger/templates/jaeger-ingress.yaml b/resources/core/charts/jaeger/templates/jaeger-ingress.yaml new file mode 100644 index 000000000000..d8585c878b1c --- /dev/null +++ b/resources/core/charts/jaeger/templates/jaeger-ingress.yaml @@ -0,0 +1,21 @@ +{{- if .Values.global.isLocalEnv }} +--- +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: istio + name: core-jaeger + namespace: kyma-system +spec: + tls: + - secretName: {{.Values.global.istio.tls.secretName }} + rules: + - host: jaeger.{{ .Values.global.domainName }} + http: + paths: + - backend: + serviceName: core-jaeger + servicePort: {{ .Values.service.uiPort }} + path: /.* +{{- end }} diff --git a/resources/core/charts/jaeger/templates/service.yaml b/resources/core/charts/jaeger/templates/service.yaml new file mode 100644 index 000000000000..864569532945 --- /dev/null +++ b/resources/core/charts/jaeger/templates/service.yaml @@ -0,0 +1,106 @@ +apiVersion: v1 +kind: List +items: +- apiVersion: v1 + kind: Service + metadata: + name: {{ template "jaeger.fullname" . }} + labels: + app: {{ template "jaeger.name" . }} + chart: {{ template "jaeger.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} + jaeger-infra: jaeger-service + annotations: + auth.istio.io/{{ .Values.service.uiPort }}: NONE + spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.uiPort }} + targetPort: {{ .Values.jaeger.uiPort }} + protocol: TCP + name: http + selector: + app: {{ template "jaeger.name" . }} + release: {{ .Release.Name }} + jaeger-infra: jaeger-pod +- apiVersion: v1 + kind: Service + metadata: + name: jaeger-collector + labels: + app: {{ template "jaeger.name" . }} + chart: {{ template "jaeger.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} + jaeger-infra: collector-service + spec: + type: {{ .Values.service.type }} + ports: + - name: jaeger-collector-tchannel + port: {{ .Values.jaeger.tChannelPort }} + targetPort: {{ .Values.jaeger.tChannelPort }} + protocol: TCP + - name: jaeger-collector-http + port: {{ .Values.jaeger.collectorHTTPPort }} + targetPort: {{ .Values.jaeger.collectorHTTPPort }} + protocol: TCP + - name: jaeger-collector-zipkin + port: {{ .Values.zipkin.httpPort }} + targetPort: {{ .Values.zipkin.httpPort }} + protocol: TCP + selector: + app: {{ template "jaeger.name" . }} + release: {{ .Release.Name }} + jaeger-infra: jaeger-pod + type: ClusterIP +- apiVersion: v1 + kind: Service + metadata: + name: jaeger-agent + labels: + app: {{ template "jaeger.name" . }} + chart: {{ template "jaeger.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} + jaeger-infra: agent-service + spec: + clusterIP: None + ports: + - name: agent-zipkin-thrift + port: {{ .Values.zipkin.thriftCompactPort }} + targetPort: {{ .Values.zipkin.thriftCompactPort }} + protocol: UDP + - name: agent-compact + port: {{ .Values.jaeger.thriftCompactPort }} + targetPort: {{ .Values.jaeger.thriftCompactPort }} + protocol: UDP + - name: agent-binary + port: {{ .Values.jaeger.thriftBinaryPort }} + targetPort: {{ .Values.jaeger.thriftBinaryPort }} + protocol: UDP + selector: + app: {{ template "jaeger.name" . }} + release: {{ .Release.Name }} + jaeger-infra: jaeger-pod +- apiVersion: v1 + kind: Service + metadata: + name: zipkin #zipkin service is required for istio and envoy since they use zipkin api for adding spans + labels: + app: {{ template "jaeger.name" . }} + chart: {{ template "jaeger.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} + jaeger-infra: zipkin-service + spec: + ports: + - name: jaeger-collector-zipkin + port: {{ .Values.zipkin.httpPort }} + targetPort: {{ .Values.zipkin.httpPort }} + protocol: TCP + clusterIP: None + selector: + app: {{ template "jaeger.name" . }} + release: {{ .Release.Name }} + jaeger-infra: jaeger-pod diff --git a/resources/core/charts/jaeger/values.yaml b/resources/core/charts/jaeger/values.yaml new file mode 100644 index 000000000000..1a0d17bdc2d8 --- /dev/null +++ b/resources/core/charts/jaeger/values.yaml @@ -0,0 +1,29 @@ +# Default values for jaeger. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: jaegertracing/all-in-one + tag: 1.2.0 + pullPolicy: IfNotPresent + +service: + type: ClusterIP + uiPort: 80 + +zipkin: + httpPort: 9411 + thriftCompactPort: 5775 + +jaeger: + uiPort: 16686 + thriftCompactPort: 6831 + thriftBinaryPort: 6832 + configPort: 5778 + tChannelPort: 14267 + collectorHTTPPort: 14268 +resources: + limits: + memory: "256M" diff --git a/resources/core/charts/kubeless/Chart.yaml b/resources/core/charts/kubeless/Chart.yaml new file mode 100644 index 000000000000..b4a65332589f --- /dev/null +++ b/resources/core/charts/kubeless/Chart.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: v1 +description: A Helm chart for Kubernetes +name: kubeless +version: 0.2.0 +keywords: + - serverless + - kubeless + - kyma +appVersion: 1.0.0 diff --git a/resources/core/charts/kubeless/README.md b/resources/core/charts/kubeless/README.md new file mode 100644 index 000000000000..cd6146ab6ff6 --- /dev/null +++ b/resources/core/charts/kubeless/README.md @@ -0,0 +1,93 @@ + +``` + _ __ _ _ + | |/ / | | | | + | ' /_ _| |__ ___| | ___ ___ ___ + | <| | | | '_ \ / _ \ |/ _ \/ __/ __| + | . \ |_| | |_) | __/ | __/\__ \__ \ + |_|\_\__,_|_.__/ \___|_|\___||___/___/ + +``` + +## Overview + +This document explains how Kyma installs Kubeless in the `kyma-system` namespace, and how the Kubeless CLI works with functions. + +The installation of Kubeless in the `kyma-system` namespace enables these components: + +* CustomResourceDefinition +* Controller +* ClusterRole with ClusterRoleBinding (valid for a global installation in a cluster) +* Namespace +* ServiceAccount +* Lambdas UI + +## Prerequisites + +The Kubeless CLI is already installed inside the Kubeless CLI container. It is required only outside of the CLI container. For more details on how to install the Kubeless CLI, go to [this](https://github.com/kubeless/kubeless#installation) URL. + +## Details + +This section explains how to manually create, deploy, call, and delete functions from the Kubeless CLI which is already installed inside the Kubeless Container. + +### Create a function in node.js + +Create a sample function in `node.js`. Save the function in a file. + + +```bash +$ echo "module.exports = { \ + foo: function (req, res) { \ + res.end('hello world') \ + } \ +}" > hello.js +``` + +### Deploy a function using Kubeless CLI + +For `node.js`, run this command: + +```bash +$ kubeless function deploy testjs --runtime nodejs8 --handler hello.foo --from-file hello.js --trigger-http +``` + +### Call a function using CLI + +To call a function using CLI, run this command: + +```bash +$ kubeless function call +E.g +$ kubeless function call testjs +``` + +### Delete a function + +To delete a function, run this command: + +```bash +$ kubeless function delete +E.g +$ kubeless function delete testjs +``` + +For more examples, see [this](https://github.com/kyma-project/examples/tree/master/serverless-lambda) document. + +## Troubleshooting + +To debug function pods, run this command: + +```bash +$ kubectl logs +``` + +To debug the `kube-controller`, run this command: + +```bash +$ kubectl logs -n kyma-system +``` + +## References + +* [Kubeless Installation](https://github.com/kubeless/kubeless#installation) +* [Expose and secure Kubeless functions](https://github.com/kubeless/kubeless/blob/master/docs/http-triggers.md#expose-and-secure-kubeless-functions) \ No newline at end of file diff --git a/resources/core/charts/kubeless/charts/lambdas-ui/.helmignore b/resources/core/charts/kubeless/charts/lambdas-ui/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/kubeless/charts/lambdas-ui/.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/resources/core/charts/kubeless/charts/lambdas-ui/Chart.yaml b/resources/core/charts/kubeless/charts/lambdas-ui/Chart.yaml new file mode 100644 index 000000000000..099c162118b0 --- /dev/null +++ b/resources/core/charts/kubeless/charts/lambdas-ui/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: Lambdas UI chart for console application. +name: lambdas-ui +version: 0.1.0 diff --git a/resources/core/charts/kubeless/charts/lambdas-ui/templates/_helpers.tpl b/resources/core/charts/kubeless/charts/lambdas-ui/templates/_helpers.tpl new file mode 100644 index 000000000000..f0d83d2edba6 --- /dev/null +++ b/resources/core/charts/kubeless/charts/lambdas-ui/templates/_helpers.tpl @@ -0,0 +1,16 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "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). +*/}} +{{- define "fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/resources/core/charts/kubeless/charts/lambdas-ui/templates/configmap.yaml b/resources/core/charts/kubeless/charts/lambdas-ui/templates/configmap.yaml new file mode 100644 index 000000000000..620d382fc184 --- /dev/null +++ b/resources/core/charts/kubeless/charts/lambdas-ui/templates/configmap.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Chart.Name }}-config + namespace: {{ .Release.Namespace }} + labels: + app: {{ template "name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version }} +data: + config.js: | + window.clusterConfig = { + domain: '{{ .Values.global.domainName }}', + graphqlApiUrl: 'https://ui-api.{{ .Values.global.domainName }}/graphql' + }; + \ No newline at end of file diff --git a/resources/core/charts/kubeless/charts/lambdas-ui/templates/deployment.yaml b/resources/core/charts/kubeless/charts/lambdas-ui/templates/deployment.yaml new file mode 100644 index 000000000000..660c3b9c32be --- /dev/null +++ b/resources/core/charts/kubeless/charts/lambdas-ui/templates/deployment.yaml @@ -0,0 +1,33 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: {{ template "fullname" . }} + labels: + app: {{ template "name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + replicas: {{ .Values.replicaCount }} + template: + metadata: + labels: + app: {{ template "name" . }} + release: {{ .Release.Name }} + spec: + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.global.containerRegistry.path }}/{{ .Values.image.name }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: {{ .Values.service.internalPort }} + volumeMounts: + - name: config + mountPath: /usr/share/nginx/html/assets/config + volumes: + - name: config + configMap: + name: {{ .Chart.Name }}-config + items: + - key: config.js + path: config.js diff --git a/resources/core/charts/kubeless/charts/lambdas-ui/templates/ingress.yaml b/resources/core/charts/kubeless/charts/lambdas-ui/templates/ingress.yaml new file mode 100644 index 000000000000..fa3ae09cb9a4 --- /dev/null +++ b/resources/core/charts/kubeless/charts/lambdas-ui/templates/ingress.yaml @@ -0,0 +1,24 @@ +{{- $serviceName := include "fullname" . -}} +{{- $servicePort := .Values.service.externalPort -}} +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: {{ template "fullname" . }} + labels: + app: {{ template "name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} + annotations: + kubernetes.io/ingress.class: "istio" +spec: + tls: + - secretName: {{ .Values.global.istio.tls.secretName }} + rules: + - host: lambdas-ui.{{ .Values.global.domainName }} + http: + paths: + - path: /.* + backend: + serviceName: {{ $serviceName }} + servicePort: {{ $servicePort }} diff --git a/resources/core/charts/kubeless/charts/lambdas-ui/templates/service.yaml b/resources/core/charts/kubeless/charts/lambdas-ui/templates/service.yaml new file mode 100644 index 000000000000..8d7364967f5f --- /dev/null +++ b/resources/core/charts/kubeless/charts/lambdas-ui/templates/service.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ template "fullname" . }} + annotations: + "auth.istio.io/{{ .Values.service.externalPort }}": NONE + labels: + app: {{ template "name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + ports: + - port: {{ .Values.service.externalPort }} + name: http2 + targetPort: {{ .Values.service.internalPort }} + selector: + app: {{ template "name" . }} + release: {{ .Release.Name }} + + diff --git a/resources/core/charts/kubeless/charts/lambdas-ui/values.yaml b/resources/core/charts/kubeless/charts/lambdas-ui/values.yaml new file mode 100644 index 000000000000..c09a6f443770 --- /dev/null +++ b/resources/core/charts/kubeless/charts/lambdas-ui/values.yaml @@ -0,0 +1,14 @@ +# Default values for lambdas-ui. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +replicaCount: 1 +image: + name: lambda + tag: 0.1.69 + pullPolicy: IfNotPresent +service: + name: nginx + type: ClusterIP + externalPort: 80 + internalPort: 80 +resources: {} diff --git a/resources/core/charts/kubeless/templates/_helpers.tpl b/resources/core/charts/kubeless/templates/_helpers.tpl new file mode 100644 index 000000000000..d7364611f8f6 --- /dev/null +++ b/resources/core/charts/kubeless/templates/_helpers.tpl @@ -0,0 +1,29 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "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). +*/}} +{{- define "fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s" .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- /* +Credit: @technosophos +https://github.com/technosophos/common-chart/ +labels.standard prints the standard Helm labels. +The standard labels are frequently used in metadata. +*/ -}} +{{- define "labels.standard" -}} +app: {{ template "name" . }} +heritage: {{ .Release.Service | quote }} +release: {{ .Release.Name | quote }} +chart: {{ .Chart.Name }}-{{ .Chart.Version }} +{{- end -}} diff --git a/resources/core/charts/kubeless/templates/kubeless-clusterroles.yaml b/resources/core/charts/kubeless/templates/kubeless-clusterroles.yaml new file mode 100644 index 000000000000..f5f7b62b1ba5 --- /dev/null +++ b/resources/core/charts/kubeless/templates/kubeless-clusterroles.yaml @@ -0,0 +1,123 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ template "fullname" . }}-controller-deployer + labels: +{{ include "labels.standard" . | indent 4 }} +rules: +- apiGroups: + - "" + resources: + - services + - configmaps + verbs: + - create + - get + - delete + - list + - update + - patch +- apiGroups: + - apps + - extensions + resources: + - deployments + verbs: + - create + - get + - delete + - list + - update + - patch +- apiGroups: + - "" + resources: + - pods + verbs: + - list + - delete +- apiGroups: + - "" + resourceNames: + - kubeless-registry-credentials + resources: + - secrets + verbs: + - get +- apiGroups: + - kubeless.io + resources: + - functions + - httptriggers + - cronjobtriggers + verbs: + - get + - list + - watch + - update + - delete +- apiGroups: + - batch + resources: + - cronjobs + - jobs + verbs: + - create + - get + - delete + - deletecollection + - list + - update + - patch +- apiGroups: + - autoscaling + resources: + - horizontalpodautoscalers + verbs: + - create + - get + - delete + - list + - update + - patch +- apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get + - list +- apiGroups: + - monitoring.coreos.com + resources: + - alertmanagers + - prometheuses + - servicemonitors + verbs: + - '*' +- apiGroups: + - extensions + resources: + - ingresses + verbs: + - create + - get + - list + - update + - delete +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ template "fullname" . }}-controller-deployer + labels: +{{ include "labels.standard" . | indent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ template "fullname" . }}-controller-deployer +subjects: +- kind: ServiceAccount + name: {{ template "fullname" . }}-controller-acct + namespace: {{ .Release.Namespace }} diff --git a/resources/core/charts/kubeless/templates/kubeless-configmap.yaml b/resources/core/charts/kubeless/templates/kubeless-configmap.yaml new file mode 100644 index 000000000000..6be9952ec2a8 --- /dev/null +++ b/resources/core/charts/kubeless/templates/kubeless-configmap.yaml @@ -0,0 +1,66 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ template "fullname" . }}-config + labels: +{{ include "labels.standard" . | indent 4 }} +data: + ingress-enabled: "false" + service-type: ClusterIP + builder-image: {{ .Values.config.builder.image }} + builder-secret: {{ .Values.config.builder.secret }} + provision-image: {{ .Values.config.provision.image }} + provision-secret: {{ .Values.config.provision.secret }} + deployment: |- + { + "spec": { + "template": { + "metadata": { + "annotations": { + "sidecar.istio.io/inject": "true" + } + }, + "spec": { + "securityContext": {} + } + } + } + } + enable-build-step: "{{ .Values.config.builder.enabled }}" + function-registry-tls-verify: "{{ .Values.config.builder.verifyTls }}" + runtime-images: |- + [ + { + "ID": "nodejs", + "compiled": false, + "versions": [ + { + "name": "node6", + "version": "6", + "runtimeImage": "kubeless/nodejs@sha256:0a8a72af4cc3bfbfd4fe9bd309cbf486e7493d0dc32a691673b3f0d3fae07487", + "initImage": "node:6.10", + }, + { + "name": "node8", + "version": "8", + "runtimeImage": "kubeless/nodejs@sha256:76ee28dc7e3613845fface2d1c56afc2e6e2c6d6392c724795a7ccc2f5e60582", + "initImage": "node:8", + } + ], + "livenessProbeInfo": { + "exec": { + "command": [ + "curl", + "-f", + "http://localhost:8080/healthz" + ] + }, + "initialDelaySeconds": 5, + "periodSeconds": 5, + "failureThreshold": 3, + "timeoutSeconds": 30 + }, + "depName": "package.json", + "fileNameSuffix": ".js" + } + ] diff --git a/resources/core/charts/kubeless/templates/kubeless-controller-deployment.yaml b/resources/core/charts/kubeless/templates/kubeless-controller-deployment.yaml new file mode 100755 index 000000000000..c4a75dd044fc --- /dev/null +++ b/resources/core/charts/kubeless/templates/kubeless-controller-deployment.yaml @@ -0,0 +1,45 @@ +--- +apiVersion: apps/v1beta1 +kind: Deployment +metadata: + name: {{ template "fullname" . }}-controller-manager + labels: +{{ include "labels.standard" . | indent 4 }} + kubeless: controller +spec: + replicas: {{ .Values.controller.deployment.replicaCount }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + selector: + matchLabels: + kubeless: controller + template: + metadata: + labels: + kubeless: controller + release: {{ .Release.Name }} + spec: + containers: + - env: + - name: KUBELESS_INGRESS_ENABLED + valueFrom: + configMapKeyRef: + key: ingress-enabled + name: {{ template "fullname" . }}-config + - name: KUBELESS_SERVICE_TYPE + valueFrom: + configMapKeyRef: + key: service-type + name: {{ template "fullname" . }}-config + - name: KUBELESS_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: KUBELESS_CONFIG + value: {{ template "fullname" . }}-config + image: "{{ .Values.controller.deployment.image }}" + imagePullPolicy: {{ .Values.controller.deployment.pullPolicy }} + name: {{ template "fullname" . }}-controller-manager + serviceAccountName: {{ template "fullname" . }}-controller-acct diff --git a/resources/core/charts/kubeless/templates/kubeless-crd.yaml b/resources/core/charts/kubeless/templates/kubeless-crd.yaml new file mode 100644 index 000000000000..c28f1ee9063f --- /dev/null +++ b/resources/core/charts/kubeless/templates/kubeless-crd.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: apiextensions.k8s.io/v1beta1 +description: Kubernetes Native Serverless Framework +kind: CustomResourceDefinition +metadata: + name: functions.kubeless.io + labels: +{{ include "labels.standard" . | indent 4 }} + annotations: + kubeless.io/namespace: {{ .Release.Namespace }} + kubeless.io/config: {{ template "fullname" . }}-config +spec: + group: kubeless.io + names: + kind: Function + plural: functions + singular: function + scope: Namespaced + version: v1beta1 diff --git a/resources/core/charts/kubeless/templates/kubeless-serviceaccount.yaml b/resources/core/charts/kubeless/templates/kubeless-serviceaccount.yaml new file mode 100644 index 000000000000..92e7cff98e98 --- /dev/null +++ b/resources/core/charts/kubeless/templates/kubeless-serviceaccount.yaml @@ -0,0 +1,7 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ template "fullname" . }}-controller-acct + labels: +{{ include "labels.standard" . | indent 4 }} diff --git a/resources/core/charts/kubeless/templates/tests/test-kubeless.yaml b/resources/core/charts/kubeless/templates/tests/test-kubeless.yaml new file mode 100644 index 000000000000..911ae7e2bd7a --- /dev/null +++ b/resources/core/charts/kubeless/templates/tests/test-kubeless.yaml @@ -0,0 +1,73 @@ +# NOTE: The Role, ServiceAccount, and RoleBinding are created once during installation and not removed after (or recreated before) each test. This is a limitation of Helm: it can only create a Pod during testing. +# (This applies to Helm 2.7.2.) +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: test-{{ template "fullname" . }} + labels: + helm-chart-test: "true" +rules: +- apiGroups: ["apiextensions.k8s.io"] + resources: ["customresourcedefinitions"] + verbs: ["get"] +- apiGroups: [""] + resources: ["pods"] + verbs: ["delete", "get", "list"] +- apiGroups: [""] + resources: ["namespaces"] + verbs: ["create", "delete", "get", "list", "patch"] +- apiGroups: [""] + resources: ["services", "services/proxy", "configmaps"] + verbs: ["get", "list"] +- apiGroups: ["kubeless.io"] + resources: ["functions"] + verbs: ["create", "delete", "get", "list"] +- apiGroups: ["extensions"] + resources: ["ingresses", "ingresses/status"] + verbs: ["create", "delete", "get", "list"] +- apiGroups: ["config.istio.io"] + resources: ["routerules"] + verbs: ["create", "delete", "get", "list"] + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: test-{{ template "fullname" . }} + labels: + helm-chart-test: "true" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: test-{{ template "fullname" . }} + labels: + helm-chart-test: "true" +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: test-{{ template "fullname" . }} +subjects: +- kind: ServiceAccount + name: test-{{ template "fullname" . }} + namespace: {{ .Release.Namespace }} +--- +apiVersion: v1 +kind: Pod +metadata: + name: test-{{ template "fullname" . }} + labels: + helm-chart-test: "true" + annotations: + helm.sh/hook: test-success +spec: + serviceAccount: "test-{{ template "fullname" . }}" + containers: + - name: test-{{ template "fullname" . }} + image: {{ .Values.global.containerRegistry.path }}/{{ .Values.test.image.name }}:{{ .Values.test.image.tag }} + env: + - name: KUBELESS_NAMESPACE + value: kyma-system + - name: KUBELESS_CONFIG + value: {{ template "fullname" . }}-config + restartPolicy: Never diff --git a/resources/core/charts/kubeless/values.yaml b/resources/core/charts/kubeless/values.yaml new file mode 100644 index 000000000000..30680a928910 --- /dev/null +++ b/resources/core/charts/kubeless/values.yaml @@ -0,0 +1,20 @@ +test: + image: + name: kubeless-test-client + tag: 0.2.37 + +controller: + deployment: + replicaCount: 1 + image: bitnami/kubeless-controller-manager:v1.0.0-alpha.5 + pullPolicy: IfNotPresent + +config: + builder: + enabled: "false" + image: kubeless/function-image-builder:v1.0.0-alpha.5 + secret: "" + verifyTls: "true" + provision: + image: kubeless/unzip@sha256:f162c062973cca05459834de6ed14c039d45df8cdb76097f50b028a1621b3697 + secret: "" diff --git a/resources/core/charts/minio/.helmignore b/resources/core/charts/minio/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/minio/.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/resources/core/charts/minio/Chart.yaml b/resources/core/charts/minio/Chart.yaml new file mode 100755 index 000000000000..fdd8ff5da355 --- /dev/null +++ b/resources/core/charts/minio/Chart.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +description: Minio is a high performance distributed object storage server, designed for large-scale private cloud infrastructure. +name: minio +version: 1.3.4 +appVersion: RELEASE.2018-06-09T03-43-35Z +keywords: +- storage +- object-storage +- S3 +home: https://minio.io +icon: https://www.minio.io/img/logo_160x160.png +sources: +- https://github.com/minio/minio +maintainers: +- name: Acaleph + email: hello@acale.ph +- name: Minio + email: dev@minio.io \ No newline at end of file diff --git a/resources/core/charts/minio/README.md b/resources/core/charts/minio/README.md new file mode 100644 index 000000000000..7c427256aca0 --- /dev/null +++ b/resources/core/charts/minio/README.md @@ -0,0 +1,47 @@ +``` + __ __ _ _ + | \/ (_) (_) + | \ / |_ _ __ _ ___ + | |\/| | | '_ \| |/ _ \ + | | | | | | | | | (_) | + |_| |_|_|_| |_|_|\___/ + + +``` + +## Overview + +Minio is an open source object storage server with Amazon S3 compatible API. Kyma provides Minio as a core component to store static content. For example, documentation, images, or videos. The size of an object can range from a few KBs to a maximum of 5TB. In the long term, you can replace Minio with an external solution, such as AWS S3. + +## Details + +This section describes how to use Minio. Learn how to connect to Minio through a web browser or Minio Client. + +### Connect to Minio through a web browser + +From your browser, go to https://minio.kyma.local and log in with these credentials: + - name: admin + - password: topSecretKey + +### Connect to Minio through Minio Client + +1. Install Minio Client: +``` +sudo apt-get install wget +wget https://dl.minio.io/client/mc/release/linux-amd64/mc +chmod a+x mc +``` + +2. Configure Minio Client: +``` +./mc config host add myminio https://minio.kyma.local admin topSecretKey +``` + +3. Try out Minio Client: +``` +./mc mb myminio/bucket1 +ls +./mc cp mc myminio/bucket1 +./mc ls myminio +./mc ls myminio/bucket1 +``` diff --git a/resources/core/charts/minio/templates/_helper_create_bucket.txt b/resources/core/charts/minio/templates/_helper_create_bucket.txt new file mode 100644 index 000000000000..582c7cd4b7cf --- /dev/null +++ b/resources/core/charts/minio/templates/_helper_create_bucket.txt @@ -0,0 +1,75 @@ +#!/bin/sh +set -e ; # Have script exit in the event of a failed command. + +# connectToMinio +# Use a check-sleep-check loop to wait for Minio service to be available +connectToMinio() { + ATTEMPTS=0 ; LIMIT=29 ; # Allow 30 attempts + set -e ; # fail if we can't read the keys. + ACCESS=$(cat /config/accesskey) ; SECRET=$(cat /config/secretkey) ; + set +e ; # The connections to minio are allowed to fail. + echo "Connecting to Minio server: http://$MINIO_ENDPOINT:$MINIO_PORT" ; + MC_COMMAND="mc config host add myminio http://$MINIO_ENDPOINT:$MINIO_PORT $ACCESS $SECRET" ; + $MC_COMMAND ; + STATUS=$? ; + until [ $STATUS = 0 ] + do + ATTEMPTS=`expr $ATTEMPTS + 1` ; + echo \"Failed attempts: $ATTEMPTS\" ; + if [ $ATTEMPTS -gt $LIMIT ]; then + exit 1 ; + fi ; + sleep 2 ; # 1 second intervals between attempts + $MC_COMMAND ; + STATUS=$? ; + done ; + set -e ; # reset `e` as active + return 0 +} + +# checkBucketExists ($bucket) +# Check if the bucket exists, by using the exit code of `mc ls` +checkBucketExists() { + BUCKET=$1 + CMD=$(/usr/bin/mc ls myminio/$BUCKET > /dev/null 2>&1) + return $? +} + +# createBucket ($bucket, $policy, $purge) +# Ensure bucket exists, purging if asked to +createBucket() { + BUCKET=$1 + POLICY=$2 + PURGE=$3 + + # Purge the bucket, if set & exists + # Since PURGE is user input, check explicitly for `true` + if [ $PURGE = true ]; then + if checkBucketExists $BUCKET ; then + echo "Purging bucket '$BUCKET'." + set +e ; # don't exit if this fails + /usr/bin/mc rm -r --force myminio/$BUCKET + set -e ; # reset `e` as active + else + echo "Bucket '$BUCKET' does not exist, skipping purge." + fi + fi + + # Create the bucket if it does not exist + if ! checkBucketExists $BUCKET ; then + echo "Creating bucket '$BUCKET'" + /usr/bin/mc mb myminio/$BUCKET + else + echo "Bucket '$BUCKET' already exists." + fi + + # At this point, the bucket should exist, skip checking for existence + # Set policy on the bucket + echo "Setting policy of bucket '$BUCKET' to '$POLICY'." + /usr/bin/mc policy $POLICY myminio/$BUCKET +} + +# Try connecting to Minio instance +connectToMinio +# Create the bucket +createBucket {{ .Values.defaultBucket.name }} {{ .Values.defaultBucket.policy }} {{ .Values.defaultBucket.purge }} diff --git a/resources/core/charts/minio/templates/_helpers.tpl b/resources/core/charts/minio/templates/_helpers.tpl new file mode 100644 index 000000000000..c8fe9ba7aa05 --- /dev/null +++ b/resources/core/charts/minio/templates/_helpers.tpl @@ -0,0 +1,43 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "minio.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 "minio.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 "minio.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Return the appropriate apiVersion for networkpolicy. +*/}} +{{- define "minio.networkPolicy.apiVersion" -}} +{{- if semverCompare ">=1.4-0, <1.7-0" .Capabilities.KubeVersion.GitVersion -}} +{{- print "extensions/v1beta1" -}} +{{- else if semverCompare "^1.7-0" .Capabilities.KubeVersion.GitVersion -}} +{{- print "networking.k8s.io/v1" -}} +{{- end -}} +{{- end -}} diff --git a/resources/core/charts/minio/templates/configmap.yaml b/resources/core/charts/minio/templates/configmap.yaml new file mode 100644 index 000000000000..cb11fcd7dda2 --- /dev/null +++ b/resources/core/charts/minio/templates/configmap.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ template "minio.fullname" . }} + labels: + app: {{ template "minio.name" . }} + chart: {{ template "minio.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +data: + initialize: |- +{{ include (print $.Template.BasePath "/_helper_create_bucket.txt") . | indent 4 }} diff --git a/resources/core/charts/minio/templates/deployment.yaml b/resources/core/charts/minio/templates/deployment.yaml new file mode 100644 index 000000000000..dd5268b0045b --- /dev/null +++ b/resources/core/charts/minio/templates/deployment.yaml @@ -0,0 +1,106 @@ +{{- if eq .Values.mode "standalone" "shared" }} +apiVersion: apps/v1beta2 +kind: Deployment +metadata: + name: {{ template "minio.fullname" . }} + labels: + app: {{ template "minio.name" . }} + chart: {{ template "minio.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + {{- if eq .Values.mode "shared" }} + replicas: {{ .Values.replicas }} + {{- end }} + strategy: + rollingUpdate: + maxUnavailable: 0 + selector: + matchLabels: + app: {{ template "minio.name" . }} + release: {{ .Release.Name }} + template: + metadata: + name: {{ template "minio.fullname" . }} + labels: + app: {{ template "minio.name" . }} + release: {{ .Release.Name }} + spec: + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- if .Values.azuregateway.enabled }} + args: ["gateway", "azure"] + {{- else }} + {{- if .Values.gcsgateway.enabled }} + args: ["gateway", "gcs", "{{ .Values.gcsgateway.projectId }}"] + {{- else }} + {{- if .Values.configPath }} + args: ["-C", "{{ .Values.configPath }}", "server", "{{ .Values.mountPath }}"] + {{- else }} + args: ["server", "{{ .Values.mountPath }}"] + {{- end }} + {{- end }} + {{- end }} + volumeMounts: + - name: export + mountPath: {{ .Values.mountPath }} + {{- if and .Values.persistence.enabled .Values.persistence.subPath }} + subPath: "{{ .Values.persistence.subPath }}" + {{- end }} + {{- if .Values.gcsgateway.enabled }} + - name: minio-user + mountPath: "/etc/credentials" + readOnly: true + {{- end }} + ports: + - name: service + containerPort: 9000 + env: + - name: MINIO_BROWSER + value: "{{ .Values.minioConfig.browser }}" + - name: MINIO_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ template "minio.fullname" . }} + key: accesskey + - name: MINIO_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ template "minio.fullname" . }} + key: secretkey + {{- if .Values.gcsgateway.enabled }} + - name: GOOGLE_APPLICATION_CREDENTIALS + value: "/etc/credentials/gcs_key.json" + {{- end }} + livenessProbe: + tcpSocket: + port: 9000 + timeoutSeconds: 1 + 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: + - name: export + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ .Values.persistence.existingClaim | default (include "minio.fullname" .) }} + {{- else }} + emptyDir: {} + {{- end }} + - name: minio-user + secret: + secretName: {{ template "minio.fullname" . }} +{{- end }} diff --git a/resources/core/charts/minio/templates/ingress.yaml b/resources/core/charts/minio/templates/ingress.yaml new file mode 100644 index 000000000000..44797678f59d --- /dev/null +++ b/resources/core/charts/minio/templates/ingress.yaml @@ -0,0 +1,22 @@ +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: {{ template "minio.fullname" . }} + annotations: + kubernetes.io/ingress.class: "istio" + labels: + app: {{ template "minio.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +spec: + tls: + - secretName: {{.Values.global.istio.tls.secretName }} + rules: + - host: minio.{{ .Values.global.domainName }} + http: + paths: + - path: /.* + backend: + serviceName: {{ template "minio.fullname" . }} + servicePort: 9000 \ No newline at end of file diff --git a/resources/core/charts/minio/templates/minio-content-upload-configmap.yaml b/resources/core/charts/minio/templates/minio-content-upload-configmap.yaml new file mode 100644 index 000000000000..08a7a3f97b14 --- /dev/null +++ b/resources/core/charts/minio/templates/minio-content-upload-configmap.yaml @@ -0,0 +1,18 @@ +# +# This is a temporary hack because there is no separate chart with documentation in Kyma. +# TODO: Move file to separate chart +# + +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ template "minio.fullname" . }}-docs-upload +data: + APP_UPLOAD_ENDPOINT: {{ template "minio.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local + APP_UPLOAD_SECURE: 'false' + APP_UPLOAD_PORT: '{{ .Values.service.port }}' + APP_BUCKET_NAME: content + APP_BUCKET_REGION: us-east-1 + APP_EXTERNAL_ENDPOINT: https://minio.{{ .Values.global.domainName }} + APP_ASSETS_FOLDER: assets + APP_VERBOSE: 'true' diff --git a/resources/core/charts/minio/templates/minio-content-upload-podpreset.yaml b/resources/core/charts/minio/templates/minio-content-upload-podpreset.yaml new file mode 100644 index 000000000000..224c0d3fc48f --- /dev/null +++ b/resources/core/charts/minio/templates/minio-content-upload-podpreset.yaml @@ -0,0 +1,27 @@ +# +# This is a temporary hack because there is no separate chart with documentation in Kyma. +# TODO: Move file to separate chart +# + +apiVersion: settings.k8s.io/v1alpha1 +kind: PodPreset +metadata: + name: {{ template "minio.fullname" . }}-docs-upload +spec: + selector: + matchLabels: + inject: docs-upload-config + env: + - name: APP_UPLOAD_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ template "minio.fullname" . }} + key: accesskey + - name: APP_UPLOAD_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ template "minio.fullname" . }} + key: secretkey + envFrom: + - configMapRef: + name: {{ template "minio.fullname" . }}-docs-upload diff --git a/resources/core/charts/minio/templates/networkpolicy.yaml b/resources/core/charts/minio/templates/networkpolicy.yaml new file mode 100644 index 000000000000..de57f485fee0 --- /dev/null +++ b/resources/core/charts/minio/templates/networkpolicy.yaml @@ -0,0 +1,25 @@ +{{- if .Values.networkPolicy.enabled }} +kind: NetworkPolicy +apiVersion: {{ template "minio.networkPolicy.apiVersion" . }} +metadata: + name: {{ template "minio.fullname" . }} + labels: + app: {{ template "minio.name" . }} + chart: {{ template "minio.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + podSelector: + matchLabels: + app: {{ template "minio.name" . }} + release: {{ .Release.Name }} + ingress: + - ports: + - port: {{ .Values.service.port }} + {{- if not .Values.networkPolicy.allowExternal }} + from: + - podSelector: + matchLabels: + {{ template "minio.name" . }}-client: "true" + {{- end }} +{{- end }} diff --git a/resources/core/charts/minio/templates/post-install-create-bucket-job.yaml b/resources/core/charts/minio/templates/post-install-create-bucket-job.yaml new file mode 100644 index 000000000000..c8fac315d0ac --- /dev/null +++ b/resources/core/charts/minio/templates/post-install-create-bucket-job.yaml @@ -0,0 +1,48 @@ +{{- if .Values.defaultBucket.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ template "minio.fullname" . }}-make-bucket-job + labels: + app: {{ template "minio.name" . }} + chart: {{ template "minio.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} + annotations: + "helm.sh/hook": post-install,post-upgrade + "helm.sh/hook-delete-policy": hook-succeeded + "helm.sh/hook-weight": "-1" +spec: + template: + metadata: + labels: + app: {{ template "minio.name" . }} + release: {{ .Release.Name }} + spec: + restartPolicy: OnFailure +{{- if .Values.nodeSelector }} + nodeSelector: +{{ toYaml .Values.nodeSelector | indent 8 }} +{{- end }} + volumes: + - name: minio-configuration + projected: + sources: + - configMap: + name: {{ template "minio.fullname" . }} + - secret: + name: {{ template "minio.fullname" . }} + containers: + - name: minio-mc + image: "{{ .Values.mcImage.repository }}:{{ .Values.mcImage.tag }}" + imagePullPolicy: {{ .Values.mcImage.pullPolicy }} + command: ["/bin/sh", "/config/initialize"] + env: + - name: MINIO_ENDPOINT + value: {{ template "minio.fullname" . }} + - name: MINIO_PORT + value: {{ .Values.service.port | quote }} + volumeMounts: + - name: minio-configuration + mountPath: /config +{{- end }} diff --git a/resources/core/charts/minio/templates/pvc.yaml b/resources/core/charts/minio/templates/pvc.yaml new file mode 100644 index 000000000000..02787e4017ab --- /dev/null +++ b/resources/core/charts/minio/templates/pvc.yaml @@ -0,0 +1,30 @@ +{{- if eq .Values.mode "standalone" "shared" }} +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ template "minio.fullname" . }} + labels: + app: {{ template "minio.name" . }} + chart: {{ template "minio.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + accessModes: + {{- if eq .Values.mode "shared" }} + - ReadWriteMany + {{- else }} + - {{ .Values.persistence.accessMode | quote }} + {{- end }} + 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 }} +{{- end }} diff --git a/resources/core/charts/minio/templates/secrets.yaml b/resources/core/charts/minio/templates/secrets.yaml new file mode 100644 index 000000000000..f130bf9a0370 --- /dev/null +++ b/resources/core/charts/minio/templates/secrets.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ template "minio.fullname" . }} + labels: + app: {{ template "minio.name" . }} + chart: {{ template "minio.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +type: Opaque +data: + accesskey: {{ .Values.accessKey | b64enc }} + secretkey: {{ .Values.secretKey | b64enc }} +{{- if .Values.gcsgateway.enabled }} + gcs_key.json: {{ .Values.gcsgateway.gcsKeyJson | b64enc }} +{{- end }} \ No newline at end of file diff --git a/resources/core/charts/minio/templates/service.yaml b/resources/core/charts/minio/templates/service.yaml new file mode 100644 index 000000000000..fb034397d260 --- /dev/null +++ b/resources/core/charts/minio/templates/service.yaml @@ -0,0 +1,33 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ template "minio.fullname" . }} + labels: + app: {{ template "minio.name" . }} + chart: {{ template "minio.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +{{- if .Values.service.annotations }} + annotations: +{{ toYaml .Values.service.annotations | indent 4 }} +{{- end }} +spec: +{{- if (or (eq .Values.service.type "ClusterIP" "") (empty .Values.service.type)) }} + type: ClusterIP + {{- if .Values.service.clusterIP }} + clusterIP: {{ .Values.service.clusterIP }} + {{end}} +{{- else if eq .Values.service.type "LoadBalancer" }} + type: {{ .Values.service.type }} + loadBalancerIP: {{ default "" .Values.service.loadBalancerIP }} +{{- else }} + type: {{ .Values.service.type }} +{{- end }} + ports: + - name: http + port: 9000 + targetPort: {{ .Values.service.port }} + protocol: TCP + selector: + app: {{ template "minio.name" . }} + release: {{ .Release.Name }} diff --git a/resources/core/charts/minio/templates/statefulset.yaml b/resources/core/charts/minio/templates/statefulset.yaml new file mode 100644 index 000000000000..85f0d3939410 --- /dev/null +++ b/resources/core/charts/minio/templates/statefulset.yaml @@ -0,0 +1,89 @@ +{{- if eq .Values.mode "distributed" }} +{{ $nodeCount := .Values.replicas | int }} +apiVersion: apps/v1beta1 +kind: StatefulSet +metadata: + name: {{ template "minio.fullname" . }} + labels: + app: {{ template "minio.name" . }} + chart: {{ template "minio.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + serviceName: {{ template "minio.fullname" . }} + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: {{ template "minio.name" . }} + release: {{ .Release.Name }} + template: + metadata: + name: {{ template "minio.fullname" . }} + labels: + app: {{ template "minio.name" . }} + release: {{ .Release.Name }} + spec: + containers: + - name: {{ .Chart.Name }} + image: {{ .Values.image.repository }}:{{ .Values.image.tag }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- if .Values.configPath }} + args: + - -C {{ .Values.configPath }} server + {{- else }} + args: + - server + {{- range $i := until $nodeCount }} + - http://{{ template "minio.fullname" $ }}-{{ $i }}.{{ template "minio.fullname" $ }}.{{ $.Release.Namespace }}.svc.cluster.local{{ $.Values.mountPath }} + {{- end }} + {{- end }} + volumeMounts: + - name: export + mountPath: {{ .Values.mountPath }} + {{- if and .Values.persistence.enabled .Values.persistence.subPath }} + subPath: "{{ .Values.persistence.subPath }}" + {{- end }} + ports: + - name: service + containerPort: 9000 + env: + - name: MINIO_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ template "minio.fullname" . }} + key: accesskey + - name: MINIO_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ template "minio.fullname" . }} + key: secretkey + 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: + - name: minio-user + secret: + secretName: {{ template "minio.fullname" . }} + volumeClaimTemplates: + - metadata: + name: export + spec: + accessModes: [ {{ .Values.persistence.accessMode | quote }} ] + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} +{{- end }} diff --git a/resources/core/charts/minio/values.yaml b/resources/core/charts/minio/values.yaml new file mode 100644 index 000000000000..be8503afdbe7 --- /dev/null +++ b/resources/core/charts/minio/values.yaml @@ -0,0 +1,202 @@ +## Set default image, imageTag, and imagePullPolicy. mode is used to indicate the +## +image: + repository: minio/minio + tag: RELEASE.2018-04-27T23-33-52Z + pullPolicy: IfNotPresent + +## Set default image, imageTag, and imagePullPolicy for the `mc` (the minio +## client used to create a default bucket). +## +mcImage: + repository: minio/mc + tag: RELEASE.2018-04-28T00-08-20Z + pullPolicy: IfNotPresent + +## minio server mode, i.e. standalone or distributed. +## Distributed Minio ref: https://docs.minio.io/docs/distributed-minio-quickstart-guide +## +mode: standalone + +## Set default accesskey, secretkey, Minio config file path, volume mount path and +## number of nodes (only used for Minio distributed mode) +## Distributed Minio ref: https://docs.minio.io/docs/distributed-minio-quickstart-guide +## WARNING - when changing `accessKey` and/or `secretKey`, change `resources/cluster-essentials/remote-environments-minio-secret.yaml` accordingly +## +accessKey: "admin" +secretKey: "topSecretKey" +configPath: "" +mountPath: "/export" +replicas: 4 + +## Enable persistence using Persistent Volume Claims +## ref: http://kubernetes.io/docs/user-guide/persistent-volumes/ +## +persistence: + enabled: true + + ## A manually managed Persistent Volume and Claim + ## Requires persistence.enabled: true + ## If defined, PVC must be created manually before volume will be bound + # existingClaim: + + ## minio 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: 10Gi + +## If subPath is set mount a sub folder of a volume instead of the root of the volume. +## This is especially handy for volume plugins that don't natively support sub mounting (like glusterfs). +## +subPath: "" + +## Expose the Minio service to be accessed from outside the cluster (LoadBalancer service). +## or access it from within the cluster (ClusterIP service). Set the service type and the port to serve it. +## ref: http://kubernetes.io/docs/user-guide/services/ +## + +service: + type: NodePort + clusterIP: None + port: 9000 + annotations: + auth.istio.io/9000: NONE + # prometheus.io/scrape: 'true' + # prometheus.io/path: '/minio/prometheus/metrics' + # prometheus.io/port: '9000' + + +## Node labels for pod assignment +## Ref: https://kubernetes.io/docs/user-guide/node-selection/ +## +nodeSelector: {} +tolerations: [] +affinity: {} + +## Configure resource requests and limits +## ref: http://kubernetes.io/docs/user-guide/compute-resources/ +## +resources: + requests: + memory: 256Mi + cpu: 250m + limits: + memory: 512Mi + + +## Create a bucket after minio install +## +defaultBucket: + enabled: true + ## If enabled, must be a string with length > 0 + name: content + ## Can be one of none|download|upload|public + policy: download + ## Purge if bucket exists already + purge: false + +## Use minio as an azure blob gateway, you should disable data persistence so no volume claim are created. +## https://docs.minio.io/docs/minio-gateway-for-azure +azuregateway: + enabled: false + +## Use minio as GCS (Google Cloud Storage) gateway, you should disable data persistence so no volume claim are created. +## https://docs.minio.io/docs/minio-gateway-for-gcs + +gcsgateway: + enabled: false + # credential json file of service account key + gcsKeyJson: "" + # Google cloud project-id + projectId: "" + +# only minioConfig.browser is used +minioConfig: +# region: "us-east-1" + browser: "off" +# domain: "" +# standardStorageClass: "" +# reducedRedundancyStorageClass: "" +# aqmp: +# enable: false +# url: "" +# exchange: "" +# routingKey: "" +# exchangeType: "" +# deliveryMode: 0 +# mandatory: false +# immediate: false +# durable: false +# internal: false +# noWait: false +# autoDeleted: false +# nats: +# enable: false +# address: "" +# subject: "" +# username: "" +# password: "" +# token: "" +# secure: false +# pingInterval: 0 +# enableStreaming: false +# clusterID: "" +# clientID: "" +# async: false +# maxPubAcksInflight: 0 +# elasticsearch: +# enable: false +# format: "namespace" +# url: "" +# index: "" +# redis: +# enable: false +# format: "namespace" +# address: "" +# password: "" +# key: "" +# postgresql: +# enable: false +# format: "namespace" +# connectionString: "" +# table: "" +# host: "" +# port: "" +# user: "" +# password: "" +# database: "" +# kafka: +# enable: false +# brokers: "null" +# topic: "" +# webhook: +# enable: false +# endpoint: "" +# mysql: +# enable: false +# format: "namespace" +# dsnString: "" +# table: "" +# host: "" +# port: "" +# user: "" +# password: "" +# database: "" +# mqtt: +# enable: false +# broker: "" +# topic: "" +# qos: 0 +# clientId: "" +# username: "" +# password: "" +# +networkPolicy: + enabled: false + allowExternal: true diff --git a/resources/core/charts/monitoring/.helmignore b/resources/core/charts/monitoring/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/monitoring/.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/resources/core/charts/monitoring/Chart.yaml b/resources/core/charts/monitoring/Chart.yaml new file mode 100644 index 000000000000..aa06847ff45b --- /dev/null +++ b/resources/core/charts/monitoring/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +name: monitoring +description: Monitoring chart based on kube-prometheus +engine: gotpl +version: 0.1.0 +keywords: +- monitoring +- kyma diff --git a/resources/core/charts/monitoring/README.md b/resources/core/charts/monitoring/README.md new file mode 100644 index 000000000000..ac7012765cd4 --- /dev/null +++ b/resources/core/charts/monitoring/README.md @@ -0,0 +1,22 @@ +``` + __ __ _ _ _ + | \/ | (_) | (_) + | \ / | ___ _ __ _| |_ ___ _ __ _ _ __ __ _ + | |\/| |/ _ \| '_ \| | __/ _ \| '__| | '_ \ / _` | + | | | | (_) | | | | | || (_) | | | | | | | (_| | + |_| |_|\___/|_| |_|_|\__\___/|_| |_|_| |_|\__, | + __/ | + |___/ +``` + +## Overview + +The [Kube-Prometheus](https://github.com/coreos/prometheus-operator/tree/master/contrib/kube-prometheus) implementation provides end-to-end Kubernetes cluster monitoring in [Kyma](https://github.com/kyma-project/kyma) using the [Prometheus operator](https://github.com/coreos/prometheus-operator). + +This chart installs [Prometheus](https://prometheus.io/), [Alertmanager](https://github.com/prometheus/alertmanager), and [Grafana](https://grafana.com/), along with the configuration to monitor a Kubernetes cluster. It requires a running instance of Prometheus operator, which Kyma provides. + +`kube-prometheus` installs in the `kyma-system` Namespace. + +## Details + +* [Grafana in Kyma](charts/grafana/README.md) diff --git a/resources/core/charts/monitoring/_helpers.tpl b/resources/core/charts/monitoring/_helpers.tpl new file mode 100644 index 000000000000..a9675ae804bf --- /dev/null +++ b/resources/core/charts/monitoring/_helpers.tpl @@ -0,0 +1,8 @@ +{{/* +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). +*/}} +{{- define "fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s" .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/resources/core/charts/monitoring/charts/alert-rules/.helmignore b/resources/core/charts/monitoring/charts/alert-rules/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/monitoring/charts/alert-rules/.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/resources/core/charts/monitoring/charts/alert-rules/Chart.yaml b/resources/core/charts/monitoring/charts/alert-rules/Chart.yaml new file mode 100644 index 000000000000..8016ad1bbc21 --- /dev/null +++ b/resources/core/charts/monitoring/charts/alert-rules/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +description: Alert rules for Kyma +name: alert-rules +version: 0.1.0 +keywords: +- monitoring +- kyma diff --git a/resources/core/charts/monitoring/charts/alert-rules/README.md b/resources/core/charts/monitoring/charts/alert-rules/README.md new file mode 100644 index 000000000000..74fdd099fc2e --- /dev/null +++ b/resources/core/charts/monitoring/charts/alert-rules/README.md @@ -0,0 +1,105 @@ +# alert-rules + +## Overview + +In order to provide a starting point for adding alert rules, Kyma includes a helm chart where new rules can be created. + +In this chart it is possible to define Prometheus alert rules. + +### Creating Alert Rules in Kyma + +Prometheus uses the a label selector **spec.ruleSelector** to identify those ConfigMap that holding Prometheus rule files. + +```yaml +{{- if .Values.rulesSelector }} + ruleSelector: +{{ toYaml .Values.rulesSelector | indent 4 }} +{{- else }} + ruleSelector: + matchLabels: + role: alert-rules + prometheus: {{ .Release.Name }} +{{- end }} +``` + +So, to define a new alert rule in Kyma, you need to specify a new ConfigMap. + +Best practice is to label the ConfigMaps containing rule files with ```role: alert-rules``` as well as the name of the Prometheus object, ```prometheus: {{ .Release.Name }}```. + +Kyma provides the file [unhealthy-pods-configmap.yaml](templates/unhealthy-pods-configmap.yaml) which serves as a reference to define Rules as configmaps. + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + app: "Kyma" + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + prometheus: {{ .Release.Name }} + release: {{ .Release.Name }} + role: alert-rules + name: {{ template "alert-rules.fullname" . }} +data: +{{- if .Values.prometheusRules }} +{{- $root := . }} +{{- range $key, $val := .Values.prometheusRules }} + {{ $key }}: |- +{{ $val | indent 4}} +{{- end }} +{{ else }} + alert.rules: |- + {{- include "unhealthy-pods-rules.yaml.tpl" . | indent 4}} +{{ end }} +``` +Under ```data:``` ``` alert.rules:``` is configured the file, [unhealthy-pods-rules.yaml](templates/unhealthy-pods-rules.yaml), where is created a rule for alerting if a pod is not running. + +```yaml +# Modify the file according to your requirements +{{ define "unhealthy-pods-rules.yaml.tpl" }} +groups: +- name: pod-not-running-rule + rules: + - alert: PodNotRunning + expr: (kube_pod_container_status_running { pod="sample-metrics",namespace="default" } == 0) + for: 15s + labels: + severity: critical + annotations: + description: "{{`{{$labels.namespace}}`}}/{{`{{$labels.pod}}`}} is not running" + summary: "{{`{{$labels.pod}}`}} is not running" +{{ end }} +``` +**A Quick explanation** +* ```alert:``` represents the name of the alert. Must be a valid metric name. +* ```expr:``` defines the PromQL expression to evaluate. + - [kube_pod_container_status_running](https://github.com/kubernetes/kube-state-metrics/blob/master/Documentation/pod-metrics.md) is a [kube-state-metrics](https://github.com/kubernetes/kube-state-metrics) and in the expression above is evaluated if the pod, **pod="sample-metrics"** in the namespace, **namespace="default"** is running. + - [Several functions](https://prometheus.io/docs/prometheus/latest/querying/functions/) are also provided by [Promethes](https://prometheus.io/docs/prometheus/latest/querying/basics/) to operate on data. +* ```for:``` Alerts are considered to be firing once they have been returned for this defined period of time. +* ```description:``` this annotation is used to enrich alert details. +* ```summary:``` this annotation is used to enrich alert details. + +#### Generic resource metrics for pods + +Resource metrics such as **cpu and memory** are also served by kube-state-metrics. The two metrics below are the Generic resource metrics recommended to be used in the future. + +| Metric name| Metric type | Labels/tags | +| ---------- | ----------- | ----------- | +| kube_pod_container_resource_requests | Gauge | `resource`=<resource-name>
      `unit`=<resource-unit>
      `container`=<container-name>
      `pod`=<pod-name>
      `namespace`=<pod-namespace>
      `node`=< node-name> | +| kube_pod_container_resource_limits | Gauge | `resource`=<resource-name>
      `unit`=<resource-unit>
      `container`=<container-name>
      `pod`=<pod-name>
      `namespace`=<pod-namespace>
      `node`=< node-name> | + +[Here](https://github.com/kubernetes/kube-state-metrics/blob/master/Documentation/pod-metrics.md) is the complete list of Pod Metrics + + +**Be aware that the metrics below will be removed in kube-state-metrics v2.0.0.** + +- kube_pod_container_resource_requests_cpu_cores +- kube_pod_container_resource_limits_cpu_cores +- kube_pod_container_resource_requests_memory_bytes +- kube_pod_container_resource_limits_memory_bytes +- kube_pod_container_resource_requests_nvidia_gpu_devices +- kube_pod_container_resource_limits_nvidia_gpu_devices + +### Configure Alertmanager + +In Kyma all the configuration related to the Alertmanager is in the chart [alertmanager](../alertmanager/README.md) diff --git a/resources/core/charts/monitoring/charts/alert-rules/templates/_helpers.tpl b/resources/core/charts/monitoring/charts/alert-rules/templates/_helpers.tpl new file mode 100644 index 000000000000..5ce05dc0a482 --- /dev/null +++ b/resources/core/charts/monitoring/charts/alert-rules/templates/_helpers.tpl @@ -0,0 +1,32 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "alert-rules.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 "alert-rules.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 "alert-rules.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/resources/core/charts/monitoring/charts/alert-rules/templates/unhealthy-pods-configmap.yaml b/resources/core/charts/monitoring/charts/alert-rules/templates/unhealthy-pods-configmap.yaml new file mode 100644 index 000000000000..27747919eb31 --- /dev/null +++ b/resources/core/charts/monitoring/charts/alert-rules/templates/unhealthy-pods-configmap.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + app: "Kyma" + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + prometheus: {{ .Release.Name }} + release: {{ .Release.Name }} + role: alert-rules + name: {{ template "alert-rules.fullname" . }} +data: +{{- if .Values.prometheusRules }} +{{- $root := . }} +{{- range $key, $val := .Values.prometheusRules }} + {{ $key }}: |- +{{ $val | indent 4}} +{{- end }} +{{ else }} + alert.rules: |- + {{- include "unhealthy-pods-rules.yaml.tpl" . | indent 4}} +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/alert-rules/templates/unhealthy-pods-rules.yaml b/resources/core/charts/monitoring/charts/alert-rules/templates/unhealthy-pods-rules.yaml new file mode 100644 index 000000000000..06e2942b7375 --- /dev/null +++ b/resources/core/charts/monitoring/charts/alert-rules/templates/unhealthy-pods-rules.yaml @@ -0,0 +1,13 @@ +{{ define "unhealthy-pods-rules.yaml.tpl" }} +groups: +- name: pod-not-running-rule + rules: + - alert: PodNotRunning + expr: (kube_pod_container_status_running { pod="sample-metrics",namespace="default" } == 0) + for: 15s + labels: + severity: critical + annotations: + description: "{{`{{$labels.namespace}}`}}/{{`{{$labels.pod}}`}} is not running" + summary: "{{`{{$labels.pod}}`}} is not running" +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/alert-rules/values.yaml b/resources/core/charts/monitoring/charts/alert-rules/values.yaml new file mode 100644 index 000000000000..9d4dafb90b31 --- /dev/null +++ b/resources/core/charts/monitoring/charts/alert-rules/values.yaml @@ -0,0 +1,4 @@ +# Default values for alert-rules. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + diff --git a/resources/core/charts/monitoring/charts/alertmanager/.helmignore b/resources/core/charts/monitoring/charts/alertmanager/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/monitoring/charts/alertmanager/.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/resources/core/charts/monitoring/charts/alertmanager/Chart.yaml b/resources/core/charts/monitoring/charts/alertmanager/Chart.yaml new file mode 100644 index 000000000000..ccb72bdbd0e9 --- /dev/null +++ b/resources/core/charts/monitoring/charts/alertmanager/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +name: alertmanager +description: Alertmanager instance created by the CoreOS Prometheus Operator +engine: gotpl +version: 0.18.1 +appVersion: 0.14.0 +keywords: +- monitoring +- kyma diff --git a/resources/core/charts/monitoring/charts/alertmanager/README.md b/resources/core/charts/monitoring/charts/alertmanager/README.md new file mode 100644 index 000000000000..682c268b3be1 --- /dev/null +++ b/resources/core/charts/monitoring/charts/alertmanager/README.md @@ -0,0 +1,95 @@ +### Alertmanager + +In Kyma all the configuration related to the Alertmanager is in this chart. + +#### Secret configuration + +Alertmanager instances require the secret resource naming to follow the format alertmanager-{ALERTMANAGER_NAME}. + +In Kyma, the name of the Alertmanager is defined by ```name: {{ .Release.Name }}```. The secret is ```name: alertmanager-{{ .Release.Name }}```. The name of the config file is alertmanager.yaml. + +```yaml +apiVersion: v1 +kind: Secret +metadata: + labels: + alertmanager: {{ .Release.Name }} + app: {{ template "alertmanager.name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + name: alertmanager-{{ .Release.Name }} +data: + alertmanager.yaml: {{ toYaml .Values.config | b64enc | quote }} +{{- range $key, $val := .Values.templateFiles }} + {{ $key }}: {{ $val | b64enc | quote }} +{{- end }} +``` + +The next section explains the Alertmanager configuration. + +#### Alertmanager configuration - alertmanager.yaml + +[kyma/resources/core/charts/monitoring/charts/alertmanager/values.yaml](values.yaml) pre-configure two simple receiver to handle alert in **VictorOps and Slack**. + +To avoid confusion, use optional configuration parameters for ```route:``` and then group the receivers under the label ```routes:``` + +```yaml +config: + global: + resolve_timeout: 5m + route: + receiver: 'null' + group_wait: 30s + group_interval: 5m + repeat_interval: 1h # change to 10m to test + group_by: ['cluster','pod','job','alertname'] + # All alerts that do not match the following child routes + # will remain at the root node and be dispatched to 'default-receiver' + routes: + - receiver: 'null' + match: + alertname: DeadMansSwitch + # - receiver: 'team-YOUR-TEAM-victorOps' + # continue: true # If continue: is set to false it will stop after the first matching. + # match: + # alertname: PodNotRunning + # - receiver: 'team-YOUR-TEAM-slack' + # continue: true # If continue: is set to false it will stop after the first matching. + # match: + # alertname: PodNotRunning + receivers: + - name: 'null' + # - name: 'team-YOUR-TEAM-victorOps' + # victorops_configs: + # - api_key: API_VICTOROPS_TOKEN + # send_resolved: true + # api_url: https://alert.victorops.com/integrations/generic/20131114/alert/ + # routing_key: YOUR-TEAM-ROUTING-KEY + # state_message: 'Alert: {{ .CommonLabels.alertname }}. Summary:{{ .CommonAnnotations.summary }}. RawData: {{ .CommonLabels }}' + # - name: 'team-YOUR-TEAM-slack' + # slack_configs: + # - channel: '#YOU_CHANNEL' + # send_resolved: true + # api_url: https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXX/XXXXXXXXXXXXXXXXXX #Your slack Webhook URL + # icon_emoji: ":ghost:" + # title: '[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] Monitoring Event Notification' + # text: " \nsummary: {{ .CommonAnnotations.summary }}\ndescription: {{ .CommonAnnotations.description }}" +``` +**A Quick explanation** +* ```route:``` A route block defines a node in a routing tree and its children. Its optional configuration parameters are inherited from its parent node if not set. +* ```routes:``` Child routes. +* ```receiver:``` Receiver is a named configuration of one or more notification integrations. +* ```receiver:``` A list of configured notification receivers. + +**References** +- [VictorOps-Prometheus Integration Guide](https://help.victorops.com/knowledge-base/victorops-prometheus-integration/) +- [Prometheus Alerting configuration](https://prometheus.io/docs/alerting/configuration/) +- [Prometheus Alerting notification examples](https://prometheus.io/docs/alerting/notification_examples/) +- [Slack Incoming WebHooks](https://slack.com/apps/A0F7XDUAZ-incoming-webhooks) +- [Slack-API Legacy custom integrations](https://api.slack.com/custom-integrations) + + +### Create Alert Rules + +In Kyma all the configuration related to alert rules is in the chart [alert-rules](../alert-rules/README.md) diff --git a/resources/core/charts/monitoring/charts/alertmanager/templates/_helpers.tpl b/resources/core/charts/monitoring/charts/alertmanager/templates/_helpers.tpl new file mode 100644 index 000000000000..02e888160937 --- /dev/null +++ b/resources/core/charts/monitoring/charts/alertmanager/templates/_helpers.tpl @@ -0,0 +1,36 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "alertmanager.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 "alertmanager.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 -}} + +{{/* +Return the appropriate apiVersion value to use for the prometheus-operator managed k8s resources +*/}} +{{- define "prometheus-operator.apiVersion" -}} +{{- if .Capabilities.APIVersions.Has "monitoring.coreos.com/v1" }} +{{- printf "%s" "monitoring.coreos.com/v1" -}} +{{- else -}} +{{- printf "%s" "monitoring.coreos.com/v1alpha1" -}} +{{- end -}} +{{- end -}} diff --git a/resources/core/charts/monitoring/charts/alertmanager/templates/alertmanager.rules.yaml b/resources/core/charts/monitoring/charts/alertmanager/templates/alertmanager.rules.yaml new file mode 100644 index 000000000000..9a034983d16d --- /dev/null +++ b/resources/core/charts/monitoring/charts/alertmanager/templates/alertmanager.rules.yaml @@ -0,0 +1,35 @@ +{{ define "alertmanager.rules.yaml.tpl" }} +groups: +- name: alertmanager.rules + rules: + - alert: AlertmanagerConfigInconsistent + expr: count_values("config_hash", alertmanager_config_hash) BY (service) / ON(service) + GROUP_LEFT() label_replace(prometheus_operator_alertmanager_spec_replicas, "service", + "alertmanager-$1", "alertmanager", "(.*)") != 1 + for: 5m + labels: + severity: critical + annotations: + description: The configuration of the instances of the Alertmanager cluster + `{{`{{$labels.service}}`}}` are out of sync. + summary: Configuration out of sync + - alert: AlertmanagerDownOrMissing + expr: label_replace(prometheus_operator_alertmanager_spec_replicas, "job", "alertmanager-$1", + "alertmanager", "(.*)") / ON(job) GROUP_RIGHT() sum(up) BY (job) != 1 + for: 5m + labels: + severity: warning + annotations: + description: An unexpected number of Alertmanagers are scraped or Alertmanagers + disappeared from discovery. + summary: Alertmanager down or missing + - alert: AlertmanagerFailedReload + expr: alertmanager_config_last_reload_successful == 0 + for: 10m + labels: + severity: warning + annotations: + description: Reloading Alertmanager's configuration has failed for {{`{{ $labels.namespace + }}`}}/{{`{{ $labels.pod}}`}}. + summary: Alertmanager's configuration reload failed +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/alertmanager/templates/alertmanager.yaml b/resources/core/charts/monitoring/charts/alertmanager/templates/alertmanager.yaml new file mode 100644 index 000000000000..3ab53aa55066 --- /dev/null +++ b/resources/core/charts/monitoring/charts/alertmanager/templates/alertmanager.yaml @@ -0,0 +1,56 @@ +apiVersion: {{ template "prometheus-operator.apiVersion" . }} +kind: Alertmanager +metadata: + labels: + alertmanager: {{ .Release.Name }} + app: {{ template "alertmanager.name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} +{{- if .Values.labels }} +{{ toYaml .Values.labels | indent 4 }} +{{- end }} + name: {{ .Release.Name }} +spec: + baseImage: "{{ .Values.image.repository }}" +{{- if .Values.externalUrl }} + externalUrl: "{{ .Values.externalUrl }}" +{{- else if .Values.ingress.fqdn }} + externalUrl: http://{{ .Values.ingress.fqdn }}{{ .Values.routePrefix }} +{{- else }} + externalUrl: http://{{ template "alertmanager.fullname" . }}.{{ .Release.Namespace }}:9093 +{{- end }} +{{- if .Values.nodeSelector }} + nodeSelector: +{{ toYaml .Values.nodeSelector | indent 4 }} +{{- end }} + paused: {{ .Values.paused }} + replicas: {{ .Values.replicaCount }} + resources: +{{ toYaml .Values.resources | indent 4 }} +{{- if .Values.storageSpec }} + storage: +{{ toYaml .Values.storageSpec | indent 4 }} +{{- end }} + version: "{{ .Values.image.tag }}" +{{- if eq .Values.podAntiAffinity "hard" }} + affinity: + podAntiAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - topologyKey: kubernetes.io/hostname + labelSelector: + matchLabels: + app: {{ template "alertmanager.name" . }} + alertmanager: {{ .Release.Name }} +{{- else if eq .Values.podAntiAffinity "soft" }} + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + topologyKey: kubernetes.io/hostname + labelSelector: + matchLabels: + app: {{ template "alertmanager.name" . }} + alertmanager: {{ .Release.Name }} +{{- end }} diff --git a/resources/core/charts/monitoring/charts/alertmanager/templates/configmap.yaml b/resources/core/charts/monitoring/charts/alertmanager/templates/configmap.yaml new file mode 100644 index 000000000000..8f83e780afde --- /dev/null +++ b/resources/core/charts/monitoring/charts/alertmanager/templates/configmap.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + app: "alertmanager" + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + prometheus: {{ .Release.Name }} + release: {{ .Release.Name }} + role: alert-rules + name: {{ template "alertmanager.fullname" . }} +data: +{{- if .Values.prometheusRules }} +{{- $root := . }} +{{- range $key, $val := .Values.prometheusRules }} + {{ $key }}: |- +{{ $val | indent 4}} +{{- end }} +{{ else }} + alertmanager.rules: |- + {{- include "alertmanager.rules.yaml.tpl" . | indent 4}} +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/alertmanager/templates/ingress.yaml b/resources/core/charts/monitoring/charts/alertmanager/templates/ingress.yaml new file mode 100644 index 000000000000..6237fafd729e --- /dev/null +++ b/resources/core/charts/monitoring/charts/alertmanager/templates/ingress.yaml @@ -0,0 +1,32 @@ +{{- if .Values.ingress.enabled }} +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: +{{- if .Values.ingress.annotations }} + annotations: +{{ toYaml .Values.ingress.annotations | indent 4 }} +{{- end }} + labels: + alertmanager: {{ .Release.Name }} + app: {{ template "alertmanager.name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} +{{- if .Values.ingress.labels }} +{{ toYaml .Values.ingress.labels | indent 4 }} +{{- end }} + name: {{ template "alertmanager.fullname" . }} +spec: + rules: + - host: "{{ .Values.ingress.fqdn }}" + http: + paths: + - path: "{{ .Values.routePrefix }}" + backend: + serviceName: {{ template "alertmanager.fullname" . }} + servicePort: 9093 +{{- if .Values.ingress.tls }} + tls: +{{ toYaml .Values.ingress.tls | indent 4 }} +{{- end }} +{{- end }} diff --git a/resources/core/charts/monitoring/charts/alertmanager/templates/secret.yaml b/resources/core/charts/monitoring/charts/alertmanager/templates/secret.yaml new file mode 100644 index 000000000000..5ce3d57e79e4 --- /dev/null +++ b/resources/core/charts/monitoring/charts/alertmanager/templates/secret.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Secret +metadata: + labels: + alertmanager: {{ .Release.Name }} + app: {{ template "alertmanager.name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + name: alertmanager-{{ .Release.Name }} +data: + alertmanager.yaml: {{ toYaml .Values.config | b64enc | quote }} +{{- range $key, $val := .Values.templateFiles }} + {{ $key }}: {{ $val | b64enc | quote }} +{{- end }} diff --git a/resources/core/charts/monitoring/charts/alertmanager/templates/service.yaml b/resources/core/charts/monitoring/charts/alertmanager/templates/service.yaml new file mode 100644 index 000000000000..758c21fe44b5 --- /dev/null +++ b/resources/core/charts/monitoring/charts/alertmanager/templates/service.yaml @@ -0,0 +1,42 @@ +apiVersion: v1 +kind: Service +metadata: +{{- if .Values.service.annotations }} + annotations: +{{ toYaml .Values.service.annotations | indent 4 }} +{{- end }} + labels: + alertmanager: {{ .Release.Name }} + app: {{ template "alertmanager.name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} +{{- if .Values.service.labels }} +{{ toYaml .Values.service.labels | indent 4 }} +{{- end }} + name: {{ template "alertmanager.fullname" . }} +spec: + clusterIP: "{{ .Values.service.clusterIP }}" +{{- if .Values.service.externalIPs }} + externalIPs: +{{ toYaml .Values.service.externalIPs | indent 4 }} +{{- end }} +{{- if .Values.service.loadBalancerIP }} + loadBalancerIP: "{{ .Values.service.loadBalancerIP }}" +{{- end }} +{{- if .Values.service.loadBalancerSourceRanges }} + loadBalancerSourceRanges: +{{ toYaml .Values.service.loadBalancerSourceRanges | indent 4 }} +{{- end }} + ports: + - name: http + {{- if eq .Values.service.type "NodePort" }} + nodePort: {{ .Values.service.nodePort }} + {{- end }} + port: 9093 + targetPort: 9093 + protocol: TCP + selector: + alertmanager: {{ .Release.Name }} + app: {{ template "alertmanager.name" . }} + type: "{{ .Values.service.type }}" diff --git a/resources/core/charts/monitoring/charts/alertmanager/templates/servicemonitor.yaml b/resources/core/charts/monitoring/charts/alertmanager/templates/servicemonitor.yaml new file mode 100644 index 000000000000..31ecf35266e4 --- /dev/null +++ b/resources/core/charts/monitoring/charts/alertmanager/templates/servicemonitor.yaml @@ -0,0 +1,26 @@ +{{- if .Values.selfServiceMonitor }} +apiVersion: {{ template "prometheus-operator.apiVersion" . }} +kind: ServiceMonitor +metadata: + labels: + app: {{ template "alertmanager.name" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + component: alertmanager + heritage: "{{ .Release.Service }}" + release: "{{ .Release.Name }}" + prometheus: {{ .Release.Name }} + name: {{ template "alertmanager.fullname" . }} +spec: + jobLabel: app + selector: + matchLabels: + alertmanager: {{ .Release.Name }} + app: {{ template "alertmanager.name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + namespaceSelector: + matchNames: + - {{ .Release.Namespace | quote }} + endpoints: + - port: http + interval: 30s +{{- end }} diff --git a/resources/core/charts/monitoring/charts/alertmanager/values.yaml b/resources/core/charts/monitoring/charts/alertmanager/values.yaml new file mode 100644 index 000000000000..93b26d38df75 --- /dev/null +++ b/resources/core/charts/monitoring/charts/alertmanager/values.yaml @@ -0,0 +1,201 @@ +## Alertmanager configuration directives +## Ref: https://prometheus.io/docs/alerting/configuration/ +## +config: + global: + resolve_timeout: 5m + route: + receiver: 'null' + group_wait: 30s + group_interval: 5m + repeat_interval: 1h # change to 10m to test + group_by: ['cluster','pod','job','alertname'] + # All alerts that do not match the following child routes + # will remain at the root node and be dispatched to 'default-receiver' + routes: + - receiver: 'null' + match: + alertname: DeadMansSwitch + # - receiver: 'team-YOUR-TEAM-victorOps' + # continue: true # If continue: is set to false it will stop after the first matching. + # match: + # alertname: PodNotRunning + # - receiver: 'team-YOUR-TEAM-slack' + # continue: true # If continue: is set to false it will stop after the first matching. + # match: + # alertname: PodNotRunning + receivers: + - name: 'null' + # - name: 'team-YOUR-TEAM-victorOps' + # victorops_configs: + # - api_key: API_VICTOROPS_TOKEN + # send_resolved: true + # api_url: https://alert.victorops.com/integrations/generic/20131114/alert/ + # routing_key: YOUR-TEAM-ROUTING-KEY + # state_message: 'Alert: {{ .CommonLabels.alertname }}. Summary:{{ .CommonAnnotations.summary }}. RawData: {{ .CommonLabels }}' + # - name: 'team-YOUR-TEAM-slack' + # slack_configs: + # - channel: '#YOU_CHANNEL' + # send_resolved: true + # api_url: https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXX/XXXXXXXXXXXXXXXXXX #Your slack Webhook URL + # icon_emoji: ":ghost:" + # title: '[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] Monitoring Event Notification' + # text: " \nsummary: {{ .CommonAnnotations.summary }}\ndescription: {{ .CommonAnnotations.description }}" + +## Alertmanager template files to include +# +templateFiles: {} +# +# An example template: +# template_1.tmpl: |- +# {{ define "cluster" }}{{ .ExternalURL | reReplaceAll ".*alertmanager\\.(.*)" "$1" }}{{ end }} +# +# {{ define "slack.myorg.text" }} +# {{- $root := . -}} +# {{ range .Alerts }} +# *Alert:* {{ .Annotations.summary }} - `{{ .Labels.severity }}` +# *Cluster:* {{ template "cluster" $root }} +# *Description:* {{ .Annotations.description }} +# *Graph:* <{{ .GeneratorURL }}|:chart_with_upwards_trend:> +# *Runbook:* <{{ .Annotations.runbook }}|:spiral_note_pad:> +# *Details:* +# {{ range .Labels.SortedPairs }} • *{{ .Name }}:* `{{ .Value }}` +# {{ end }} + +## External URL at which Alertmanager will be reachable +## +externalUrl: "" + +## If true, create a serviceMonitor for alertmanager +## +selfServiceMonitor: true + +## Alertmanager container image +## +image: + repository: quay.io/prometheus/alertmanager + tag: v0.14.0 + +## Labels to be added to the Alertmanager +## +labels: {} + +ingress: + ## If true, Alertmanager Ingress will be created + ## + enabled: false + + ## Annotations for Alertmanager Ingress + ## + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + + ## Labels to be added to the Ingress + ## + labels: {} + + fqdn: "" + + ## TLS configuration for Alertmanager Ingress + ## Secret must be manually created in the namespace + ## + tls: [] + # - secretName: alertmanager-general-tls + # hosts: + # - alertmanager.example.com + +## Node labels for Alertmanager pod assignment +## Ref: https://kubernetes.io/docs/user-guide/node-selection/ +## +nodeSelector: {} + +## Tolerations for use with node taints +## Ref: https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/ +## +tolerations: {} + # - key: "key" + # operator: "Equal" + # value: "value" + # effect: "NoSchedule" + + + +## If true, the Operator won't process any Alertmanager configuration changes +## +paused: false + +## Number of Alertmanager replicas desired +## +replicaCount: 1 + +## Pod anti-affinity can prevent the scheduler from placing Alertmanager replicas on the same node. +## The default value "soft" means that the scheduler should *prefer* to not schedule two replica pods onto the same node but no guarantee is provided. +## The value "hard" means that the scheduler is *required* to not schedule two replica pods onto the same node. +## The value "" will disable pod anti-affinity so that no anti-affinity rules will be configured. +podAntiAffinity: "soft" + +## Resource limits & requests +## Ref: https://kubernetes.io/docs/user-guide/compute-resources/ +## +resources: + requests: + memory: 200Mi + limits: + memory: 400Mi + +service: + ## Annotations to be added to the Service + ## + annotations: {} + + ## Cluster-internal IP address for Alertmanager Service + ## + clusterIP: "" + + ## List of external IP addresses at which the Alertmanager Service will be available + ## + externalIPs: [] + + ## Labels to be added to the Service + ## + labels: {} + + ## External IP address to assign to Alertmanager Service + ## Only used if service.type is 'LoadBalancer' and supported by cloud provider + ## + loadBalancerIP: "" + + ## List of client IPs allowed to access Alertmanager Service + ## Only used if service.type is 'LoadBalancer' and supported by cloud provider + ## + loadBalancerSourceRanges: [] + + ## Port to expose on each node + ## Only used if service.type is 'NodePort' + ## + nodePort: 30903 + + ## Service type + ## + type: ClusterIP + +## If true, create & use RBAC resources +## +rbacEnable: true + +## Alertmanager StorageSpec for persistent data +## Ref: https://github.com/coreos/prometheus-operator/blob/master/Documentation/user-guides/storage.md +## +storageSpec: {} +# volumeClaimTemplate: +# spec: +# storageClassName: gluster +# accessModes: ["ReadWriteOnce"] +# resources: +# requests: +# storage: 50Gi +# selector: {} + +# default rules are in templates/alertmanager.rules.yaml +# prometheusRules: {} diff --git a/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/.helmignore b/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/.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/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/Chart.yaml b/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/Chart.yaml new file mode 100644 index 000000000000..8fb41a8a7c4c --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +name: exporter-kube-controller-manager +description: Exporter for kube-controller-manager +version: 0.1.8 +keywords: +- monitoring +- kyma diff --git a/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/_helpers.tpl b/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/_helpers.tpl new file mode 100644 index 000000000000..8f14ebc47af9 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/_helpers.tpl @@ -0,0 +1,36 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "exporter-kube-controller-manager.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 "exporter-kube-controller-manager.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 -}} + +{{/* +Return the appropriate apiVersion value to use for the prometheus-operator managed k8s resources +*/}} +{{- define "prometheus-operator.apiVersion" -}} +{{- if .Capabilities.APIVersions.Has "monitoring.coreos.com/v1" }} +{{- printf "%s" "monitoring.coreos.com/v1" -}} +{{- else -}} +{{- printf "%s" "monitoring.coreos.com/v1alpha1" -}} +{{- end -}} +{{- end -}} diff --git a/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/configmap.yaml b/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/configmap.yaml new file mode 100644 index 000000000000..edfd810db48f --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/configmap.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + app: "prometheus" + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + prometheus: {{ .Release.Name }} + release: {{ .Release.Name }} + role: alert-rules + {{- if .Values.additionalRulesConfigMapLabels }} +{{ toYaml .Values.additionalRulesConfigMapLabels | indent 4 }} + {{- end }} + name: {{ template "exporter-kube-controller-manager.fullname" . }} +data: +{{- if .Values.prometheusRules }} +{{- $root := . }} +{{- range $key, $val := .Values.prometheusRules }} + {{ $key }}: |- +{{ $val | indent 4}} +{{- end }} +{{ else }} + kube-controller-manager.rules: |- + {{- include "kube-controller-manager.rules.yaml.tpl" . | indent 4}} +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/endpoints.yaml b/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/endpoints.yaml new file mode 100644 index 000000000000..657fb1ab29ef --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/endpoints.yaml @@ -0,0 +1,22 @@ +{{- if .Values.endpoints }} +apiVersion: v1 +kind: Endpoints +metadata: + labels: + app: {{ template "exporter-kube-controller-manager.name" . }} + component: kube-controller-manager + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + name: {{ template "exporter-kube-controller-manager.fullname" . }} + namespace: kube-system +subsets: + - addresses: + {{- range .Values.endpoints }} + - ip: {{ . }} + {{- end }} + ports: + - name: {{ .Values.scheme }}-metrics + port: {{ .Values.controllerManagerPort }} + protocol: TCP +{{- end }} diff --git a/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/kube-controller-manager.rules.yaml b/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/kube-controller-manager.rules.yaml new file mode 100644 index 000000000000..c271e4fc832b --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/kube-controller-manager.rules.yaml @@ -0,0 +1,5 @@ +{{ define "kube-controller-manager.rules.yaml.tpl" }} +groups: +- name: kube-controller-manager.rules + rules: +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/service.yaml b/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/service.yaml new file mode 100644 index 000000000000..5906f81fc4b2 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/service.yaml @@ -0,0 +1,23 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: {{ template "exporter-kube-controller-manager.name" . }} + component: kube-controller-manager + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + name: {{ template "exporter-kube-controller-manager.fullname" . }} + namespace: kube-system +spec: + clusterIP: None + ports: + - name: http-metrics + port: {{ .Values.controllerManagerPort}} + protocol: TCP + targetPort: {{ .Values.controllerManagerPort}} +{{- if not .Values.endpoints }} + selector: + {{ .Values.serviceSelectorLabelKey }}: kube-controller-manager +{{- end }} + type: ClusterIP diff --git a/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/servicemonitor.yaml b/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/servicemonitor.yaml new file mode 100644 index 000000000000..2cf1ee9fa8cf --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/templates/servicemonitor.yaml @@ -0,0 +1,31 @@ +apiVersion: {{ template "prometheus-operator.apiVersion" . }} +kind: ServiceMonitor +metadata: + labels: + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + component: kube-controller-manager + heritage: "{{ .Release.Service }}" + release: "{{ .Release.Name }}" + prometheus: {{ .Release.Name }} + {{- if .Values.additionalServiceMonitorLabels }} +{{ toYaml .Values.additionalServiceMonitorLabels | indent 4 }} + {{- end }} + name: {{ template "exporter-kube-controller-manager.fullname" . }} +spec: + jobLabel: component + selector: + matchLabels: + app: {{ template "exporter-kube-controller-manager.name" . }} + component: kube-controller-manager + namespaceSelector: + matchNames: + - "kube-system" + endpoints: + - port: http-metrics + interval: 30s + tlsConfig: + caFile: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt + # Skip verification until we have resolved why the certificate validation + # for the kubelet on API server nodes fail. + insecureSkipVerify: true + bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token diff --git a/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/values.yaml b/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/values.yaml new file mode 100644 index 000000000000..31849381d9f2 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-controller-manager/values.yaml @@ -0,0 +1,17 @@ +# on what port are the metrics exposed by etcd +controllerManagerPort: 10252 +# for deployments that have etcd deployed outside of the cluster, list their adresses here +endpoints: [] +# Are we talking http or https? +scheme: http +# service selector label key to target kube controller manager pods +serviceSelectorLabelKey: k8s-app +# default rules are in templates/kube-controller-manager.rules.yaml +# prometheusRules: {} +## Custom Labels to be added to ServiceMonitor +## +additionalServiceMonitorLabels: {} + +##Custom Labels to be added to Prometheus Rules ConfigMap +## +additionalRulesConfigMapLabels: {} diff --git a/resources/core/charts/monitoring/charts/exporter-kube-dns/.helmignore b/resources/core/charts/monitoring/charts/exporter-kube-dns/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-dns/.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/resources/core/charts/monitoring/charts/exporter-kube-dns/Chart.yaml b/resources/core/charts/monitoring/charts/exporter-kube-dns/Chart.yaml new file mode 100644 index 000000000000..b6cc0a4522c4 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-dns/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +name: exporter-kube-dns +description: Exporter for kube-dns +version: 0.1.6 +keywords: +- monitoring +- kyma diff --git a/resources/core/charts/monitoring/charts/exporter-kube-dns/templates/_helpers.tpl b/resources/core/charts/monitoring/charts/exporter-kube-dns/templates/_helpers.tpl new file mode 100644 index 000000000000..5da5a125fb2e --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-dns/templates/_helpers.tpl @@ -0,0 +1,36 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "exporter-kube-dns.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 "exporter-kube-dns.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 -}} + +{{/* +Return the appropriate apiVersion value to use for the prometheus-operator managed k8s resources +*/}} +{{- define "prometheus-operator.apiVersion" -}} +{{- if .Capabilities.APIVersions.Has "monitoring.coreos.com/v1" }} +{{- printf "%s" "monitoring.coreos.com/v1" -}} +{{- else -}} +{{- printf "%s" "monitoring.coreos.com/v1alpha1" -}} +{{- end -}} +{{- end -}} diff --git a/resources/core/charts/monitoring/charts/exporter-kube-dns/templates/service.yaml b/resources/core/charts/monitoring/charts/exporter-kube-dns/templates/service.yaml new file mode 100644 index 000000000000..b0f08411ae9c --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-dns/templates/service.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: {{ template "exporter-kube-dns.name" . }} + component: kube-dns + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + name: {{ template "exporter-kube-dns.fullname" . }} + namespace: kube-system +spec: + clusterIP: None + ports: + - name: http-metrics-dnsmasq + port: 10054 + protocol: TCP + targetPort: 10054 + - name: http-metrics-skydns + port: 10055 + protocol: TCP + targetPort: 10055 + selector: + k8s-app: kube-dns + type: ClusterIP diff --git a/resources/core/charts/monitoring/charts/exporter-kube-dns/templates/servicemonitor.yaml b/resources/core/charts/monitoring/charts/exporter-kube-dns/templates/servicemonitor.yaml new file mode 100644 index 000000000000..d7c56d00b680 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-dns/templates/servicemonitor.yaml @@ -0,0 +1,29 @@ +apiVersion: {{ template "prometheus-operator.apiVersion" . }} +kind: ServiceMonitor +metadata: + labels: + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + component: kube-dns + heritage: "{{ .Release.Service }}" + release: "{{ .Release.Name }}" + prometheus: {{ .Release.Name }} + {{- if .Values.additionalServiceMonitorLabels }} +{{ toYaml .Values.additionalServiceMonitorLabels | indent 4 }} + {{- end }} + name: {{ template "exporter-kube-dns.fullname" . }} +spec: + jobLabel: component + selector: + matchLabels: + app: {{ template "exporter-kube-dns.name" . }} + component: kube-dns + namespaceSelector: + matchNames: + - "kube-system" + endpoints: + - port: http-metrics-dnsmasq + interval: 30s + bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token + - port: http-metrics-skydns + interval: 30s + bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token diff --git a/resources/core/charts/monitoring/charts/exporter-kube-dns/values.yaml b/resources/core/charts/monitoring/charts/exporter-kube-dns/values.yaml new file mode 100644 index 000000000000..b01a38649172 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-dns/values.yaml @@ -0,0 +1,5 @@ +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +## Custom Labels to be added to ServiceMonitor +## +additionalServiceMonitorLabels: {} diff --git a/resources/core/charts/monitoring/charts/exporter-kube-etcd/.helmignore b/resources/core/charts/monitoring/charts/exporter-kube-etcd/.helmignore new file mode 100755 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-etcd/.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/resources/core/charts/monitoring/charts/exporter-kube-etcd/Chart.yaml b/resources/core/charts/monitoring/charts/exporter-kube-etcd/Chart.yaml new file mode 100755 index 000000000000..b99a69bd5984 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-etcd/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +name: exporter-kube-etcd +description: Exporter for kube-etcd +version: 0.1.10 +keywords: +- monitoring +- kyma diff --git a/resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/_helpers.tpl b/resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/_helpers.tpl new file mode 100755 index 000000000000..ecf8a77995d4 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/_helpers.tpl @@ -0,0 +1,36 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "exporter-kube-etcd.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 "exporter-kube-etcd.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 -}} + +{{/* +Return the appropriate apiVersion value to use for the prometheus-operator managed k8s resources +*/}} +{{- define "prometheus-operator.apiVersion" -}} +{{- if .Capabilities.APIVersions.Has "monitoring.coreos.com/v1" }} +{{- printf "%s" "monitoring.coreos.com/v1" -}} +{{- else -}} +{{- printf "%s" "monitoring.coreos.com/v1alpha1" -}} +{{- end -}} +{{- end -}} diff --git a/resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/configmap.yaml b/resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/configmap.yaml new file mode 100644 index 000000000000..67ced538c26c --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/configmap.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + app: "prometheus" + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + prometheus: {{ .Release.Name }} + release: {{ .Release.Name }} + role: alert-rules + {{- if .Values.additionalRulesConfigMapLabels }} +{{ toYaml .Values.additionalRulesConfigMapLabels | indent 4 }} + {{- end }} + name: {{ template "exporter-kube-etcd.fullname" . }} +data: +{{- if .Values.prometheusRules }} +{{- $root := . }} +{{- range $key, $val := .Values.prometheusRules }} + {{ $key }}: |- +{{ tpl $val $root | indent 4}} +{{- end }} +{{ else }} + etcd3.rules: |- + {{- include "etcd3.rules.yaml.tpl" . | indent 4}} +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/endpoints.yaml b/resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/endpoints.yaml new file mode 100644 index 000000000000..635ff6e62bf0 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/endpoints.yaml @@ -0,0 +1,22 @@ +{{- if .Values.endpoints }} +apiVersion: v1 +kind: Endpoints +metadata: + labels: + app: {{ template "exporter-kube-etcd.name" . }} + component: kube-etcd + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + name: {{ template "exporter-kube-etcd.fullname" . }} + namespace: kube-system +subsets: + - addresses: + {{- range .Values.endpoints }} + - ip: {{ . }} + {{- end }} + ports: + - name: {{ .Values.scheme }}-metrics + port: {{ .Values.etcdPort }} + protocol: TCP +{{- end }} diff --git a/resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/etcd3.rules.yaml b/resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/etcd3.rules.yaml new file mode 100644 index 000000000000..8efd14381fea --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/etcd3.rules.yaml @@ -0,0 +1,125 @@ +{{ define "etcd3.rules.yaml.tpl" }} +groups: +- name: ./etcd3.rules + rules: + - alert: InsufficientMembers + expr: count(up{job="etcd"} == 0) > (count(up{job="etcd"}) / 2 - 1) + for: 3m + labels: + severity: critical + annotations: + description: If one more etcd member goes down the cluster will be unavailable + summary: etcd cluster insufficient members + - alert: NoLeader + expr: etcd_server_has_leader{job="etcd"} == 0 + for: 1m + labels: + severity: critical + annotations: + description: etcd member {{`{{ $labels.instance }}`}} has no leader + summary: etcd member has no leader + - alert: HighNumberOfLeaderChanges + expr: increase(etcd_server_leader_changes_seen_total{job="etcd"}[1h]) > 3 + labels: + severity: warning + annotations: + description: etcd instance {{`{{ $labels.instance }}`}} has seen {{`{{ $value }}`}} leader + changes within the last hour + summary: a high number of leader changes within the etcd cluster are happening + - alert: HighNumberOfFailedGRPCRequests + expr: sum(rate(grpc_server_handled_total{grpc_code!="OK",job="etcd"}[5m])) BY (grpc_service, grpc_method) + / sum(rate(grpc_server_handled_total{job="etcd"}[5m])) BY (grpc_service, grpc_method) > 0.01 + for: 10m + labels: + severity: warning + annotations: + description: '{{`{{ $value }}`}}% of requests for {{`{{ $labels.grpc_method }}`}} failed + on etcd instance {{`{{ $labels.instance }}`}}' + summary: a high number of gRPC requests are failing + - alert: HighNumberOfFailedGRPCRequests + expr: sum(rate(grpc_server_handled_total{grpc_code!="OK",job="etcd"}[5m])) BY (grpc_service, grpc_method) + / sum(rate(grpc_server_handled_total{job="etcd"}[5m])) BY (grpc_service, grpc_method) > 0.05 + for: 5m + labels: + severity: critical + annotations: + description: '{{`{{ $value }}`}}% of requests for {{`{{ $labels.grpc_method }}`}} failed + on etcd instance {{`{{ $labels.instance }}`}}' + summary: a high number of gRPC requests are failing + - alert: GRPCRequestsSlow + expr: histogram_quantile(0.99, sum(rate(grpc_server_handling_seconds_bucket{job="etcd",grpc_type="unary"}[5m])) by (grpc_service, grpc_method, le)) + > 0.15 + for: 10m + labels: + severity: critical + annotations: + description: on etcd instance {{`{{ $labels.instance }}`}} gRPC requests to {{`{{ $labels.grpc_method + }}`}} are slow + summary: slow gRPC requests + - alert: HighNumberOfFailedHTTPRequests + expr: sum(rate(etcd_http_failed_total{job="etcd"}[5m])) BY (method) / sum(rate(etcd_http_received_total{job="etcd"}[5m])) + BY (method) > 0.01 + for: 10m + labels: + severity: warning + annotations: + description: '{{`{{ $value }}`}}% of requests for {{`{{ $labels.method }}`}} failed on etcd + instance {{`{{ $labels.instance }}`}}' + summary: a high number of HTTP requests are failing + - alert: HighNumberOfFailedHTTPRequests + expr: sum(rate(etcd_http_failed_total{job="etcd"}[5m])) BY (method) / sum(rate(etcd_http_received_total{job="etcd"}[5m])) + BY (method) > 0.05 + for: 5m + labels: + severity: critical + annotations: + description: '{{`{{ $value }}`}}% of requests for {{`{{ $labels.method }}`}} failed on etcd + instance {{`{{ $labels.instance }}`}}' + summary: a high number of HTTP requests are failing + - alert: HTTPRequestsSlow + expr: histogram_quantile(0.99, rate(etcd_http_successful_duration_seconds_bucket[5m])) + > 0.15 + for: 10m + labels: + severity: warning + annotations: + description: on etcd instance {{`{{ $labels.instance }}`}} HTTP requests to {{`{{ $labels.method + }}`}} are slow + summary: slow HTTP requests + - alert: EtcdMemberCommunicationSlow + expr: histogram_quantile(0.99, rate(etcd_network_peer_round_trip_time_seconds_bucket[5m])) + > 0.15 + for: 10m + labels: + severity: warning + annotations: + description: etcd instance {{`{{ $labels.instance }}`}} member communication with + {{`{{ $labels.To }}`}} is slow + summary: etcd member communication is slow + - alert: HighNumberOfFailedProposals + expr: increase(etcd_server_proposals_failed_total{job="etcd"}[1h]) > 5 + labels: + severity: warning + annotations: + description: etcd instance {{`{{ $labels.instance }}`}} has seen {{`{{ $value }}`}} proposal + failures within the last hour + summary: a high number of proposals within the etcd cluster are failing + - alert: HighFsyncDurations + expr: histogram_quantile(0.99, rate(etcd_disk_wal_fsync_duration_seconds_bucket[5m])) + > 0.5 + for: 10m + labels: + severity: warning + annotations: + description: etcd instance {{`{{ $labels.instance }}`}} fync durations are high + summary: high fsync durations + - alert: HighCommitDurations + expr: histogram_quantile(0.99, rate(etcd_disk_backend_commit_duration_seconds_bucket[5m])) + > 0.25 + for: 10m + labels: + severity: warning + annotations: + description: etcd instance {{`{{ $labels.instance }}`}} commit durations are high + summary: high commit durations +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/service.yaml b/resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/service.yaml new file mode 100644 index 000000000000..6891f0030e6a --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/service.yaml @@ -0,0 +1,23 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: {{ template "exporter-kube-etcd.name" . }} + component: kube-etcd + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + name: {{ template "exporter-kube-etcd.fullname" . }} + namespace: kube-system +spec: + clusterIP: None + ports: + - name: {{ .Values.scheme }}-metrics + port: {{ .Values.etcdPort }} + protocol: TCP + targetPort: {{ .Values.etcdPort }} +{{- if .Values.endpoints }}{{- else }} + selector: + k8s-app: etcd-server +{{- end }} + type: ClusterIP diff --git a/resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/servicemonitor.yaml b/resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/servicemonitor.yaml new file mode 100644 index 000000000000..cb00d1dcb4ef --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-etcd/templates/servicemonitor.yaml @@ -0,0 +1,39 @@ +apiVersion: {{ template "prometheus-operator.apiVersion" . }} +kind: ServiceMonitor +metadata: + labels: + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + component: kube-etcd + heritage: "{{ .Release.Service }}" + release: "{{ .Release.Name }}" + prometheus: {{ .Release.Name }} + {{- if .Values.additionalServiceMonitorLabels }} +{{ toYaml .Values.additionalServiceMonitorLabels | indent 4 }} + {{- end }} + name: {{ template "exporter-kube-etcd.fullname" . }} +spec: + jobLabel: component + selector: + matchLabels: + app: {{ template "exporter-kube-etcd.name" . }} + component: kube-etcd + namespaceSelector: + matchNames: + - "kube-system" + endpoints: + - port: {{ .Values.scheme }}-metrics + interval: 30s + bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token + {{- if eq .Values.scheme "https" }} + scheme: https + tlsConfig: + caFile: {{ .Values.caFile }} + {{- if .Values.certFile }} + certFile: {{ .Values.certFile }} + {{- end }} + {{- if .Values.keyFile }} + keyFile: {{ .Values.keyFile }} + {{- end}} + insecureSkipVerify: true + {{- end }} + diff --git a/resources/core/charts/monitoring/charts/exporter-kube-etcd/values.yaml b/resources/core/charts/monitoring/charts/exporter-kube-etcd/values.yaml new file mode 100755 index 000000000000..703c52d54ff9 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-etcd/values.yaml @@ -0,0 +1,20 @@ +# on what port are the metrics exposed by etcd. Use port 2379 for https +etcdPort: 4001 +# for deployments that have etcd deployed outside of the cluster, list their adresses here +endpoints: [] +# Are we talking http or https? +scheme: http +# default rules are in templates/etcd3.rules.yaml +# prometheusRules: {} + +# TLS Cofiguration for the service monitor, default to none, but append cert and keyfile if passed +caFile: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt +certFile: "" +keyFile: "" + +## Custom Labels to be added to ServiceMonitor +## +additionalServiceMonitorLabels: {} +##Custom Labels to be added to Prometheus Rules ConfigMap +## +additionalRulesConfigMapLabels: {} diff --git a/resources/core/charts/monitoring/charts/exporter-kube-scheduler/.helmignore b/resources/core/charts/monitoring/charts/exporter-kube-scheduler/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-scheduler/.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/resources/core/charts/monitoring/charts/exporter-kube-scheduler/Chart.yaml b/resources/core/charts/monitoring/charts/exporter-kube-scheduler/Chart.yaml new file mode 100644 index 000000000000..5ab12d8b9f08 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-scheduler/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +name: exporter-kube-scheduler +description: Exporter for kube-scheduler +version: 0.1.7 +keywords: +- monitoring +- kyma diff --git a/resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/_helpers.tpl b/resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/_helpers.tpl new file mode 100644 index 000000000000..483fe94289c8 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/_helpers.tpl @@ -0,0 +1,36 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "exporter-kube-scheduler.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 "exporter-kube-scheduler.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 -}} + +{{/* +Return the appropriate apiVersion value to use for the prometheus-operator managed k8s resources +*/}} +{{- define "prometheus-operator.apiVersion" -}} +{{- if .Capabilities.APIVersions.Has "monitoring.coreos.com/v1" }} +{{- printf "%s" "monitoring.coreos.com/v1" -}} +{{- else -}} +{{- printf "%s" "monitoring.coreos.com/v1alpha1" -}} +{{- end -}} +{{- end -}} diff --git a/resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/configmap.yaml b/resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/configmap.yaml new file mode 100644 index 000000000000..c572b89bad32 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/configmap.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + app: "prometheus" + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + prometheus: {{ .Release.Name }} + release: {{ .Release.Name }} + role: alert-rules + {{- if .Values.additionalRulesConfigMapLabels }} +{{ toYaml .Values.additionalRulesConfigMapLabels | indent 4 }} + {{- end }} + name: {{ template "exporter-kube-scheduler.fullname" . }} +data: +{{- if .Values.prometheusRules }} +{{- $root := . }} +{{- range $key, $val := .Values.prometheusRules }} + {{ $key }}: |- +{{ tpl $val $root | indent 4}} +{{- end }} +{{ else }} + kube-scheduler.rules: |- + {{- include "kube-scheduler.rules.yaml.tpl" . | indent 4}} +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/endpoints.yaml b/resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/endpoints.yaml new file mode 100644 index 000000000000..5ce12ac9c104 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/endpoints.yaml @@ -0,0 +1,22 @@ +{{- if .Values.endpoints }} +apiVersion: v1 +kind: Endpoints +metadata: + labels: + app: {{ template "exporter-kube-scheduler.name" . }} + component: kube-scheduler + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + name: {{ template "exporter-kube-scheduler.fullname" . }} + namespace: kube-system +subsets: + - addresses: + {{- range .Values.endpoints }} + - ip: {{ . }} + {{- end }} + ports: + - name: {{ .Values.scheme }}-metrics + port: {{ .Values.schedulerPort }} + protocol: TCP +{{- end }} diff --git a/resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/kube-scheduler.rules.yaml b/resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/kube-scheduler.rules.yaml new file mode 100644 index 000000000000..3de74fe50014 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/kube-scheduler.rules.yaml @@ -0,0 +1,50 @@ +{{ define "kube-scheduler.rules.yaml.tpl" }} +groups: +- name: kube-scheduler.rules + rules: + - record: cluster:scheduler_e2e_scheduling_latency_seconds:quantile + expr: histogram_quantile(0.99, sum(scheduler_e2e_scheduling_latency_microseconds_bucket) + BY (le, cluster)) / 1e+06 + labels: + quantile: "0.99" + - record: cluster:scheduler_e2e_scheduling_latency_seconds:quantile + expr: histogram_quantile(0.9, sum(scheduler_e2e_scheduling_latency_microseconds_bucket) + BY (le, cluster)) / 1e+06 + labels: + quantile: "0.9" + - record: cluster:scheduler_e2e_scheduling_latency_seconds:quantile + expr: histogram_quantile(0.5, sum(scheduler_e2e_scheduling_latency_microseconds_bucket) + BY (le, cluster)) / 1e+06 + labels: + quantile: "0.5" + - record: cluster:scheduler_scheduling_algorithm_latency_seconds:quantile + expr: histogram_quantile(0.99, sum(scheduler_scheduling_algorithm_latency_microseconds_bucket) + BY (le, cluster)) / 1e+06 + labels: + quantile: "0.99" + - record: cluster:scheduler_scheduling_algorithm_latency_seconds:quantile + expr: histogram_quantile(0.9, sum(scheduler_scheduling_algorithm_latency_microseconds_bucket) + BY (le, cluster)) / 1e+06 + labels: + quantile: "0.9" + - record: cluster:scheduler_scheduling_algorithm_latency_seconds:quantile + expr: histogram_quantile(0.5, sum(scheduler_scheduling_algorithm_latency_microseconds_bucket) + BY (le, cluster)) / 1e+06 + labels: + quantile: "0.5" + - record: cluster:scheduler_binding_latency_seconds:quantile + expr: histogram_quantile(0.99, sum(scheduler_binding_latency_microseconds_bucket) + BY (le, cluster)) / 1e+06 + labels: + quantile: "0.99" + - record: cluster:scheduler_binding_latency_seconds:quantile + expr: histogram_quantile(0.9, sum(scheduler_binding_latency_microseconds_bucket) + BY (le, cluster)) / 1e+06 + labels: + quantile: "0.9" + - record: cluster:scheduler_binding_latency_seconds:quantile + expr: histogram_quantile(0.5, sum(scheduler_binding_latency_microseconds_bucket) + BY (le, cluster)) / 1e+06 + labels: + quantile: "0.5" +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/service.yaml b/resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/service.yaml new file mode 100644 index 000000000000..0674bb780733 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/service.yaml @@ -0,0 +1,23 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: {{ template "exporter-kube-scheduler.name" . }} + component: kube-scheduler + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + name: {{ template "exporter-kube-scheduler.fullname" . }} + namespace: kube-system +spec: + clusterIP: None + ports: + - name: http-metrics + port: {{ .Values.schedulerPort}} + protocol: TCP + targetPort: {{ .Values.schedulerPort}} +{{- if not .Values.endpoints }} + selector: + {{ .Values.serviceSelectorLabelKey }}: kube-scheduler +{{- end }} + type: ClusterIP diff --git a/resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/servicemonitor.yaml b/resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/servicemonitor.yaml new file mode 100644 index 000000000000..2e3db77d2e11 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-scheduler/templates/servicemonitor.yaml @@ -0,0 +1,26 @@ +apiVersion: {{ template "prometheus-operator.apiVersion" . }} +kind: ServiceMonitor +metadata: + labels: + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + component: kube-scheduler + heritage: "{{ .Release.Service }}" + release: "{{ .Release.Name }}" + prometheus: {{ .Release.Name }} + {{- if .Values.additionalServiceMonitorLabels }} +{{ toYaml .Values.additionalServiceMonitorLabels | indent 4 }} + {{- end }} + name: {{ template "exporter-kube-scheduler.fullname" . }} +spec: + jobLabel: component + selector: + matchLabels: + app: {{ template "exporter-kube-scheduler.name" . }} + component: kube-scheduler + namespaceSelector: + matchNames: + - "kube-system" + endpoints: + - port: http-metrics + interval: 30s + bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token diff --git a/resources/core/charts/monitoring/charts/exporter-kube-scheduler/values.yaml b/resources/core/charts/monitoring/charts/exporter-kube-scheduler/values.yaml new file mode 100644 index 000000000000..df889729032c --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-scheduler/values.yaml @@ -0,0 +1,16 @@ +# on what port are the metrics exposed by etcd +schedulerPort: 10251 +# for deployments that have etcd deployed outside of the cluster, list their adresses here +endpoints: [] +# Are we talking http or https? +scheme: http +# service selector label key to target kube scheduler pods +serviceSelectorLabelKey: k8s-app +# default rules are in templates/kube-scheduler.rules.yaml +# prometheusRules: {} +## Custom Labels to be added to ServiceMonitor +## +additionalServiceMonitorLabels: {} +##Custom Labels to be added to Prometheus Rules ConfigMap +## +additionalRulesConfigMapLabels: {} diff --git a/resources/core/charts/monitoring/charts/exporter-kube-state/.helmignore b/resources/core/charts/monitoring/charts/exporter-kube-state/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-state/.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/resources/core/charts/monitoring/charts/exporter-kube-state/Chart.yaml b/resources/core/charts/monitoring/charts/exporter-kube-state/Chart.yaml new file mode 100644 index 000000000000..cc6be22b09f7 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-state/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +name: exporter-kube-state +description: Exporter for kube-state +version: 0.1.17 +keywords: +- monitoring +- kyma diff --git a/resources/core/charts/monitoring/charts/exporter-kube-state/templates/_helpers.tpl b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/_helpers.tpl new file mode 100644 index 000000000000..013449378782 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/_helpers.tpl @@ -0,0 +1,36 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "exporter-kube-state.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 "exporter-kube-state.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 -}} + +{{/* +Return the appropriate apiVersion value to use for the prometheus-operator managed k8s resources +*/}} +{{- define "prometheus-operator.apiVersion" -}} +{{- if .Capabilities.APIVersions.Has "monitoring.coreos.com/v1" }} +{{- printf "%s" "monitoring.coreos.com/v1" -}} +{{- else -}} +{{- printf "%s" "monitoring.coreos.com/v1alpha1" -}} +{{- end -}} +{{- end -}} diff --git a/resources/core/charts/monitoring/charts/exporter-kube-state/templates/clusterrole.yaml b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/clusterrole.yaml new file mode 100644 index 000000000000..22346c8de044 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/clusterrole.yaml @@ -0,0 +1,48 @@ +{{- if .Values.global.rbacEnable }} +{{- if .Capabilities.APIVersions.Has "rbac.authorization.k8s.io/v1beta1" }} +apiVersion: rbac.authorization.k8s.io/v1beta1 +{{- else if .Capabilities.APIVersions.Has "rbac.authorization.k8s.io/v1alpha1" }} +apiVersion: rbac.authorization.k8s.io/v1alpha1 +{{- end }} +kind: ClusterRole +metadata: + labels: + app: {{ template "exporter-kube-state.name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + name: {{ template "exporter-kube-state.fullname" . }} +rules: +- apiGroups: [""] + resources: + - namespaces + - nodes + - pods + - services + - resourcequotas + - replicationcontrollers + - limitranges + - persistentvolumeclaims + - persistentvolumes + - endpoints + verbs: ["list", "watch"] +- apiGroups: ["extensions"] + resources: + - daemonsets + - deployments + - replicasets + verbs: ["list", "watch"] +- apiGroups: ["apps"] + resources: + - statefulsets + verbs: ["list", "watch"] +- apiGroups: ["batch"] + resources: + - cronjobs + - jobs + verbs: ["list", "watch"] +- apiGroups: ["autoscaling"] + resources: + - horizontalpodautoscalers + verbs: ["list", "watch"] +{{- end }} diff --git a/resources/core/charts/monitoring/charts/exporter-kube-state/templates/clusterrolebinding.yaml b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/clusterrolebinding.yaml new file mode 100644 index 000000000000..a7ed67f8767e --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/clusterrolebinding.yaml @@ -0,0 +1,23 @@ +{{- if .Values.global.rbacEnable }} +{{- if .Capabilities.APIVersions.Has "rbac.authorization.k8s.io/v1beta1" }} +apiVersion: rbac.authorization.k8s.io/v1beta1 +{{- else if .Capabilities.APIVersions.Has "rbac.authorization.k8s.io/v1alpha1" }} +apiVersion: rbac.authorization.k8s.io/v1alpha1 +{{- end }} +kind: ClusterRoleBinding +metadata: + labels: + app: {{ template "exporter-kube-state.name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + name: {{ template "exporter-kube-state.fullname" . }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ template "exporter-kube-state.fullname" . }} +subjects: + - kind: ServiceAccount + name: {{ template "exporter-kube-state.fullname" . }} + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/resources/core/charts/monitoring/charts/exporter-kube-state/templates/configmap.yaml b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/configmap.yaml new file mode 100644 index 000000000000..13144638cc6a --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/configmap.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + app: "prometheus" + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + prometheus: {{ .Release.Name }} + release: {{ .Release.Name }} + role: alert-rules + {{- if .Values.additionalRulesConfigMapLabels }} +{{ toYaml .Values.additionalRulesConfigMapLabels | indent 4 }} + {{- end }} + name: {{ template "exporter-kube-state.fullname" . }} +data: +{{- if .Values.prometheusRules }} +{{- $root := . }} +{{- range $key, $val := .Values.prometheusRules }} + {{ $key }}: |- +{{ $val | indent 4}} +{{- end }} +{{ else }} + kube-state-metrics.rules: |- + {{- include "kube-state-metrics.rules.yaml.tpl" . | indent 4}} +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/exporter-kube-state/templates/deployment.yaml b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/deployment.yaml new file mode 100644 index 000000000000..15ad46a5f4de --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/deployment.yaml @@ -0,0 +1,77 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: {{ template "exporter-kube-state.fullname" . }} + labels: + app: {{ template "exporter-kube-state.name" . }} + component: kube-state + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + version: "{{ .Values.kube_state_metrics.image.tag }}" +spec: + replicas: {{ .Values.replicaCount }} + revisionHistoryLimit: 10 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + selector: + matchLabels: + app: {{ template "exporter-kube-state.fullname" . }} + template: + metadata: + labels: + app: {{ template "exporter-kube-state.fullname" . }} + component: kube-state + release: {{ .Release.Name }} + version: "{{ .Values.kube_state_metrics.image.tag }}" + spec: + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.kube_state_metrics.image.repository }}:{{ .Values.kube_state_metrics.image.tag }}" + imagePullPolicy: {{ .Values.kube_state_metrics.image.pullPolicy }} + ports: + - containerPort: {{ .Values.kube_state_metrics.service.internalPort }} + protocol: TCP + livenessProbe: + httpGet: + path: / + port: {{ .Values.kube_state_metrics.service.internalPort }} + initialDelaySeconds: 30 + timeoutSeconds: 30 + readinessProbe: + httpGet: + path: / + port: {{ .Values.kube_state_metrics.service.internalPort }} + initialDelaySeconds: 30 + timeoutSeconds: 5 + - name: {{ .Chart.Name }}-addon-resizer + image: "{{ .Values.addon_resizer.image.repository }}:{{ .Values.addon_resizer.image.tag }}" + env: + - name: MY_POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: MY_POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + command: + - /pod_nanny + - --container={{ .Chart.Name }} + - --cpu={{ .Values.addon_resizer.cpu }} + - --extra-cpu={{ .Values.addon_resizer.extra_cpu }} + - --memory={{ .Values.addon_resizer.memory }} + - --extra-memory={{ .Values.addon_resizer.extra_memory }} + - --threshold=5 + - --deployment={{ template "exporter-kube-state.fullname" . }} + resources: +{{ toYaml .Values.addon_resizer.resources | indent 12 }} + {{- if .Values.global.rbacEnable }} + serviceAccountName: {{ template "exporter-kube-state.fullname" . }} + {{- end }} + {{- if .Values.kube_state_metrics.tolerations }} + tolerations: + {{ toYaml .Values.kube_state_metrics.tolerations | indent 4 }} + {{- end }} diff --git a/resources/core/charts/monitoring/charts/exporter-kube-state/templates/kube-state-metrics.rules.yaml b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/kube-state-metrics.rules.yaml new file mode 100644 index 000000000000..1feb2ffbe04d --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/kube-state-metrics.rules.yaml @@ -0,0 +1,61 @@ +{{ define "kube-state-metrics.rules.yaml.tpl" }} +groups: +- name: kube-state-metrics.rules + rules: + - alert: DeploymentGenerationMismatch + expr: kube_deployment_status_observed_generation != kube_deployment_metadata_generation + for: 15m + labels: + severity: warning + annotations: + description: Observed deployment generation does not match expected one for + deployment {{`{{$labels.namespaces}}`}}/{{`{{$labels.deployment}}`}} + summary: Deployment is outdated + - alert: DeploymentReplicasNotUpdated + expr: ((kube_deployment_status_replicas_updated != kube_deployment_spec_replicas) + or (kube_deployment_status_replicas_available != kube_deployment_spec_replicas)) + unless (kube_deployment_spec_paused == 1) + for: 15m + labels: + severity: warning + annotations: + description: Replicas are not updated and available for deployment {{`{{$labels.namespaces}}`}}/{{`{{$labels.deployment}}`}} + summary: Deployment replicas are outdated + - alert: DaemonSetRolloutStuck + expr: kube_daemonset_status_number_ready / kube_daemonset_status_desired_number_scheduled + * 100 < 100 + for: 15m + labels: + severity: warning + annotations: + description: Only {{`{{$value}}`}}% of desired pods scheduled and ready for daemon + set {{`{{$labels.namespaces}}`}}/{{`{{$labels.daemonset}}`}} + summary: DaemonSet is missing pods + - alert: K8SDaemonSetsNotScheduled + expr: kube_daemonset_status_desired_number_scheduled - kube_daemonset_status_current_number_scheduled + > 0 + for: 10m + labels: + severity: warning + annotations: + description: A number of daemonsets are not scheduled. + summary: Daemonsets are not scheduled correctly + - alert: DaemonSetsMissScheduled + expr: kube_daemonset_status_number_misscheduled > 0 + for: 10m + labels: + severity: warning + annotations: + description: A number of daemonsets are running where they are not supposed + to run. + summary: Daemonsets are not scheduled correctly + - alert: PodFrequentlyRestarting + expr: increase(kube_pod_container_status_restarts_total[1h]) > 5 + for: 10m + labels: + severity: warning + annotations: + description: Pod {{`{{$labels.namespaces}}`}}/{{`{{$labels.pod}}`}} is was restarted {{`{{$value}}`}} + times within the last hour + summary: Pod is restarting frequently +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/exporter-kube-state/templates/role.yaml b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/role.yaml new file mode 100644 index 000000000000..629bfce780d3 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/role.yaml @@ -0,0 +1,23 @@ +{{- if .Values.global.rbacEnable }} +{{- if .Capabilities.APIVersions.Has "rbac.authorization.k8s.io/v1beta1" }} +apiVersion: rbac.authorization.k8s.io/v1beta1 +{{- else if .Capabilities.APIVersions.Has "rbac.authorization.k8s.io/v1alpha1" }} +apiVersion: rbac.authorization.k8s.io/v1alpha1 +{{- end }} +kind: Role +metadata: + labels: + app: {{ template "exporter-kube-state.name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + name: {{ template "exporter-kube-state.fullname" . }} +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get"] +- apiGroups: ["extensions"] + resources: ["deployments"] + resourceNames: [{{ template "exporter-kube-state.fullname" . }}] + verbs: ["get", "update"] +{{- end }} diff --git a/resources/core/charts/monitoring/charts/exporter-kube-state/templates/rolebinding.yaml b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/rolebinding.yaml new file mode 100644 index 000000000000..10e07365c10c --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/rolebinding.yaml @@ -0,0 +1,22 @@ +{{- if .Values.global.rbacEnable }} +{{- if .Capabilities.APIVersions.Has "rbac.authorization.k8s.io/v1beta1" }} +apiVersion: rbac.authorization.k8s.io/v1beta1 +{{- else if .Capabilities.APIVersions.Has "rbac.authorization.k8s.io/v1alpha1" }} +apiVersion: rbac.authorization.k8s.io/v1alpha1 +{{- end }} +kind: RoleBinding +metadata: + labels: + app: {{ template "exporter-kube-state.name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + name: {{ template "exporter-kube-state.fullname" . }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ template "exporter-kube-state.fullname" . }} +subjects: +- kind: ServiceAccount + name: {{ template "exporter-kube-state.fullname" . }} +{{- end }} diff --git a/resources/core/charts/monitoring/charts/exporter-kube-state/templates/service.yaml b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/service.yaml new file mode 100644 index 000000000000..5fbdb0ccb62f --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/service.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ template "exporter-kube-state.fullname" . }} + labels: + app: {{ template "exporter-kube-state.name" . }} + component: kube-state + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +spec: + type: {{ .Values.kube_state_metrics.service.type }} + ports: + - port: {{ .Values.kube_state_metrics.service.externalPort }} + targetPort: {{ .Values.kube_state_metrics.service.internalPort }} + protocol: TCP + name: {{ .Values.kube_state_metrics.service.name }} + selector: + app: {{ template "exporter-kube-state.fullname" . }} + component: kube-state + release: {{ .Release.Name }} diff --git a/resources/core/charts/monitoring/charts/exporter-kube-state/templates/serviceaccount.yaml b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/serviceaccount.yaml new file mode 100644 index 000000000000..e22fceab334e --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/serviceaccount.yaml @@ -0,0 +1,11 @@ +{{- if .Values.global.rbacEnable }} +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + app: {{ template "exporter-kube-state.name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + name: {{ template "exporter-kube-state.fullname" . }} +{{- end }} diff --git a/resources/core/charts/monitoring/charts/exporter-kube-state/templates/servicemonitor.yaml b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/servicemonitor.yaml new file mode 100644 index 000000000000..89ed3ec52463 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-state/templates/servicemonitor.yaml @@ -0,0 +1,26 @@ +apiVersion: {{ template "prometheus-operator.apiVersion" . }} +kind: ServiceMonitor +metadata: + labels: + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + component: kube-state + heritage: "{{ .Release.Service }}" + release: "{{ .Release.Name }}" + prometheus: {{ .Release.Name }} + {{- if .Values.additionalServiceMonitorLabels }} +{{ toYaml .Values.additionalServiceMonitorLabels | indent 4 }} + {{- end }} + name: {{ template "exporter-kube-state.fullname" . }} +spec: + jobLabel: component + selector: + matchLabels: + app: {{ template "exporter-kube-state.name" . }} + component: kube-state + namespaceSelector: + matchNames: + - {{ .Release.Namespace | quote }} + endpoints: + - port: {{ .Values.kube_state_metrics.service.name }} + interval: 30s + honorLabels: true diff --git a/resources/core/charts/monitoring/charts/exporter-kube-state/values.yaml b/resources/core/charts/monitoring/charts/exporter-kube-state/values.yaml new file mode 100644 index 000000000000..b02b4f4f4e87 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kube-state/values.yaml @@ -0,0 +1,51 @@ +# Default values for kube-state-metrics. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +replicaCount: 1 + +## If true, create & use RBAC resources +## +global: + rbacEnable: true + +kube_state_metrics: + image: + repository: gcr.io/google_containers/kube-state-metrics + tag: v1.2.0 + pullPolicy: IfNotPresent + service: + name: kube-state-metrics + type: ClusterIP + externalPort: 80 + internalPort: 8080 +addon_resizer: + memory: 130Mi + extra_memory: 2Mi + cpu: 100m + extra_cpu: 1m + image: + repository: gcr.io/google_containers/addon-resizer + tag: "1.7" + pullPolicy: IfNotPresent + resources: + requests: + memory: 30Mi + +# default rules are in templates/kube-state-metrics.rules.yaml +# prometheusRules: {} + +## Tolerations for use with node taints +## Ref: https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/ +## +tolerations: {} + # - key: "key" + # operator: "Equal" + # value: "value" + # effect: "NoSchedule" + +## Custom Labels to be added to ServiceMonitor +## +additionalServiceMonitorLabels: {} +##Custom Labels to be added to Prometheus Rules ConfigMap +## +additionalRulesConfigMapLabels: {} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/exporter-kubelets/.helmignore b/resources/core/charts/monitoring/charts/exporter-kubelets/.helmignore new file mode 100755 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kubelets/.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/resources/core/charts/monitoring/charts/exporter-kubelets/Chart.yaml b/resources/core/charts/monitoring/charts/exporter-kubelets/Chart.yaml new file mode 100755 index 000000000000..a79b8af918cf --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kubelets/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +name: exporter-kubelets +description: Exporter for kubelets +version: 0.2.8 +keywords: +- monitoring +- kyma diff --git a/resources/core/charts/monitoring/charts/exporter-kubelets/templates/_helpers.tpl b/resources/core/charts/monitoring/charts/exporter-kubelets/templates/_helpers.tpl new file mode 100755 index 000000000000..3c6a9dfc0b41 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kubelets/templates/_helpers.tpl @@ -0,0 +1,36 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "exporter-kubelets.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 "exporter-kubelets.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 -}} + +{{/* +Return the appropriate apiVersion value to use for the prometheus-operator managed k8s resources +*/}} +{{- define "prometheus-operator.apiVersion" -}} +{{- if .Capabilities.APIVersions.Has "monitoring.coreos.com/v1" }} +{{- printf "%s" "monitoring.coreos.com/v1" -}} +{{- else -}} +{{- printf "%s" "monitoring.coreos.com/v1alpha1" -}} +{{- end -}} +{{- end -}} diff --git a/resources/core/charts/monitoring/charts/exporter-kubelets/templates/configmap.yaml b/resources/core/charts/monitoring/charts/exporter-kubelets/templates/configmap.yaml new file mode 100644 index 000000000000..9c18565ae5bb --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kubelets/templates/configmap.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + app: "prometheus" + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + prometheus: {{ .Release.Name }} + release: {{ .Release.Name }} + role: alert-rules + {{- if .Values.additionalRulesConfigMapLabels }} +{{ toYaml .Values.additionalRulesConfigMapLabels | indent 4 }} + {{- end }} + name: {{ template "exporter-kubelets.fullname" . }} +data: +{{- if .Values.prometheusRules }} +{{- $root := . }} +{{- range $key, $val := .Values.prometheusRules }} + {{ $key }}: |- +{{ $val | indent 4}} +{{- end }} +{{ else }} + kubelet.rules: |- + {{- include "kubelet.rules.yaml.tpl" . | indent 4}} +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/exporter-kubelets/templates/kubelet.rules.yaml b/resources/core/charts/monitoring/charts/exporter-kubelets/templates/kubelet.rules.yaml new file mode 100644 index 000000000000..4181d1961b03 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kubelets/templates/kubelet.rules.yaml @@ -0,0 +1,50 @@ +{{ define "kubelet.rules.yaml.tpl" }} +groups: +- name: kubelet.rules + rules: + - alert: K8SNodeNotReady + expr: kube_node_status_condition{condition="Ready",status="true"} == 0 + for: 1h + labels: + severity: warning + annotations: + description: The Kubelet on {{`{{ $labels.node }}`}} has not checked in with the API, + or has set itself to NotReady, for more than an hour + summary: Node status is NotReady + - alert: K8SManyNodesNotReady + expr: count(kube_node_status_condition{condition="Ready",status="true"} == 0) + > 1 and (count(kube_node_status_condition{condition="Ready",status="true"} == + 0) / count(kube_node_status_condition{condition="Ready",status="true"})) > 0.2 + for: 1m + labels: + severity: critical + annotations: + description: '{{`{{ $value }}`}}% of Kubernetes nodes are not ready' + - alert: K8SKubeletDown + expr: count(up{job="kubelet"} == 0) / count(up{job="kubelet"}) * 100 > 3 + for: 1h + labels: + severity: warning + annotations: + description: Prometheus failed to scrape {{`{{ $value }}`}}% of kubelets. + summary: Prometheus failed to scrape + - alert: K8SKubeletDown + expr: (absent(up{job="kubelet"} == 1) or count(up{job="kubelet"} == 0) / count(up{job="kubelet"})) + * 100 > 10 + for: 1h + labels: + severity: critical + annotations: + description: Prometheus failed to scrape {{`{{ $value }}`}}% of kubelets, or all Kubelets + have disappeared from service discovery. + summary: Many Kubelets cannot be scraped + - alert: K8SKubeletTooManyPods + expr: kubelet_running_pod_count > 100 + for: 10m + labels: + severity: warning + annotations: + description: Kubelet {{`{{$labels.instance}}`}} is running {{`{{$value}}`}} pods, close + to the limit of 110 + summary: Kubelet is close to pod limit +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/exporter-kubelets/templates/servicemonitor.yaml b/resources/core/charts/monitoring/charts/exporter-kubelets/templates/servicemonitor.yaml new file mode 100644 index 000000000000..bdf72d2c0ee1 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kubelets/templates/servicemonitor.yaml @@ -0,0 +1,50 @@ +apiVersion: {{ template "prometheus-operator.apiVersion" . }} +kind: ServiceMonitor +metadata: + labels: + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + component: kubelets + heritage: "{{ .Release.Service }}" + release: "{{ .Release.Name }}" + prometheus: {{ .Release.Name }} + {{- if .Values.additionalServiceMonitorLabels }} +{{ toYaml .Values.additionalServiceMonitorLabels | indent 4 }} + {{- end }} + name: {{ template "exporter-kubelets.fullname" . }} +spec: + jobLabel: component + selector: + matchLabels: + k8s-app: kubelet + namespaceSelector: + matchNames: + - "kube-system" + endpoints: + {{- if .Values.https }} + - port: https-metrics + scheme: https + interval: 30s + tlsConfig: + caFile: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt + {{- if .Values.insecureSkipVerify }} + insecureSkipVerify: true + {{- end }} + bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token + - port: https-metrics + scheme: https + path: /metrics/cadvisor + interval: 30s + honorLabels: true + tlsConfig: + caFile: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt + {{- if .Values.insecureSkipVerify }} + insecureSkipVerify: true + {{- end }} + bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token + {{- else }} + - port: http-metrics + interval: 30s + - port: cadvisor + interval: 30s + honorLabels: true + {{- end }} diff --git a/resources/core/charts/monitoring/charts/exporter-kubelets/values.yaml b/resources/core/charts/monitoring/charts/exporter-kubelets/values.yaml new file mode 100755 index 000000000000..8de5b1e4df5c --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kubelets/values.yaml @@ -0,0 +1,14 @@ +# Set to false for GKE +https: true + +insecureSkipVerify: true + +# default rules are in templates/kubelet.rules.yaml +# prometheusRules: {} + +## Custom Labels to be added to ServiceMonitor +## +additionalServiceMonitorLabels: {} +##Custom Labels to be added to Prometheus Rules ConfigMap +## +additionalRulesConfigMapLabels: {} diff --git a/resources/core/charts/monitoring/charts/exporter-kubernetes/.helmignore b/resources/core/charts/monitoring/charts/exporter-kubernetes/.helmignore new file mode 100755 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kubernetes/.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/resources/core/charts/monitoring/charts/exporter-kubernetes/Chart.yaml b/resources/core/charts/monitoring/charts/exporter-kubernetes/Chart.yaml new file mode 100755 index 000000000000..cef05327071c --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kubernetes/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +name: exporter-kubernetes +description: Exporter for kubernetes +version: 0.1.8 +keywords: +- monitoring +- kyma diff --git a/resources/core/charts/monitoring/charts/exporter-kubernetes/templates/_helpers.tpl b/resources/core/charts/monitoring/charts/exporter-kubernetes/templates/_helpers.tpl new file mode 100755 index 000000000000..23591ff514ce --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kubernetes/templates/_helpers.tpl @@ -0,0 +1,36 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "exporter-kubernetes.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 "exporter-kubernetes.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 -}} + +{{/* +Return the appropriate apiVersion value to use for the prometheus-operator managed k8s resources +*/}} +{{- define "prometheus-operator.apiVersion" -}} +{{- if .Capabilities.APIVersions.Has "monitoring.coreos.com/v1" }} +{{- printf "%s" "monitoring.coreos.com/v1" -}} +{{- else -}} +{{- printf "%s" "monitoring.coreos.com/v1alpha1" -}} +{{- end -}} +{{- end -}} diff --git a/resources/core/charts/monitoring/charts/exporter-kubernetes/templates/configmap.yaml b/resources/core/charts/monitoring/charts/exporter-kubernetes/templates/configmap.yaml new file mode 100644 index 000000000000..c77bf05386cc --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kubernetes/templates/configmap.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + app: "prometheus" + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + prometheus: {{ .Release.Name }} + release: {{ .Release.Name }} + role: alert-rules + {{- if .Values.additionalRulesConfigMapLabels }} +{{ toYaml .Values.additionalRulesConfigMapLabels | indent 4 }} + {{- end }} + name: {{ template "exporter-kubernetes.fullname" . }} +data: +{{- if .Values.prometheusRules }} +{{- $root := . }} +{{- range $key, $val := .Values.prometheusRules }} + {{ $key }}: |- +{{ $val | indent 4}} +{{- end }} +{{ else }} + kubernetes.rules: |- + {{- include "kubernetes.rules.yaml.tpl" . | indent 4}} +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/exporter-kubernetes/templates/kubernetes.rules.yaml b/resources/core/charts/monitoring/charts/exporter-kubernetes/templates/kubernetes.rules.yaml new file mode 100644 index 000000000000..67df03c57a23 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kubernetes/templates/kubernetes.rules.yaml @@ -0,0 +1,108 @@ +{{ define "kubernetes.rules.yaml.tpl" }} +groups: +- name: kubernetes.rules + rules: + - record: pod_name:container_memory_usage_bytes:sum + expr: sum(container_memory_usage_bytes{container_name!="POD",pod_name!=""}) BY + (pod_name) + - record: pod_name:container_spec_cpu_shares:sum + expr: sum(container_spec_cpu_shares{container_name!="POD",pod_name!=""}) BY (pod_name) + - record: pod_name:container_cpu_usage:sum + expr: sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name!=""}[5m])) + BY (pod_name) + - record: pod_name:container_fs_usage_bytes:sum + expr: sum(container_fs_usage_bytes{container_name!="POD",pod_name!=""}) BY (pod_name) + - record: namespace:container_memory_usage_bytes:sum + expr: sum(container_memory_usage_bytes{container_name!=""}) BY (namespace) + - record: namespace:container_spec_cpu_shares:sum + expr: sum(container_spec_cpu_shares{container_name!=""}) BY (namespace) + - record: namespace:container_cpu_usage:sum + expr: sum(rate(container_cpu_usage_seconds_total{container_name!="POD"}[5m])) + BY (namespace) + - record: cluster:memory_usage:ratio + expr: sum(container_memory_usage_bytes{container_name!="POD",pod_name!=""}) BY + (cluster) / sum(machine_memory_bytes) BY (cluster) + - record: cluster:container_spec_cpu_shares:ratio + expr: sum(container_spec_cpu_shares{container_name!="POD",pod_name!=""}) / 1000 + / sum(machine_cpu_cores) + - record: cluster:container_cpu_usage:ratio + expr: sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name!=""}[5m])) + / sum(machine_cpu_cores) + - record: apiserver_latency_seconds:quantile + expr: histogram_quantile(0.99, rate(apiserver_request_latencies_bucket[5m])) / + 1e+06 + labels: + quantile: "0.99" + - record: apiserver_latency:quantile_seconds + expr: histogram_quantile(0.9, rate(apiserver_request_latencies_bucket[5m])) / + 1e+06 + labels: + quantile: "0.9" + - record: apiserver_latency_seconds:quantile + expr: histogram_quantile(0.5, rate(apiserver_request_latencies_bucket[5m])) / + 1e+06 + labels: + quantile: "0.5" + - alert: APIServerLatencyHigh + expr: apiserver_latency_seconds:quantile{quantile="0.99",subresource!="log",verb!~"^(?:WATCH|WATCHLIST|PROXY|CONNECT)$"} + > 1 + for: 10m + labels: + severity: warning + annotations: + description: the API server has a 99th percentile latency of {{`{{ $value }}`}} seconds + for {{`{{$labels.verb}}`}} {{`{{$labels.resource}}`}} + summary: API server high latency + - alert: APIServerLatencyHigh + expr: apiserver_latency_seconds:quantile{quantile="0.99",subresource!="log",verb!~"^(?:WATCH|WATCHLIST|PROXY|CONNECT)$"} + > 4 + for: 10m + labels: + severity: critical + annotations: + description: the API server has a 99th percentile latency of {{`{{ $value }}`}} seconds + for {{`{{$labels.verb}}`}} {{`{{$labels.resource}}`}} + summary: API server high latency + - alert: APIServerErrorsHigh + expr: rate(apiserver_request_count{code=~"^(?:5..)$"}[5m]) / rate(apiserver_request_count[5m]) + * 100 > 2 + for: 10m + labels: + severity: warning + annotations: + description: API server returns errors for {{`{{ $value }}`}}% of requests + summary: API server request errors + - alert: APIServerErrorsHigh + expr: rate(apiserver_request_count{code=~"^(?:5..)$"}[5m]) / rate(apiserver_request_count[5m]) + * 100 > 5 + for: 10m + labels: + severity: critical + annotations: + description: API server returns errors for {{`{{ $value }}`}}% of requests + - alert: K8SApiserverDown + expr: absent(up{job="apiserver"} == 1) + for: 20m + labels: + severity: critical + annotations: + description: No API servers are reachable or all have disappeared from service + discovery + summary: No API servers are reachable + + - alert: K8sCertificateExpirationNotice + labels: + severity: warning + annotations: + description: Kubernetes API Certificate is expiring soon (less than 7 days) + summary: Kubernetes API Certificate is expiering soon + expr: sum(apiserver_client_certificate_expiration_seconds_bucket{le="604800"}) > 0 + + - alert: K8sCertificateExpirationNotice + labels: + severity: critical + annotations: + description: Kubernetes API Certificate is expiring in less than 1 day + summary: Kubernetes API Certificate is expiering + expr: sum(apiserver_client_certificate_expiration_seconds_bucket{le="86400"}) > 0 +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/exporter-kubernetes/templates/servicemonitor.yaml b/resources/core/charts/monitoring/charts/exporter-kubernetes/templates/servicemonitor.yaml new file mode 100644 index 000000000000..3f79eb0cccb6 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kubernetes/templates/servicemonitor.yaml @@ -0,0 +1,32 @@ +apiVersion: {{ template "prometheus-operator.apiVersion" . }} +kind: ServiceMonitor +metadata: + labels: + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + component: kubelets + heritage: "{{ .Release.Service }}" + release: "{{ .Release.Name }}" + prometheus: {{ .Release.Name }} + {{- if .Values.additionalServiceMonitorLabels }} +{{ toYaml .Values.additionalServiceMonitorLabels | indent 4 }} + {{- end }} + name: {{ template "exporter-kubernetes.fullname" . }} +spec: + jobLabel: component + selector: + matchLabels: + component: apiserver + provider: kubernetes + namespaceSelector: + matchNames: + - "default" + endpoints: + - port: https + interval: 30s + scheme: https + tlsConfig: + caFile: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt + # Skip verification until we have resolved why the certificate validation + # for the kubelet on API server nodes fail. + insecureSkipVerify: true + bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token diff --git a/resources/core/charts/monitoring/charts/exporter-kubernetes/values.yaml b/resources/core/charts/monitoring/charts/exporter-kubernetes/values.yaml new file mode 100755 index 000000000000..32ec7719640d --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-kubernetes/values.yaml @@ -0,0 +1,9 @@ +# default rules are in templates/kubernetes.rules.yaml +# prometheusRules: {} + +## Custom Labels to be added to ServiceMonitor +## +additionalServiceMonitorLabels: {} +##Custom Labels to be added to Prometheus Rules ConfigMap +## +additionalRulesConfigMapLabels: {} diff --git a/resources/core/charts/monitoring/charts/exporter-node/.helmignore b/resources/core/charts/monitoring/charts/exporter-node/.helmignore new file mode 100755 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-node/.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/resources/core/charts/monitoring/charts/exporter-node/Chart.yaml b/resources/core/charts/monitoring/charts/exporter-node/Chart.yaml new file mode 100755 index 000000000000..52a134fbfc9d --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-node/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +name: exporter-node +description: Exporter for node +version: 0.2.2 +keywords: +- monitoring +- kyma diff --git a/resources/core/charts/monitoring/charts/exporter-node/templates/_helpers.tpl b/resources/core/charts/monitoring/charts/exporter-node/templates/_helpers.tpl new file mode 100755 index 000000000000..7b5654a037b3 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-node/templates/_helpers.tpl @@ -0,0 +1,36 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "exporter-node.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 "exporter-node.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 -}} + +{{/* +Return the appropriate apiVersion value to use for the prometheus-operator managed k8s resources +*/}} +{{- define "prometheus-operator.apiVersion" -}} +{{- if .Capabilities.APIVersions.Has "monitoring.coreos.com/v1" }} +{{- printf "%s" "monitoring.coreos.com/v1" -}} +{{- else -}} +{{- printf "%s" "monitoring.coreos.com/v1alpha1" -}} +{{- end -}} +{{- end -}} diff --git a/resources/core/charts/monitoring/charts/exporter-node/templates/configmap.yaml b/resources/core/charts/monitoring/charts/exporter-node/templates/configmap.yaml new file mode 100644 index 000000000000..33920eab9054 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-node/templates/configmap.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + app: "prometheus" + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + prometheus: {{ .Release.Name }} + release: {{ .Release.Name }} + role: alert-rules + {{- if .Values.additionalRulesConfigMapLabels }} +{{ toYaml .Values.additionalRulesConfigMapLabels | indent 4 }} + {{- end }} + name: {{ template "exporter-node.fullname" . }} +data: +{{- if .Values.prometheusRules }} +{{- $root := . }} +{{- range $key, $val := .Values.prometheusRules }} + {{ $key }}: |- +{{ $val | indent 4}} +{{- end }} +{{ else }} + node.rules: |- + {{- include "node.rules.yaml.tpl" . | indent 4}} +{{ end }} diff --git a/resources/core/charts/monitoring/charts/exporter-node/templates/daemonset.yaml b/resources/core/charts/monitoring/charts/exporter-node/templates/daemonset.yaml new file mode 100755 index 000000000000..abcb84daaf36 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-node/templates/daemonset.yaml @@ -0,0 +1,51 @@ +apiVersion: extensions/v1beta1 +kind: DaemonSet +metadata: + labels: + app: {{ template "exporter-node.name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + component: node-exporter + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + name: {{ template "exporter-node.fullname" . }} +spec: + updateStrategy: + rollingUpdate: + maxUnavailable: 1 + type: RollingUpdate + template: + metadata: + labels: + app: {{ template "exporter-node.fullname" . }} + component: node-exporter + release: {{ .Release.Name }} + spec: + containers: + - name: node-exporter + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: "{{ .Values.image.pullPolicy }}" + args: + - --web.listen-address=0.0.0.0:{{ .Values.service.containerPort }} + {{- if and .Values.container .Values.container.args }} +{{ toYaml .Values.container.args | indent 10 }} + {{- end }} + ports: + - name: metrics + containerPort: {{ .Values.service.containerPort }} + hostPort: {{ .Values.service.containerPort }} + resources: +{{ toYaml .Values.resources | indent 12 }} + {{- if and .Values.container .Values.container.volumeMounts }} + volumeMounts: +{{ toYaml .Values.container.volumeMounts | indent 10 }} + {{- end }} + {{- if .Values.tolerations }} + tolerations: +{{ toYaml .Values.tolerations | indent 8 }} + {{- end }} + hostNetwork: true + hostPID: true + {{- if and .Values.container .Values.container.volumes }} + volumes: +{{ toYaml .Values.container.volumes | indent 6 }} + {{- end}} diff --git a/resources/core/charts/monitoring/charts/exporter-node/templates/node.rules.yaml b/resources/core/charts/monitoring/charts/exporter-node/templates/node.rules.yaml new file mode 100644 index 000000000000..c2ea54acf3a1 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-node/templates/node.rules.yaml @@ -0,0 +1,49 @@ +{{ define "node.rules.yaml.tpl" }} +groups: +- name: node.rules + rules: + - record: instance:node_cpu:rate:sum + expr: sum(rate(node_cpu{mode!="idle",mode!="iowait",mode!~"^(?:guest.*)$"}[3m])) + BY (instance) + - record: instance:node_filesystem_usage:sum + expr: sum((node_filesystem_size{mountpoint="/"} - node_filesystem_free{mountpoint="/"})) + BY (instance) + - record: instance:node_network_receive_bytes:rate:sum + expr: sum(rate(node_network_receive_bytes[3m])) BY (instance) + - record: instance:node_network_transmit_bytes:rate:sum + expr: sum(rate(node_network_transmit_bytes[3m])) BY (instance) + - record: instance:node_cpu:ratio + expr: sum(rate(node_cpu{mode!="idle"}[5m])) WITHOUT (cpu, mode) / ON(instance) + GROUP_LEFT() count(sum(node_cpu) BY (instance, cpu)) BY (instance) + - record: cluster:node_cpu:sum_rate5m + expr: sum(rate(node_cpu{mode!="idle"}[5m])) + - record: cluster:node_cpu:ratio + expr: cluster:node_cpu:rate5m / count(sum(node_cpu) BY (instance, cpu)) + - alert: NodeExporterDown + expr: absent(up{job="node-exporter"} == 1) + for: 10m + labels: + severity: warning + annotations: + description: Prometheus could not scrape a node-exporter for more than 10m, + or node-exporters have disappeared from discovery + summary: Prometheus could not scrape a node-exporter + - alert: NodeDiskRunningFull + expr: predict_linear(node_filesystem_free[6h], 3600 * 24) < 0 + for: 30m + labels: + severity: warning + annotations: + description: device {{`{{$labels.device}}`}} on node {{`{{$labels.instance}}`}} is running + full within the next 24 hours (mounted at {{`{{$labels.mountpoint}}`}}) + summary: Node disk is running full within 24 hours + - alert: NodeDiskRunningFull + expr: predict_linear(node_filesystem_free[30m], 3600 * 2) < 0 + for: 10m + labels: + severity: critical + annotations: + description: device {{`{{$labels.device}}`}} on node {{`{{$labels.instance}}`}} is running + full within the next 2 hours (mounted at {{`{{$labels.mountpoint}}`}}) + summary: Node disk is running full within 2 hours +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/exporter-node/templates/service.yaml b/resources/core/charts/monitoring/charts/exporter-node/templates/service.yaml new file mode 100755 index 000000000000..232e9969a7d3 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-node/templates/service.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ template "exporter-node.fullname" . }} + labels: + app: {{ template "exporter-node.name" . }} + component: node-exporter + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +spec: + type: {{ .Values.service.type }} + ports: + - name: metrics + port: {{ .Values.service.externalPort }} + targetPort: metrics + protocol: TCP + selector: + app: {{ template "exporter-node.fullname" . }} + component: node-exporter + release: {{ .Release.Name }} diff --git a/resources/core/charts/monitoring/charts/exporter-node/templates/servicemonitor.yaml b/resources/core/charts/monitoring/charts/exporter-node/templates/servicemonitor.yaml new file mode 100644 index 000000000000..3c01c3945a06 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-node/templates/servicemonitor.yaml @@ -0,0 +1,25 @@ +apiVersion: {{ template "prometheus-operator.apiVersion" . }} +kind: ServiceMonitor +metadata: + labels: + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + component: node-exporter + heritage: "{{ .Release.Service }}" + release: "{{ .Release.Name }}" + prometheus: {{ .Release.Name }} + {{- if .Values.additionalServiceMonitorLabels }} +{{ toYaml .Values.additionalServiceMonitorLabels | indent 4 }} + {{- end }} + name: {{ template "exporter-node.fullname" . }} +spec: + jobLabel: component + selector: + matchLabels: + app: {{ template "exporter-node.name" . }} + component: node-exporter + namespaceSelector: + matchNames: + - {{ .Release.Namespace | quote }} + endpoints: + - port: metrics + interval: 30s diff --git a/resources/core/charts/monitoring/charts/exporter-node/values.yaml b/resources/core/charts/monitoring/charts/exporter-node/values.yaml new file mode 100755 index 000000000000..2857063e1f20 --- /dev/null +++ b/resources/core/charts/monitoring/charts/exporter-node/values.yaml @@ -0,0 +1,54 @@ +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +replicaCount: 1 +image: + repository: quay.io/prometheus/node-exporter + tag: v0.15.2 + pullPolicy: IfNotPresent +service: + type: ClusterIP + externalPort: 9100 + containerPort: 9100 + +resources: + requests: + memory: 30Mi + +container: + args: + - --path.procfs=/host/proc + - --path.sysfs=/host/sys + + volumes: + - name: proc + hostPath: + path: /proc + - name: sys + hostPath: + path: /sys + + volumeMounts: + - name: proc + mountPath: /host/proc + readOnly: true + - name: sys + mountPath: /host/sys + readOnly: true + +## Tolerations for use with node taints +## Ref: https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/ +## +tolerations: +- effect: NoSchedule + operator: Exists + + +# default rules are in templates/node.rules.yaml +# prometheusRules: {} + +## Custom Labels to be added to ServiceMonitor +## +additionalServiceMonitorLabels: {} +##Custom Labels to be added to Prometheus Rules ConfigMap +## +additionalRulesConfigMapLabels: {} diff --git a/resources/core/charts/monitoring/charts/grafana/Chart.yaml b/resources/core/charts/monitoring/charts/grafana/Chart.yaml new file mode 100755 index 000000000000..cb3c5e30ae31 --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +name: grafana +description: Grafana instance created by the CoreOS Prometheus Operator +engine: gotpl +version: 0.0.27 +keywords: +- monitoring +- kyma diff --git a/resources/core/charts/monitoring/charts/grafana/README.md b/resources/core/charts/monitoring/charts/grafana/README.md new file mode 100644 index 000000000000..cf4688ab65e5 --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/README.md @@ -0,0 +1,56 @@ +# Grafana in Kyma + +## Overview + +Kyma comes with a set of dashboards for monitoring Kubernetes clusters. These dashboards display metrics that the Prometheus server collects. + +In Kyma, you can find these dashboards under [dashboards](dashboards/). + +These are the available dashboards: + +* **Deployment** - Displays metrics on details such as memory, CPU, network and replicas for deployments running in different namespaces. Find the configuration of this dashboard in [this](dashboards/deployment-dashboard.json) file. +* **Istio** - Displays Istio metrics for services (HTTP and TCP) as well as the Service Mesh. Find the configuration of this dashboard in [this](dashboards/istio-dashboard.json) file. +* **Kubernetes Capacity Planning** - Displays the current memory usage, disk usage, system load, and other system status details. Find the configuration of this dashboard in [this](dashboards/kubernetes-capacity-planning-dashboard.json) file. +* **Kubernetes Cluster Health** - Displays the status of alerts, nodes, pods and control plan components. Find the configuration of this dashboard in [this](dashboards/kubernetes-cluster-health-dashboard.json) file. +* **Kubernetes Cluster Status** - Displays metrics on alerts, API servers, CPU utilitzation, schedulers, and more. Find the configuration of this dashboard in [this](dashboards/kubernetes-cluster-status-dashboard.json) file. +* **Kubernetes Control Plane Status** - Displays metrics on control planes. Find the configuration of this dashboard in [this](dashboards/kubernetes-control-plane-status-dashboard.json) file. +* **Kubernetes Resource Requests** - Displays details on CPU core and memory resource usage. Find the configuration of this dashboard in [this](dashboards/kubernetes-resource-requests-dashboard.json) file. +* **Mixer** -Displays metrics on incoming requests, response durations, connections, cluster membership, server error rate and more. Find the configuration of this dashboard in [this](dashboards/mixer-dashboard.json) file. +* **Nodes** - Displays information pertaining to Kubernetes nodes utilization. Find the configuration of this dashboard in [this](dashboards/nodes-dashboard.json) file. +* **Pilot** - Displays metrics on request latency, discovery calls and various cache types. Find the configuration of this dashboard in [this](dashboards/pilot-dashboard.json) file. +* **Pods** - Displays Pod metrics such as CPU and Memory. Find the configuration of this dashboard in [this](dashboards/pods-dashboard.json) file. +* **StatefulSet** - Displays Kubernetes StatefulSet metrics such as replica count, CPU and Memory. Find the configuration of this dashboard in [this](dashboards/statefulset-dashboard.json) file. + +## Add a dashboard to Kyma + +Grafana dashboards in Kyma are configured through a [ConfigMap](templates/dashboards-configmap.yaml). This dashboard consumes the data configuration of all the JSON files located in the [dashboards directory](dashboards/). + +```yaml +data: + ... + {{- if .Values.keepOriginalDashboards }} +{{ (.Files.Glob "dashboards/*.json").AsConfig | indent 2 }} + {{- end }} +``` + +To add a dashboard to Kyma: + +1. Create or modify the dashboard using the Grafana UI. +2. Export the dashboard to a JSON format file. Name the file following this convention: `{dashboard_name}-dashboard.json`. +4. Clone the Kyma [master branch](https://github.com/kyma-project/kyma). +5. Copy the JSON file to the directory **[dashboards](dashboards/)** of your local installation. +6. Install Kyma locally and open it in a browser at https://console.kyma.local. +7. Access the Grafana console from Kyma by clicking **Administration > Diagnostics > Status & Metrics** in the left navigation. +8. Sign in and check if the newly added dashboard is deployed. +9. Create a pull request following the [GitHub workflow](https://github.com/kyma-project/community/blob/master/github-flow.md) defined for Kyma. + +## Add a Custom Dashboard in Grafana + +Users can create their own **Grafana Dashboard** by using the Grafana UI as the dashboards are persisted even after the Pod restarts. + +1. Create or Modify a dashboard using Grafana UI +2. Save the dashboard with a new name. + +## Additional Resources + +There are several resources you can use to become more familiar with Grafana. The [Grafana Getting Started Guide](http://docs.grafana.org/guides/getting_started/) is an ideal starting point. Refer to the document [Export and Import Dashboards](http://docs.grafana.org/reference/export_import/) for a closer look at dashboards used to export and import data in Grafana. Grafana also provides in-depth documentation on the [Grafana Dashboard API](http://docs.grafana.org/http_api/dashboard/). diff --git a/resources/core/charts/monitoring/charts/grafana/dashboards/deployment-dashboard.json b/resources/core/charts/monitoring/charts/grafana/dashboards/deployment-dashboard.json new file mode 100644 index 000000000000..0206e1edf368 --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/dashboards/deployment-dashboard.json @@ -0,0 +1,727 @@ +{ + "__inputs": [ + { + "description": "", + "label": "prometheus", + "name": "DS_PROMETHEUS", + "pluginId": "prometheus", + "pluginName": "Prometheus", + "type": "datasource" + } + ], + "annotations": { + "list": [] + }, + "editable": true, + "graphTooltip": 1, + "hideControls": false, + "links": [], + "rows": [ + { + "collapse": false, + "height": "200px", + "panels": [ + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 8, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "cores", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 4, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": true + }, + "targets": [ + { + "expr": "sum(rate(container_cpu_usage_seconds_total{namespace=\"$deployment_namespace\",pod_name=~\"$deployment_name.*\"}[3m]))", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "title": "CPU", + "type": "singlestat", + "valueFontSize": "110%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 9, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "GB", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "80%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 4, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": true + }, + "targets": [ + { + "expr": "sum(container_memory_usage_bytes{namespace=\"$deployment_namespace\",pod_name=~\"$deployment_name.*\"}) / 1024^3", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "title": "Memory", + "type": "singlestat", + "valueFontSize": "110%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "Bps", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": false + }, + "id": 7, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 4, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": true + }, + "targets": [ + { + "expr": "sum(rate(container_network_transmit_bytes_total{namespace=\"$deployment_namespace\",pod_name=~\"$deployment_name.*\"}[3m])) + sum(rate(container_network_receive_bytes_total{namespace=\"$deployment_namespace\",pod_name=~\"$deployment_name.*\"}[3m]))", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "title": "Network", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + } + ], + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + }, + { + "collapse": false, + "height": "100px", + "panels": [ + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": false + }, + "id": 5, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "max(kube_deployment_spec_replicas{deployment=\"$deployment_name\",namespace=\"$deployment_namespace\"}) without (instance, pod)", + "intervalFactor": 2, + "metric": "kube_deployment_spec_replicas", + "refId": "A", + "step": 600 + } + ], + "title": "Desired Replicas", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 6, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "min(kube_deployment_status_replicas_available{deployment=\"$deployment_name\",namespace=\"$deployment_namespace\"}) without (instance, pod)", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "title": "Available Replicas", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 3, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "max(kube_deployment_status_observed_generation{deployment=\"$deployment_name\",namespace=\"$deployment_namespace\"}) without (instance, pod)", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "title": "Observed Generation", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 2, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "max(kube_deployment_metadata_generation{deployment=\"$deployment_name\",namespace=\"$deployment_namespace\"}) without (instance, pod)", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "title": "Metadata Generation", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + } + ], + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + }, + { + "collapse": false, + "height": "350px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 1, + "isNew": true, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 12, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "max(kube_deployment_status_replicas{deployment=\"$deployment_name\",namespace=\"$deployment_namespace\"}) without (instance, pod)", + "intervalFactor": 2, + "legendFormat": "current replicas", + "refId": "A", + "step": 30 + }, + { + "expr": "min(kube_deployment_status_replicas_available{deployment=\"$deployment_name\",namespace=\"$deployment_namespace\"}) without (instance, pod)", + "intervalFactor": 2, + "legendFormat": "available", + "refId": "B", + "step": 30 + }, + { + "expr": "max(kube_deployment_status_replicas_unavailable{deployment=\"$deployment_name\",namespace=\"$deployment_namespace\"}) without (instance, pod)", + "intervalFactor": 2, + "legendFormat": "unavailable", + "refId": "C", + "step": 30 + }, + { + "expr": "min(kube_deployment_status_replicas_updated{deployment=\"$deployment_name\",namespace=\"$deployment_namespace\"}) without (instance, pod)", + "intervalFactor": 2, + "legendFormat": "updated", + "refId": "D", + "step": 30 + }, + { + "expr": "max(kube_deployment_spec_replicas{deployment=\"$deployment_name\",namespace=\"$deployment_namespace\"}) without (instance, pod)", + "intervalFactor": 2, + "legendFormat": "desired", + "refId": "E", + "step": 30 + } + ], + "title": "Replicas", + "tooltip": { + "msResolution": true, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "none", + "label": "", + "logBase": 1, + "show": true + }, + { + "format": "short", + "label": "", + "logBase": 1, + "show": false + } + ] + } + ], + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + } + ], + "schemaVersion": 14, + "sharedCrosshair": false, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "allValue": ".*", + "current": {}, + "datasource": "Prometheus", + "hide": 0, + "includeAll": false, + "label": "Namespace", + "multi": false, + "name": "deployment_namespace", + "options": [], + "query": "label_values(kube_deployment_metadata_generation, namespace)", + "refresh": 1, + "regex": "", + "sort": 0, + "tagValuesQuery": null, + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "hide": 0, + "includeAll": false, + "label": "Deployment", + "multi": false, + "name": "deployment_name", + "options": [], + "query": "label_values(kube_deployment_metadata_generation{namespace=\"$deployment_namespace\"}, deployment)", + "refresh": 1, + "regex": "", + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "deployment", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "Deployment", + "version": 1 +} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/grafana/dashboards/istio-dashboard.json b/resources/core/charts/monitoring/charts/grafana/dashboards/istio-dashboard.json new file mode 100755 index 000000000000..07bafec369df --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/dashboards/istio-dashboard.json @@ -0,0 +1,1545 @@ +{ + "__requires": [{ + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "5.0.0-beta2" + }, + { + "type": "panel", + "id": "graph", + "name": "Graph", + "version": "" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "singlestat", + "name": "Singlestat", + "version": "" + }, + { + "type": "panel", + "id": "text", + "name": "Text", + "version": "" + } + ], + "annotations": { + "list": [{ + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + }] + }, + "editable": false, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "iteration": 1518735681119, + "links": [{ + "icon": "external link", + "tags": [], + "title": "istio.io", + "tooltip": "Istio Home", + "type": "link", + "url": "https://istio.io/" + }], + "panels": [{ + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 50, + "panels": [], + "repeat": null, + "title": "Row title", + "type": "row" + }, + { + "content": "
      \n
      \n Istio\n
      \n
      \n Istio is an open platform that provides a uniform way to connect,\n manage, and \n secure microservices.\n
      \n Need help? Join the Istio community.\n
      \n
      ", + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 1 + }, + "height": "50px", + "id": 13, + "links": [], + "mode": "html", + "style": { + "font-size": "18pt" + }, + "title": "", + "transparent": true, + "type": "text" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 4 + }, + "id": 51, + "panels": [], + "repeat": null, + "title": "Dashboard Row", + "type": "row" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "format": "ops", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 3, + "w": 6, + "x": 0, + "y": 5 + }, + "id": 20, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [{ + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [{ + "from": "null", + "text": "N/A", + "to": "null" + }], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": true, + "lineColor": "rgb(31, 120, 193)", + "show": true + }, + "tableColumn": "", + "targets": [{ + "expr": "round(sum(irate(istio_request_count[1m])), 0.001)", + "intervalFactor": 1, + "refId": "A", + "step": 4 + }], + "thresholds": "", + "title": "Global Request Volume", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [{ + "op": "=", + "text": "N/A", + "value": "null" + }], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "format": "percentunit", + "gauge": { + "maxValue": 100, + "minValue": 80, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": false + }, + "gridPos": { + "h": 3, + "w": 6, + "x": 6, + "y": 5 + }, + "id": 21, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [{ + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [{ + "from": "null", + "text": "N/A", + "to": "null" + }], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": true, + "lineColor": "rgb(31, 120, 193)", + "show": true + }, + "tableColumn": "", + "targets": [{ + "expr": "sum(rate(istio_request_count{response_code!~\"5.*\"}[1m])) / sum(rate(istio_request_count[1m]))", + "intervalFactor": 1, + "refId": "A", + "step": 4 + }], + "thresholds": "95, 99, 99.5", + "title": "Global Success Rate (non-5xx responses)", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [{ + "op": "=", + "text": "N/A", + "value": "null" + }], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "format": "ops", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 3, + "w": 6, + "x": 12, + "y": 5 + }, + "id": 22, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [{ + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [{ + "from": "null", + "text": "N/A", + "to": "null" + }], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": true, + "lineColor": "rgb(31, 120, 193)", + "show": true + }, + "tableColumn": "", + "targets": [{ + "expr": "sum(irate(istio_request_count{response_code=~\"4.*\"}[1m])) ", + "intervalFactor": 1, + "refId": "A", + "step": 4 + }], + "thresholds": "", + "title": "4xxs", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [{ + "op": "=", + "text": "N/A", + "value": "null" + }], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "format": "ops", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 3, + "w": 6, + "x": 18, + "y": 5 + }, + "id": 23, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [{ + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [{ + "from": "null", + "text": "N/A", + "to": "null" + }], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": true, + "lineColor": "rgb(31, 120, 193)", + "show": true + }, + "tableColumn": "", + "targets": [{ + "expr": "sum(irate(istio_request_count{response_code=~\"5.*\"}[1m])) ", + "intervalFactor": 1, + "refId": "A", + "step": 4 + }], + "thresholds": "", + "title": "5xxs", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [{ + "op": "=", + "text": "N/A", + "value": "null" + }], + "valueName": "avg" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 8 + }, + "id": 52, + "panels": [], + "repeat": null, + "title": "Service Mesh", + "type": "row" + }, + { + "content": "
      \n Service Mesh\n
      \n", + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 9 + }, + "height": "30px", + "id": 24, + "links": [], + "mode": "html", + "title": "", + "transparent": true, + "type": "text" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 0, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 12 + }, + "id": 1, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null as zero", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [{ + "expr": "round(sum(irate(istio_request_count{source_service=~\"$source\",source_version=~\"$source_version\",destination_service=~\"$http_destination\",destination_version=~\"$destination_version\"}[1m])), 0.001)", + "intervalFactor": 1, + "legendFormat": "All", + "refId": "B", + "step": 2 + }, + { + "expr": "round(sum(irate(istio_request_count{source_service=~\"$source\",source_version=~\"$source_version\",destination_service=~\"$http_destination\",destination_version=~\"$destination_version\",response_code=\"200\"}[1m])), 0.001)", + "hide": false, + "intervalFactor": 1, + "legendFormat": "200s", + "refId": "A", + "step": 2 + }, + { + "expr": "round(sum(irate(istio_request_count{source_service=~\"$source\",source_version=~\"$source_version\",destination_service=~\"$http_destination\",destination_version=~\"$destination_version\",response_code=~\"4.*\"}[1m])), 0.001)", + "hide": false, + "intervalFactor": 1, + "legendFormat": "400s", + "refId": "C", + "step": 2 + }, + { + "expr": "round(sum(irate(istio_request_count{source_service=~\"$source\",source_version=~\"$source_version\",destination_service=~\"$http_destination\",destination_version=~\"$destination_version\",connection_mtls=\"true\"}[1m])), 0.001)", + "hide": false, + "intervalFactor": 1, + "legendFormat": "All Secured Requests", + "refId": "D", + "step": 2 + }, + { + "expr": "round(sum(irate(istio_request_count{source_service=~\"$source\",source_version=~\"$source_version\",destination_service=~\"$http_destination\",destination_version=~\"$destination_version\",connection_mtls=\"false\"}[1m])), 0.001)", + "hide": false, + "intervalFactor": 1, + "legendFormat": "All Unsecured Requests", + "refId": "E", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Request Volume", + "tooltip": { + "shared": false, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + "total" + ] + }, + "yaxes": [{ + "format": "ops", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 6, + "y": 12 + }, + "id": 7, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [{ + "expr": "label_replace(sum(irate(istio_request_count{destination_service=~\"$http_destination\",source_service=~\"$source\",response_code!~\"5.*\",destination_version=~\"$destination_version\"}[1m])) by (destination_service) / sum(irate(istio_request_count{destination_service=~\"$http_destination\",source_service=~\"$source\",destination_version=~\"$destination_version\"}[1m])) by (destination_service), \"destination_service\", \"$1\", \"destination_service\", \"(.*).svc.cluster.local\")", + "hide": false, + "intervalFactor": 1, + "legendFormat": "{{ destination_service }}", + "refId": "A", + "step": 2 + }], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Success Rate by Service (non-5xx responses)", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [{ + "format": "percentunit", + "label": null, + "logBase": 1, + "max": "1.01", + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 12 + }, + "id": 8, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [{ + "expr": "label_replace(sum(irate(istio_request_count{source_service=~\"$source\",source_version=~\"$source_version\",destination_service=~\"$http_destination\",destination_version=~\"$destination_version\",response_code=~\"4.*\"}[1m])) by (destination_service), \"destination_service\", \"$1\", \"destination_service\", \"(.*).svc.cluster.local\")", + "intervalFactor": 1, + "legendFormat": "{{ destination_service }}", + "refId": "A", + "step": 2 + }], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "4xxs by Service", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [{ + "format": "ops", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 12 + }, + "id": 25, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [{ + "expr": "label_replace(sum(irate(istio_request_count{source_service=~\"$source\",source_version=~\"$source_version\",destination_service=~\"$http_destination\",destination_version=~\"$destination_version\",response_code=~\"5.*\"}[1m])) by (destination_service) , \"destination_service\", \"$1\", \"destination_service\", \"(.*).svc.cluster.local\")", + "intervalFactor": 1, + "legendFormat": "{{ destination_service }}", + "refId": "A", + "step": 2 + }], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "5xxs by Service", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [{ + "format": "ops", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 19 + }, + "id": 53, + "panels": [], + "repeat": null, + "title": "Services", + "type": "row" + }, + { + "content": "
      \n HTTP Services\n
      ", + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 20 + }, + "height": "50px", + "id": 26, + "links": [], + "mode": "html", + "title": "", + "transparent": true, + "type": "text" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 23 + }, + "id": 54, + "panels": [], + "repeat": "http_destination", + "title": "$http_destination", + "type": "row" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 0, + "gridPos": { + "h": 6, + "w": 6, + "x": 0, + "y": 24 + }, + "id": 27, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null as zero", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [{ + "expr": "label_replace(round(sum(irate(istio_request_count{destination_service=~\"$http_destination\",destination_version=~\"$destination_version\"}[1m])) by (source_service, source_version, destination_version, response_code), 0.001), \"source_service\", \"$1\", \"source_service\", \"(.*).svc.cluster.local\")", + "intervalFactor": 1, + "legendFormat": "{{ source_service }}-{{ source_version}} -> {{destination_version}} : {{ response_code }}", + "refId": "B", + "step": 2 + }], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Requests by Source, Version, and Response Code", + "tooltip": { + "shared": false, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + "total" + ] + }, + "yaxes": [{ + "format": "ops", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 6, + "w": 6, + "x": 6, + "y": 24 + }, + "id": 30, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [{ + "expr": "label_replace(sum(irate(istio_request_count{source_version=~\"$source_version\",destination_service=~\"$http_destination\",source_service=~\"$source\",destination_version=~\"$destination_version\",response_code!~\"5.*\"}[1m])) by (source_service, source_version, destination_version) / sum(irate(istio_request_count{source_version=~\"$source_version\",destination_service=~\"$http_destination\",source_service=~\"$source\",destination_version=~\"$destination_version\"}[1m])) by (source_service, source_version, destination_version), \"source_service\", \"$1\", \"source_service\", \"(.*).svc.cluster.local\")", + "hide": false, + "intervalFactor": 1, + "legendFormat": "{{source_service}}-{{source_version}} -> {{destination_version}}", + "refId": "A", + "step": 2 + }], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Success Rate by Source and Version (non-5xx responses)", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [{ + "format": "percentunit", + "label": null, + "logBase": 1, + "max": "1.01", + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 6, + "w": 6, + "x": 12, + "y": 24 + }, + "id": 28, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [{ + "expr": "label_replace(histogram_quantile(0.50, sum(irate(istio_request_duration_bucket{source_service=~\"$source\",destination_service=~\"$http_destination\",destination_version=~\"$destination_version\",source_version=~\"$source_version\"}[1m])) by (source_service, source_version, destination_version, le)), \"source_service\", \"$1\", \"source_service\", \"(.*).svc.cluster.local\")", + "hide": false, + "intervalFactor": 1, + "legendFormat": "{{ source_service }}-{{ source_version }} -> {{ destination_version }} (p50)", + "refId": "D", + "step": 2 + }, + { + "expr": "label_replace(histogram_quantile(0.90, sum(irate(istio_request_duration_bucket{source_service=~\"$source\",destination_service=~\"$http_destination\",destination_version=~\"$destination_version\",source_version=~\"$source_version\"}[1m])) by (source_service, source_version,destination_version, le)), \"source_service\", \"$1\", \"source_service\", \"(.*).svc.cluster.local\")", + "hide": false, + "intervalFactor": 1, + "legendFormat": "{{ source_service }}-{{source_version}} -> {{ destination_version }} (p90)", + "refId": "A", + "step": 2 + }, + { + "expr": "label_replace(histogram_quantile(0.95, sum(irate(istio_request_duration_bucket{source_service=~\"$source\",destination_service=~\"$http_destination\",destination_version=~\"$destination_version\",source_version=~\"$source_version\"}[1m])) by (source_service, source_version,destination_version, le)), \"source_service\", \"$1\", \"source_service\", \"(.*).svc.cluster.local\")", + "hide": false, + "intervalFactor": 1, + "legendFormat": "{{ source_service }}-{{source_version}} -> {{ destination_version }} (p95)", + "refId": "B", + "step": 2 + }, + { + "expr": "label_replace(histogram_quantile(0.99, sum(irate(istio_request_duration_bucket{source_service=~\"$source\",destination_service=~\"$http_destination\",destination_version=~\"$destination_version\",source_version=~\"$source_version\"}[1m])) by (source_service, source_version, destination_version, le)), \"source_service\", \"$1\", \"source_service\", \"(.*).svc.cluster.local\")", + "hide": false, + "intervalFactor": 1, + "legendFormat": "{{ source_service }}-{{source_version}} -> {{ destination_version }} (p99)", + "refId": "C", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Response Time by Source and Version", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [{ + "format": "s", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 6, + "w": 6, + "x": 18, + "y": 24 + }, + "id": 29, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [{ + "expr": "label_replace(histogram_quantile(0.50, sum(irate(istio_response_size_bucket{source_service=~\"$source\",destination_service=~\"$http_destination\",destination_version=~\"$destination_version\",source_version=~\"$source_version\"}[1m])) by (source_service, source_version, destination_version, le)), \"source_service\", \"$1\", \"source_service\", \"(.*).svc.cluster.local\")", + "hide": false, + "intervalFactor": 1, + "legendFormat": "{{ source_service }}-{{ source_version }} -> {{ destination_version }} (p50)", + "refId": "D", + "step": 2 + }, + { + "expr": "label_replace(histogram_quantile(0.90, sum(irate(istio_response_size_bucket{source_service=~\"$source\",destination_service=~\"$http_destination\",destination_version=~\"$destination_version\",source_version=~\"$source_version\"}[1m])) by (source_service, source_version, destination_version, le)), \"source_service\", \"$1\", \"source_service\", \"(.*).svc.cluster.local\")", + "hide": false, + "intervalFactor": 1, + "legendFormat": "{{ source_service }}-{{ source_version }} -> {{ destination_version }} (p90)", + "refId": "A", + "step": 2 + }, + { + "expr": "label_replace(histogram_quantile(0.95, sum(irate(istio_response_size_bucket{source_service=~\"$source\",destination_service=~\"$http_destination\",destination_version=~\"$destination_version\",source_version=~\"$source_version\"}[1m])) by (source_service, source_version, destination_version, le)), \"source_service\", \"$1\", \"source_service\", \"(.*).svc.cluster.local\")", + "hide": false, + "intervalFactor": 1, + "legendFormat": "{{ source_service }}-{{ source_version }} -> {{ destination_version }} (p95)", + "refId": "B", + "step": 2 + }, + { + "expr": "label_replace(histogram_quantile(0.99, sum(irate(istio_response_size_bucket{source_service=~\"$source\",destination_service=~\"$http_destination\",destination_version=~\"$destination_version\",source_version=~\"$source_version\"}[1m])) by (source_service, source_version, destination_version, le)), \"source_service\", \"$1\", \"source_service\", \"(.*).svc.cluster.local\")", + "hide": false, + "intervalFactor": 1, + "legendFormat": "{{ source_service }}-{{ source_version }} -> {{ destination_version }} (p99)", + "refId": "C", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Response Size by Source and Version", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [{ + "format": "decbytes", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 30 + }, + "id": 55, + "panels": [], + "repeat": null, + "title": "TCP Services", + "type": "row" + }, + { + "content": "
      \n TCP Services\n
      ", + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 31 + }, + "height": "50px", + "id": 49, + "links": [], + "mode": "html", + "repeat": null, + "title": "", + "transparent": true, + "type": "text" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 34 + }, + "id": 56, + "panels": [], + "repeat": "tcp_destination", + "title": "$tcp_destination", + "type": "row" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 6, + "w": 12, + "x": 0, + "y": 35 + }, + "id": 47, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [{ + "expr": "label_replace(round(sum(irate(istio_tcp_bytes_received{destination_service=~\"$tcp_destination\",destination_version=~\"$destination_version\", source_service=~\"$source\", source_version=~\"$source_version\"}[1m])) by (source_service, source_version, destination_version), 0.001), \"source_service\", \"$1\", \"source_service\", \"(.*).svc.cluster.local\")", + "intervalFactor": 1, + "legendFormat": "{{ source_service }}-{{ source_version}} -> {{ destination_version }}", + "refId": "A", + "step": 2 + }], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Bytes Received", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [{ + "format": "Bps", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 35 + }, + "id": 48, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [{ + "expr": "label_replace(round(sum(irate(istio_tcp_bytes_sent{destination_service=~\"$tcp_destination\",destination_version=~\"$destination_version\", source_service=~\"$source\", source_version=~\"$source_version\"}[1m])) by (source_service, source_version, destination_version), 0.001), \"source_service\", \"$1\", \"source_service\", \"(.*).svc.cluster.local\")", + "intervalFactor": 1, + "legendFormat": "{{ destination_version }} -> {{ source_service }}-{{source_version}} ", + "refId": "A", + "step": 2 + }], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Bytes Sent", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [{ + "format": "Bps", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "refresh": "5s", + "schemaVersion": 16, + "style": "dark", + "tags": [], + "templating": { + "list": [{ + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "hide": 0, + "includeAll": true, + "label": "Source", + "multi": true, + "name": "source", + "options": [], + "query": "label_values(source_service)", + "refresh": 1, + "regex": ".+", + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "hide": 0, + "includeAll": true, + "label": "Source Version", + "multi": true, + "name": "source_version", + "options": [], + "query": "label_values(source_version)", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "hide": 2, + "includeAll": true, + "label": "Destination", + "multi": true, + "name": "destination", + "options": [], + "query": "label_values(destination_service)", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "hide": 0, + "includeAll": true, + "label": "HTTP Destination", + "multi": true, + "name": "http_destination", + "options": [], + "query": "label_values(istio_request_count, destination_service)", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "hide": 0, + "includeAll": true, + "label": "TCP Destination", + "multi": true, + "name": "tcp_destination", + "options": [], + "query": "label_values(istio_tcp_bytes_received, destination_service)", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "hide": 0, + "includeAll": true, + "label": "Destination Version", + "multi": true, + "name": "destination_version", + "options": [], + "query": "label_values(destination_version)", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "Istio Dashboard", + "uid": "1", + "version": 1 +} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/grafana/dashboards/kubernetes-capacity-planning-dashboard.json b/resources/core/charts/monitoring/charts/grafana/dashboards/kubernetes-capacity-planning-dashboard.json new file mode 100644 index 000000000000..6c47b780efa6 --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/dashboards/kubernetes-capacity-planning-dashboard.json @@ -0,0 +1,974 @@ +{ + "__inputs": [ + { + "description": "", + "label": "prometheus", + "name": "DS_PROMETHEUS", + "pluginId": "prometheus", + "pluginName": "Prometheus", + "type": "datasource" + } + ], + "annotations": { + "list": [] + }, + "editable": true, + "gnetId": 22, + "graphTooltip": 0, + "hideControls": false, + "links": [], + "refresh": false, + "rows": [ + { + "collapse": false, + "editable": true, + "height": "250px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 3, + "isNew": false, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(rate(node_cpu{mode=\"idle\"}[2m])) * 100", + "hide": false, + "intervalFactor": 10, + "legendFormat": "", + "refId": "A", + "step": 50 + } + ], + "title": "Idle CPU", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "percent", + "label": "cpu usage", + "logBase": 1, + "min": 0, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 9, + "isNew": false, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(node_load1)", + "intervalFactor": 4, + "legendFormat": "load 1m", + "refId": "A", + "step": 20, + "target": "" + }, + { + "expr": "sum(node_load5)", + "intervalFactor": 4, + "legendFormat": "load 5m", + "refId": "B", + "step": 20, + "target": "" + }, + { + "expr": "sum(node_load15)", + "intervalFactor": 4, + "legendFormat": "load 15m", + "refId": "C", + "step": 20, + "target": "" + } + ], + "title": "System Load", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "percentunit", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ] + } + ], + "showTitle": false, + "title": "New Row", + "titleSize": "h6" + }, + { + "collapse": false, + "editable": true, + "height": "250px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 4, + "isNew": false, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "node_memory_SwapFree{instance=\"172.17.0.1:9100\",job=\"prometheus\"}", + "yaxis": 2 + } + ], + "spaceLength": 10, + "span": 9, + "stack": true, + "steppedLine": false, + "targets": [ + { + "expr": "sum(node_memory_MemTotal) - sum(node_memory_MemFree) - sum(node_memory_Buffers) - sum(node_memory_Cached)", + "intervalFactor": 2, + "legendFormat": "memory usage", + "metric": "memo", + "refId": "A", + "step": 10, + "target": "" + }, + { + "expr": "sum(node_memory_Buffers)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "memory buffers", + "metric": "memo", + "refId": "B", + "step": 10, + "target": "" + }, + { + "expr": "sum(node_memory_Cached)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "memory cached", + "metric": "memo", + "refId": "C", + "step": 10, + "target": "" + }, + { + "expr": "sum(node_memory_MemFree)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "memory free", + "metric": "memo", + "refId": "D", + "step": 10, + "target": "" + } + ], + "title": "Memory Usage", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "logBase": 1, + "min": "0", + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ] + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 5, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "((sum(node_memory_MemTotal) - sum(node_memory_MemFree) - sum(node_memory_Buffers) - sum(node_memory_Cached)) / sum(node_memory_MemTotal)) * 100", + "intervalFactor": 2, + "metric": "", + "refId": "A", + "step": 60, + "target": "" + } + ], + "thresholds": "80, 90", + "title": "Memory Usage", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + } + ], + "showTitle": false, + "title": "New Row", + "titleSize": "h6" + }, + { + "collapse": false, + "editable": true, + "height": "246px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 6, + "isNew": false, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "read", + "yaxis": 1 + }, + { + "alias": "{instance=\"172.17.0.1:9100\"}", + "yaxis": 2 + }, + { + "alias": "io time", + "yaxis": 2 + } + ], + "spaceLength": 10, + "span": 9, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(rate(node_disk_bytes_read[5m]))", + "hide": false, + "intervalFactor": 4, + "legendFormat": "read", + "refId": "A", + "step": 20, + "target": "" + }, + { + "expr": "sum(rate(node_disk_bytes_written[5m]))", + "intervalFactor": 4, + "legendFormat": "written", + "refId": "B", + "step": 20 + }, + { + "expr": "sum(rate(node_disk_io_time_ms[5m]))", + "intervalFactor": 4, + "legendFormat": "io time", + "refId": "C", + "step": 20 + } + ], + "title": "Disk I/O", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "logBase": 1, + "show": true + }, + { + "format": "ms", + "logBase": 1, + "show": true + } + ] + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "percentunit", + "gauge": { + "maxValue": 1, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 12, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "(sum(node_filesystem_size{device!=\"rootfs\"}) - sum(node_filesystem_free{device!=\"rootfs\"})) / sum(node_filesystem_size{device!=\"rootfs\"})", + "intervalFactor": 2, + "refId": "A", + "step": 60, + "target": "" + } + ], + "thresholds": "0.75, 0.9", + "title": "Disk Space Usage", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + } + ], + "showTitle": false, + "title": "New Row", + "titleSize": "h6" + }, + { + "collapse": false, + "editable": true, + "height": "250px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 8, + "isNew": false, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "transmitted", + "yaxis": 2 + } + ], + "spaceLength": 10, + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(rate(node_network_receive_bytes{device!~\"lo\"}[5m]))", + "hide": false, + "intervalFactor": 2, + "legendFormat": "", + "refId": "A", + "step": 10, + "target": "" + } + ], + "title": "Network Received", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "logBase": 1, + "show": true + }, + { + "format": "bytes", + "logBase": 1, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 10, + "isNew": false, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "transmitted", + "yaxis": 2 + } + ], + "spaceLength": 10, + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(rate(node_network_transmit_bytes{device!~\"lo\"}[5m]))", + "hide": false, + "intervalFactor": 2, + "legendFormat": "", + "refId": "B", + "step": 10, + "target": "" + } + ], + "title": "Network Transmitted", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "logBase": 1, + "show": true + }, + { + "format": "bytes", + "logBase": 1, + "show": true + } + ] + } + ], + "showTitle": false, + "title": "New Row", + "titleSize": "h6" + }, + { + "collapse": false, + "editable": true, + "height": "276px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 11, + "isNew": true, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 11, + "span": 9, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(kube_pod_info)", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Current number of Pods", + "refId": "A", + "step": 10 + }, + { + "expr": "sum(kube_node_status_capacity_pods)", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Maximum capacity of pods", + "refId": "B", + "step": 10 + } + ], + "title": "Cluster Pod Utilization", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ] + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 7, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "100 - (sum(kube_node_status_capacity_pods) - sum(kube_pod_info)) / sum(kube_node_status_capacity_pods) * 100", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A", + "step": 60, + "target": "" + } + ], + "thresholds": "80, 90", + "title": "Pod Utilization", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + } + ], + "showTitle": false, + "title": "New Row", + "titleSize": "h6" + } + ], + "schemaVersion": 14, + "sharedCrosshair": false, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "Kubernetes Capacity Planning", + "version": 4 +} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/grafana/dashboards/kubernetes-cluster-health-dashboard.json b/resources/core/charts/monitoring/charts/grafana/dashboards/kubernetes-cluster-health-dashboard.json new file mode 100644 index 000000000000..0724ba96d824 --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/dashboards/kubernetes-cluster-health-dashboard.json @@ -0,0 +1,694 @@ +{ + "__inputs": [ + { + "description": "", + "label": "prometheus", + "name": "DS_PROMETHEUS", + "pluginId": "prometheus", + "pluginName": "Prometheus", + "type": "datasource" + } + ], + "annotations": { + "list": [] + }, + "editable": true, + "graphTooltip": 0, + "hideControls": false, + "links": [], + "refresh": "10s", + "rows": [ + { + "collapse": false, + "editable": true, + "height": "254px", + "panels": [ + { + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 1, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "sum(up{job=~\"apiserver|kube-scheduler|kube-controller-manager\"} == 0)", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A", + "step": 600 + } + ], + "thresholds": "1, 3", + "title": "Control Plane Components Down", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "Everything UP and healthy", + "value": "null" + }, + { + "op": "=", + "text": "", + "value": "" + } + ], + "valueName": "avg" + }, + { + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 2, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "sum(ALERTS{alertstate=\"firing\",alertname!=\"DeadMansSwitch\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A", + "step": 600 + } + ], + "thresholds": "1, 3", + "title": "Alerts Firing", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "0", + "value": "null" + } + ], + "valueName": "current" + }, + { + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 3, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "sum(ALERTS{alertstate=\"pending\",alertname!=\"DeadMansSwitch\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A", + "step": 600 + } + ], + "thresholds": "3, 5", + "title": "Alerts Pending", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "0", + "value": "null" + } + ], + "valueName": "current" + }, + { + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 4, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "count(increase(kube_pod_container_status_restarts[1h]) > 5)", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A", + "step": 600 + } + ], + "thresholds": "1, 3", + "title": "Crashlooping Pods", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "0", + "value": "null" + } + ], + "valueName": "current" + } + ], + "showTitle": false, + "title": "Row", + "titleSize": "h6" + }, + { + "collapse": false, + "editable": true, + "height": "250px", + "panels": [ + { + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 5, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "sum(kube_node_status_condition{condition=\"Ready\",status!=\"true\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A", + "step": 600 + } + ], + "thresholds": "1, 3", + "title": "Node Not Ready", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 6, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "sum(kube_node_status_condition{condition=\"DiskPressure\",status=\"true\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A", + "step": 600 + } + ], + "thresholds": "1, 3", + "title": "Node Disk Pressure", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 7, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "sum(kube_node_status_condition{condition=\"MemoryPressure\",status=\"true\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A", + "step": 600 + } + ], + "thresholds": "1, 3", + "title": "Node Memory Pressure", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 8, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "sum(kube_node_spec_unschedulable)", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A", + "step": 600 + } + ], + "thresholds": "1, 3", + "title": "Nodes Unschedulable", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + } + ], + "showTitle": false, + "title": "Row", + "titleSize": "h6" + } + ], + "schemaVersion": 14, + "sharedCrosshair": false, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "Kubernetes Cluster Health", + "version": 9 +} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/grafana/dashboards/kubernetes-cluster-status-dashboard.json b/resources/core/charts/monitoring/charts/grafana/dashboards/kubernetes-cluster-status-dashboard.json new file mode 100644 index 000000000000..ffa613e2b42f --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/dashboards/kubernetes-cluster-status-dashboard.json @@ -0,0 +1,807 @@ +{ + "__inputs": [ + { + "description": "", + "label": "prometheus", + "name": "DS_PROMETHEUS", + "pluginId": "prometheus", + "pluginName": "Prometheus", + "type": "datasource" + } + ], + "annotations": { + "list": [] + }, + "editable": true, + "graphTooltip": 0, + "hideControls": false, + "links": [], + "rows": [ + { + "collapse": false, + "height": "129px", + "panels": [ + { + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 5, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 6, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "sum(up{job=~\"apiserver|kube-scheduler|kube-controller-manager\"} == 0)", + "format": "time_series", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "thresholds": "1, 3", + "title": "Control Plane UP", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "UP", + "value": "null" + } + ], + "valueName": "total" + }, + { + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 6, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 6, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "sum(ALERTS{alertstate=\"firing\",alertname!=\"DeadMansSwitch\"})", + "format": "time_series", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "thresholds": "3, 5", + "title": "Alerts Firing", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "0", + "value": "null" + } + ], + "valueName": "current" + } + ], + "showTitle": true, + "title": "Cluster Health", + "titleSize": "h6" + }, + { + "collapse": false, + "height": "168px", + "panels": [ + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 1, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "(sum(up{job=\"apiserver\"} == 1) / count(up{job=\"apiserver\"})) * 100", + "format": "time_series", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "thresholds": "50, 80", + "title": "API Servers UP", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 2, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "(sum(up{job=\"kube-controller-manager\"} == 1) / count(up{job=\"kube-controller-manager\"})) * 100", + "format": "time_series", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "thresholds": "50, 80", + "title": "Controller Managers UP", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 3, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "(sum(up{job=\"kube-scheduler\"} == 1) / count(up{job=\"kube-scheduler\"})) * 100", + "format": "time_series", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "thresholds": "50, 80", + "title": "Schedulers UP", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 4, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "count(increase(kube_pod_container_status_restarts{namespace=~\"kube-system|tectonic-system\"}[1h]) > 5)", + "format": "time_series", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "thresholds": "1, 3", + "title": "Crashlooping Control Plane Pods", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "0", + "value": "null" + } + ], + "valueName": "current" + } + ], + "showTitle": true, + "title": "Control Plane Status", + "titleSize": "h6" + }, + { + "collapse": false, + "height": "158px", + "panels": [ + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 8, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "sum(100 - (avg by (instance) (rate(node_cpu{job=\"node-exporter\",mode=\"idle\"}[5m])) * 100)) / count(node_cpu{job=\"node-exporter\",mode=\"idle\"})", + "format": "time_series", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "thresholds": "80, 90", + "title": "CPU Utilization", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 7, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "((sum(node_memory_MemTotal) - sum(node_memory_MemFree) - sum(node_memory_Buffers) - sum(node_memory_Cached)) / sum(node_memory_MemTotal)) * 100", + "format": "time_series", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "thresholds": "80, 90", + "title": "Memory Utilization", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 9, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "(sum(node_filesystem_size{device!=\"rootfs\"}) - sum(node_filesystem_free{device!=\"rootfs\"})) / sum(node_filesystem_size{device!=\"rootfs\"})", + "format": "time_series", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "thresholds": "80, 90", + "title": "Filesystem Utilization", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 10, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "100 - (sum(kube_node_status_capacity_pods) - sum(kube_pod_info)) / sum(kube_node_status_capacity_pods) * 100", + "format": "time_series", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "thresholds": "80, 90", + "title": "Pod Utilization", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + } + ], + "showTitle": true, + "title": "Capacity Planning", + "titleSize": "h6" + } + ], + "schemaVersion": 14, + "sharedCrosshair": false, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "Kubernetes Cluster Status", + "version": 3 +} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/grafana/dashboards/kubernetes-control-plane-status-dashboard.json b/resources/core/charts/monitoring/charts/grafana/dashboards/kubernetes-control-plane-status-dashboard.json new file mode 100644 index 000000000000..670462d9277d --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/dashboards/kubernetes-control-plane-status-dashboard.json @@ -0,0 +1,626 @@ +{ + "__inputs": [ + { + "description": "", + "label": "prometheus", + "name": "DS_PROMETHEUS", + "pluginId": "prometheus", + "pluginName": "Prometheus", + "type": "datasource" + } + ], + "annotations": { + "list": [] + }, + "editable": true, + "graphTooltip": 0, + "hideControls": false, + "links": [], + "rows": [ + { + "collapse": false, + "editable": true, + "height": "250px", + "panels": [ + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 1, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "(sum(up{job=\"apiserver\"} == 1) / sum(up{job=\"apiserver\"})) * 100", + "format": "time_series", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "thresholds": "50, 80", + "title": "API Servers UP", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 2, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "(sum(up{job=\"kube-controller-manager\"} == 1) / sum(up{job=\"kube-controller-manager\"})) * 100", + "format": "time_series", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "thresholds": "50, 80", + "title": "Controller Managers UP", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 3, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "(sum(up{job=\"kube-scheduler\"} == 1) / sum(up{job=\"kube-scheduler\"})) * 100", + "format": "time_series", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "thresholds": "50, 80", + "title": "Schedulers UP", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 4, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "max(sum by(instance) (rate(apiserver_request_count{code=~\"5..\"}[5m])) / sum by(instance) (rate(apiserver_request_count[5m]))) * 100", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A", + "step": 600 + } + ], + "thresholds": "5, 10", + "title": "API Server Request Error Rate", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "0", + "value": "null" + } + ], + "valueName": "avg" + } + ], + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + }, + { + "collapse": false, + "editable": true, + "height": "250px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 7, + "isNew": false, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 12, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by(verb) (rate(apiserver_latency_seconds:quantile[5m]) >= 0)", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A", + "step": 30 + } + ], + "title": "API Server Request Latency", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ] + } + ], + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + }, + { + "collapse": false, + "editable": true, + "height": "250px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 5, + "isNew": false, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "cluster:scheduler_e2e_scheduling_latency_seconds:quantile", + "format": "time_series", + "intervalFactor": 2, + "refId": "A", + "step": 60 + } + ], + "title": "End to End Scheduling Latency", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "dtdurations", + "logBase": 1, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 6, + "isNew": false, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by(instance) (rate(apiserver_request_count{code!~\"2..\"}[5m]))", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Error Rate", + "refId": "A", + "step": 60 + }, + { + "expr": "sum by(instance) (rate(apiserver_request_count[5m]))", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Request Rate", + "refId": "B", + "step": 60 + } + ], + "title": "API Server Request Rates", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ] + } + ], + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + } + ], + "schemaVersion": 14, + "sharedCrosshair": false, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "Kubernetes Control Plane Status", + "version": 3 +} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/grafana/dashboards/kubernetes-resource-requests-dashboard.json b/resources/core/charts/monitoring/charts/grafana/dashboards/kubernetes-resource-requests-dashboard.json new file mode 100644 index 000000000000..fc20d67b70a0 --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/dashboards/kubernetes-resource-requests-dashboard.json @@ -0,0 +1,403 @@ +{ + "__inputs": [ + { + "description": "", + "label": "prometheus", + "name": "DS_PROMETHEUS", + "pluginId": "prometheus", + "pluginName": "Prometheus", + "type": "datasource" + } + ], + "annotations": { + "list": [] + }, + "editable": true, + "graphTooltip": 0, + "hideControls": false, + "links": [], + "refresh": false, + "rows": [ + { + "collapse": false, + "editable": true, + "height": "300px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "description": "This represents the total [CPU resource requests](https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/#meaning-of-cpu) in the cluster.\nFor comparison the total [allocatable CPU cores](https://github.com/kubernetes/community/blob/master/contributors/design-proposals/node-allocatable.md) is also shown.", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 1, + "isNew": false, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 9, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "min(sum(kube_node_status_allocatable_cpu_cores) by (instance))", + "hide": false, + "intervalFactor": 2, + "legendFormat": "Allocatable CPU Cores", + "refId": "A", + "step": 20 + }, + { + "expr": "max(sum(kube_pod_container_resource_requests_cpu_cores) by (instance))", + "hide": false, + "intervalFactor": 2, + "legendFormat": "Requested CPU Cores", + "refId": "B", + "step": 20 + } + ], + "title": "CPU Cores", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": "CPU Cores", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ] + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 2, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": true + }, + "targets": [ + { + "expr": "max(sum(kube_pod_container_resource_requests_cpu_cores) by (instance)) / min(sum(kube_node_status_allocatable_cpu_cores) by (instance)) * 100", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A", + "step": 240 + } + ], + "thresholds": "80, 90", + "title": "CPU Cores", + "transparent": false, + "type": "singlestat", + "valueFontSize": "110%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + } + ], + "showTitle": false, + "title": "CPU Cores", + "titleSize": "h6" + }, + { + "collapse": false, + "editable": true, + "height": "300px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "description": "This represents the total [memory resource requests](https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/#meaning-of-memory) in the cluster.\nFor comparison the total [allocatable memory](https://github.com/kubernetes/community/blob/master/contributors/design-proposals/node-allocatable.md) is also shown.", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 3, + "isNew": false, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 9, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "min(sum(kube_node_status_allocatable_memory_bytes) by (instance))", + "hide": false, + "intervalFactor": 2, + "legendFormat": "Allocatable Memory", + "refId": "A", + "step": 20 + }, + { + "expr": "max(sum(kube_pod_container_resource_requests_memory_bytes) by (instance))", + "hide": false, + "intervalFactor": 2, + "legendFormat": "Requested Memory", + "refId": "B", + "step": 20 + } + ], + "title": "Memory", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "label": "Memory", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ] + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 4, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": true + }, + "targets": [ + { + "expr": "max(sum(kube_pod_container_resource_requests_memory_bytes) by (instance)) / min(sum(kube_node_status_allocatable_memory_bytes) by (instance)) * 100", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A", + "step": 240 + } + ], + "thresholds": "80, 90", + "title": "Memory", + "transparent": false, + "type": "singlestat", + "valueFontSize": "110%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + } + ], + "showTitle": false, + "title": "Memory", + "titleSize": "h6" + } + ], + "schemaVersion": 14, + "sharedCrosshair": false, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-3h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "Kubernetes Resource Requests", + "version": 2 +} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/grafana/dashboards/lambda-dashboard.json b/resources/core/charts/monitoring/charts/grafana/dashboards/lambda-dashboard.json new file mode 100644 index 000000000000..5df9120d1aba --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/dashboards/lambda-dashboard.json @@ -0,0 +1,1006 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": 6, + "iteration": 1531123853368, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 52, + "panels": [], + "repeat": null, + "title": "Lambda", + "type": "row" + }, + { + "content": "# $lambda_service.$environment\n", + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 1 + }, + "height": "30px", + "id": 24, + "links": [], + "mode": "markdown", + "title": "", + "transparent": true, + "type": "text" + }, + { + "aliasColors": { + "200 response code": "#5195ce", + "500 response code": "#890f02" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 4 + }, + "id": 7, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null as zero", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "expr": "label_replace(sum(irate(istio_request_count{destination_service=~\"$http_destination\",response_code=~\"5.*\"}[2m])) by (destination_service, response_code) / sum(irate(istio_request_count{destination_service=~\"$http_destination\"}[2m])) by (destination_service, response_code), \"destination_service\", \"$1\", \"destination_service\", \"$lambda_service.$environment.*\")", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "{{ response_code }} response code", + "refId": "B" + }, + { + "expr": "label_replace(sum(irate(istio_request_count{destination_service=~\"$http_destination\",response_code=~\"2.*\"}[2m])) by (destination_service, response_code) / sum(irate(istio_request_count{destination_service=~\"$http_destination\",}[2m])) by (destination_service, response_code), \"destination_service\", \"$1\", \"destination_service\", \"$lambda_service.$environment.*\")", + "format": "time_series", + "hide": false, + "instant": false, + "intervalFactor": 1, + "legendFormat": "{{ response_code }} response code", + "refId": "A", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Rate by Service and Response Codes (2xx and 5xx)", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "percentunit", + "label": null, + "logBase": 1, + "max": "1.01", + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "content": "# Lambda Health\n", + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 13 + }, + "height": "30px", + "id": 70, + "links": [], + "mode": "markdown", + "title": "", + "transparent": true, + "type": "text" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": false, + "colors": [ + "#299c46", + "rgba(237, 129, 40, 0.89)", + "#d44a3a" + ], + "datasource": "Prometheus", + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 16 + }, + "id": 57, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "", + "targets": [ + { + "expr": "(sum(container_memory_usage_bytes{pod_name=~\"$lambda_service.*\", container_name=\"$lambda_service\", container_name!=\"POD\"}) / sum (kube_pod_container_resource_limits_memory_bytes{pod=~\"$lambda_service.*\", container=\"$lambda_service\"})) * 100", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "", + "refId": "A" + } + ], + "thresholds": "80, 90", + "title": "Memory Usage", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": true, + "colorValue": false, + "colors": [ + "#7eb26d", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "s", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 7, + "w": 5, + "x": 6, + "y": 16 + }, + "id": 67, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "70%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "sparkline": { + "fillColor": "#3f6833", + "full": false, + "lineColor": "rgb(24, 183, 21)", + "show": false + }, + "tableColumn": "", + "targets": [ + { + "expr": "sum (rate(container_cpu_usage_seconds_total{container_name=~\"$lambda_service\", pod_name=~\"$lambda_service.*\", namespace=\"$environment\"}[2m]) )", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "thresholds": "80,90", + "title": "CPU Usage (Seconds)", + "type": "singlestat", + "valueFontSize": "100%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "format": "percentunit", + "gauge": { + "maxValue": 100, + "minValue": 80, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": false + }, + "gridPos": { + "h": 7, + "w": 5, + "x": 11, + "y": 16 + }, + "hideTimeOverride": false, + "id": 64, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": true, + "lineColor": "rgb(31, 120, 193)", + "show": true + }, + "tableColumn": "", + "targets": [ + { + "expr": "sum(rate(istio_request_count{destination_service=~\"$http_destination\", response_code=~\"2.*\"}[2m])) / sum(rate(istio_request_count{destination_service=~\"$http_destination\"}[2m])) ", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "refId": "A", + "step": 4 + } + ], + "thresholds": "95, 99, 99.5", + "timeShift": null, + "title": "Success Rate (2xx responses)", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "format": "s", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 4, + "w": 8, + "x": 16, + "y": 16 + }, + "id": 69, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "sparkline": { + "fillColor": "#3f6833", + "full": true, + "lineColor": "rgb(34, 193, 31)", + "show": true + }, + "tableColumn": "", + "targets": [ + { + "expr": "histogram_quantile(0.95, sum(irate(istio_request_duration_bucket{destination_service=~\"$http_destination\"}[2m])) by (destination_service, le))", + "format": "time_series", + "intervalFactor": 1, + "refId": "A", + "step": 4 + } + ], + "thresholds": "", + "title": "Response Time by Service (Seconds)", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": false, + "colors": [ + "#299c46", + "rgba(237, 129, 40, 0.89)", + "#d44a3a" + ], + "datasource": "Prometheus", + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 3, + "w": 8, + "x": 16, + "y": 20 + }, + "id": 59, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "", + "targets": [ + { + "expr": "min(kube_deployment_status_replicas_available{deployment=\"$lambda_service\",namespace=\"$environment\"}) without (deployment, pod)", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "refId": "A" + } + ], + "thresholds": "80, 90", + "title": "Available Replicas", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 23 + }, + "id": 53, + "panels": [], + "repeat": null, + "title": "Services", + "type": "row" + }, + { + "content": "
      \n HTTP Services\n
      ", + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 24 + }, + "height": "50px", + "id": 26, + "links": [], + "mode": "html", + "title": "", + "transparent": true, + "type": "text" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 27 + }, + "id": 54, + "panels": [], + "repeat": "http_destination", + "scopedVars": { + "http_destination": {} + }, + "title": "$http_destination", + "type": "row" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 0, + "gridPos": { + "h": 13, + "w": 12, + "x": 0, + "y": 28 + }, + "id": 27, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null as zero", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "scopedVars": { + "http_destination": {} + }, + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "round(sum(irate(istio_request_count{destination_service=~\"$http_destination\"}[2m])) by (destination_service, response_code), 0.001)", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "{{ destination_service}} -> {{ response_code }}", + "refId": "B", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Requests Rate by Service and Response Code", + "tooltip": { + "shared": false, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + "total" + ] + }, + "yaxes": [ + { + "format": "ops", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 13, + "w": 12, + "x": 12, + "y": 28 + }, + "id": 28, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "scopedVars": { + "http_destination": {} + }, + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "histogram_quantile(0.50, sum(irate(istio_request_duration_bucket{destination_service=~\"$http_destination\"}[2m])) by (destination_service, le))", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "{{ destination_service }} -> (p50)", + "refId": "D", + "step": 2 + }, + { + "expr": "histogram_quantile(0.90, sum(irate(istio_request_duration_bucket{destination_service=~\"$http_destination\"}[2m])) by (destination_service, le))", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "{{ destination_service }} -> ( (p90)", + "refId": "A", + "step": 2 + }, + { + "expr": "histogram_quantile(0.95, sum(irate(istio_request_duration_bucket{destination_service=~\"$http_destination\"}[2m])) by (destination_service, le))", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "{{ destination_service }} -> ( (p95)", + "refId": "B", + "step": 2 + }, + { + "expr": "histogram_quantile(0.99, sum(irate(istio_request_duration_bucket{destination_service=~\"$http_destination\"}[2m])) by (destination_service, le))", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "{{ destination_service }} -> ( (p99)", + "refId": "C", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Response Time by Service", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "s", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "refresh": "30s", + "schemaVersion": 16, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "hide": 2, + "includeAll": true, + "label": "Source", + "multi": true, + "name": "source", + "options": [], + "query": "label_values(istio_request_count{source_service=~\"istio-ingress.istio-system.*\"}, source_service)", + "refresh": 1, + "regex": "", + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "current": {}, + "hide": 2, + "label": "Source Version", + "name": "source_version", + "options": [ + { + "selected": true, + "text": "unknown", + "value": "unknown" + } + ], + "query": "unknown", + "type": "constant" + }, + { + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "hide": 2, + "includeAll": true, + "label": "Destination", + "multi": true, + "name": "destination", + "options": [], + "query": "label_values(destination_service)", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "hide": 0, + "includeAll": false, + "label": "Environment", + "multi": false, + "name": "environment", + "options": [], + "query": "label_values(kube_namespace_labels{label_env=\"true\"},namespace)", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "hide": 0, + "includeAll": false, + "label": "Lambda Function", + "multi": false, + "name": "lambda_service", + "options": [], + "query": "label_values(kube_service_labels{label_created_by=\"kubeless\",namespace=\"$environment\"},service)", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "hide": 2, + "includeAll": false, + "label": "HTTP Destination", + "multi": false, + "name": "http_destination", + "options": [], + "query": "label_values(istio_request_count{destination_service=~\"$lambda_service.$environment.*\"}, destination_service)", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "current": {}, + "hide": 2, + "label": "Destination Version", + "name": "destination_version", + "options": [ + { + "selected": true, + "text": "unknown", + "value": "unknown" + } + ], + "query": "unknown", + "type": "constant" + } + ] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "Lambda Dashboard", + "version": 1 +} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/grafana/dashboards/mixer-dashboard.json b/resources/core/charts/monitoring/charts/grafana/dashboards/mixer-dashboard.json new file mode 100755 index 000000000000..01d60a554439 --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/dashboards/mixer-dashboard.json @@ -0,0 +1,2106 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "5.0.0-beta2" + }, + { + "type": "panel", + "id": "graph", + "name": "Graph", + "version": "" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "text", + "name": "Text", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": false, + "gnetId": null, + "graphTooltip": 1, + "id": null, + "iteration": 1520470122238, + "links": [], + "panels": [ + { + "content": "

      Resource Usage

      ", + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 0 + }, + "height": "40", + "id": 29, + "links": [], + "mode": "html", + "title": "", + "transparent": true, + "type": "text" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 3 + }, + "id": 5, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(process_virtual_memory_bytes{job=~\"mixer-.*\"}) by (job)", + "format": "time_series", + "instant": false, + "intervalFactor": 2, + "legendFormat": "Virtual Memory ({{ job }})", + "refId": "I" + }, + { + "expr": "sum(process_resident_memory_bytes{job=~\"mixer-.*\"}) by (job)", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Resident Memory ({{ job }})", + "refId": "H" + }, + { + "expr": "sum(go_memstats_heap_sys_bytes{job=~\"mixer-.*\"}) by (job)", + "format": "time_series", + "hide": true, + "intervalFactor": 2, + "legendFormat": "heap sys ({{ job }})", + "refId": "A" + }, + { + "expr": "sum(go_memstats_heap_alloc_bytes{job=~\"mixer-.*\"}) by (job)", + "format": "time_series", + "hide": true, + "intervalFactor": 2, + "legendFormat": "heap alloc ({{ job }})", + "refId": "D" + }, + { + "expr": "sum(go_memstats_alloc_bytes{job=~\"mixer-.*\"}) by (job)", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Alloc ({{ job }})", + "refId": "F" + }, + { + "expr": "sum(go_memstats_heap_inuse_bytes{job=~\"mixer-.*\"}) by (job)", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "Heap in-use ({{ job }})", + "refId": "E" + }, + { + "expr": "sum(go_memstats_stack_inuse_bytes{job=~\"mixer-.*\"}) by (job)", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Stack in-use ({{ job }})", + "refId": "G" + }, + { + "expr": "sum(label_replace(container_memory_usage_bytes{container_name=~\"mixer|istio-proxy\", pod_name=~\"istio-telemetry-.*|istio-policy-.*\"}, \"service\", \"$1\" , \"pod_name\", \"(istio-telemetry|istio-policy)-.*\")) by (service)", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "{{ service }} total (k8s)", + "refId": "C" + }, + { + "expr": "sum(label_replace(container_memory_usage_bytes{container_name=~\"mixer|istio-proxy\", pod_name=~\"istio-telemetry-.*|istio-policy-.*\"}, \"service\", \"$1\" , \"pod_name\", \"(istio-telemetry|istio-policy)-.*\")) by (container_name, service)", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "{{ service }} - {{ container_name }} (k8s)", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Memory", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 6, + "y": 3 + }, + "id": 6, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(label_replace(container_cpu_usage_seconds_total{container_name=~\"mixer|istio-proxy\", pod_name=~\"istio-telemetry-.*|istio-policy-.*\"}, \"service\", \"$1\" , \"pod_name\", \"(istio-telemetry|istio-policy)-.*\")) by (service)", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "{{ service }} total (k8s)", + "refId": "A" + }, + { + "expr": "sum(label_replace(container_cpu_usage_seconds_total{container_name=~\"mixer|istio-proxy\", pod_name=~\"istio-telemetry-.*|istio-policy-.*\"}, \"service\", \"$1\" , \"pod_name\", \"(istio-telemetry|istio-policy)-.*\")) by (container_name, service)", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "{{ service }} - {{ container_name }} (k8s)", + "refId": "B" + }, + { + "expr": "sum(irate(process_cpu_seconds_total{job=~\"mixer-.*\"}[1m])) by (job)", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "{{ job }} (self-reported)", + "refId": "C" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "CPU", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 3 + }, + "id": 7, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(process_open_fds{job=~\"mixer-.*\"}) by (job)", + "format": "time_series", + "hide": true, + "instant": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "Open FDs ({{ job }})", + "refId": "A" + }, + { + "expr": "sum(label_replace(container_fs_usage_bytes{container_name=~\"mixer|istio-proxy\", pod_name=~\"istio-telemetry-.*|istio-policy-.*\"}, \"service\", \"$1\" , \"pod_name\", \"(istio-telemetry|istio-policy)-.*\")) by (container_name, service)", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ service }} - {{ container_name }}", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Disk", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "decimals": null, + "format": "none", + "label": "", + "logBase": 1024, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 3 + }, + "id": 4, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(go_goroutines{job=~\"mixer-.*\"}) by (job)", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Number of Goroutines ({{ job }})", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Goroutines", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "content": "

      Mixer Overview

      ", + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 10 + }, + "height": "40px", + "id": 30, + "links": [], + "mode": "html", + "title": "", + "transparent": true, + "type": "text" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 6, + "w": 6, + "x": 0, + "y": 13 + }, + "id": 9, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "rate(envoy_cluster_mixer_check_server_upstream_rq_total[1m])", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "envoy (Check)", + "refId": "A" + }, + { + "expr": "rate(envoy_cluster_mixer_report_server_upstream_rq_total[1m])", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "envoy (Report)", + "refId": "D" + }, + { + "expr": "sum(rate(grpc_server_handled_total[1m]))", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "mixer (Total)", + "refId": "B" + }, + { + "expr": "rate(grpc_server_handled_total[1m])", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "mixer ({{ grpc_method }})", + "refId": "C" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Incoming Requests", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "ops", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 6, + "w": 6, + "x": 6, + "y": 13 + }, + "id": 8, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "{}", + "yaxis": 1 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "envoy_cluster_mixer_check_server_upstream_rq_time", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ quantile }} (envoy Check)", + "refId": "A" + }, + { + "expr": "envoy_cluster_mixer_report_server_upstream_rq_time", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ quantile }} (envoy Report)", + "refId": "E" + }, + { + "expr": "histogram_quantile(0.5, sum(rate(grpc_server_handling_seconds_bucket{}[1m])) by (grpc_method, le)) * 1000", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ grpc_method }} 0.5", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.9, sum(rate(grpc_server_handling_seconds_bucket{}[1m])) by (grpc_method, le)) * 1000", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ grpc_method }} 0.9", + "refId": "C" + }, + { + "expr": "histogram_quantile(0.99, sum(rate(grpc_server_handling_seconds_bucket{}[1m])) by (grpc_method, le)) * 1000", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ grpc_method }} 0.99", + "refId": "D" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Response Durations", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "ms", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 6, + "w": 6, + "x": 12, + "y": 13 + }, + "id": 11, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "rate(envoy_cluster_mixer_check_server_upstream_rq_5xx[1m])", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Envoy Check", + "refId": "A" + }, + { + "expr": "rate(envoy_cluster_mixer_report_server_upstream_rq_5xx[1m])", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Envoy Report", + "refId": "C" + }, + { + "expr": "sum(rate(grpc_server_handled_total{grpc_code=~\"Unknown|Unimplemented|Internal|DataLoss\"}[1m])) by (grpc_method)", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Mixer {{ grpc_method }}", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Server Error Rate (5xx responses)", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 6, + "w": 6, + "x": 18, + "y": 13 + }, + "id": 12, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "irate(envoy_cluster_mixer_check_server_upstream_rq_4xx[1m])", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Envoy Check", + "refId": "A" + }, + { + "expr": "irate(envoy_cluster_mixer_report_server_upstream_rq_4xx[1m])", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Envoy Report", + "refId": "C" + }, + { + "expr": "sum(irate(grpc_server_handled_total{grpc_code!=\"OK\",grpc_service=~\".*Mixer\"}[1m])) by (grpc_method)", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Mixer {{ grpc_method }}", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Non-successes (4xxs)", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 19 + }, + "id": 10, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "envoy_cluster_mixer_check_server_upstream_cx_active", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Active (mixer_check_server)", + "refId": "B" + }, + { + "expr": "envoy_cluster_mixer_report_server_upstream_cx_active", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Active (mixer_report_server)", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Connections", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 6, + "y": 19 + }, + "id": 1, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "envoy_cluster_mixer_check_server_membership_healthy", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "Healthy (mixer_check_server)", + "refId": "A" + }, + { + "expr": "envoy_cluster_mixer_check_server_membership_total", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "Total (mixer_check_server)", + "refId": "B" + }, + { + "expr": "envoy_cluster_mixer_report_server_membership_healthy", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "Healthy (mixer_report_server)", + "refId": "C" + }, + { + "expr": "envoy_cluster_mixer_report_server_membership_total", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "Total (mixer_report_server)", + "refId": "D" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Cluster Membership", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 19 + }, + "id": 35, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "rate(envoy_cluster_mixer_check_server_outlier_detection_ejections_enforced_total[1m])", + "format": "time_series", + "instant": false, + "intervalFactor": 2, + "legendFormat": "Total Ejections (mixer_check_server)", + "refId": "A" + }, + { + "expr": "rate(envoy_cluster_mixer_check_server_outlier_detection_ejections_overflow[1m])", + "format": "time_series", + "instant": false, + "intervalFactor": 2, + "legendFormat": "Overflow Ejections (mixer_check_server)", + "refId": "B" + }, + { + "expr": "envoy_cluster_mixer_check_server_outlier_detection_ejections_active", + "format": "time_series", + "instant": false, + "intervalFactor": 2, + "legendFormat": "Active Ejections (mixer_check_server)", + "refId": "C" + }, + { + "expr": "rate(envoy_cluster_mixer_report_server_outlier_detection_ejections_enforced_total[1m])", + "format": "time_series", + "instant": false, + "intervalFactor": 2, + "legendFormat": "Total Ejections (mixer_report_server)", + "refId": "D" + }, + { + "expr": "rate(envoy_cluster_mixer_report_server_outlier_detection_ejections_overflow[1m])", + "format": "time_series", + "instant": false, + "intervalFactor": 2, + "legendFormat": "Overflow Ejections (mixer_report_server)", + "refId": "E" + }, + { + "expr": "envoy_cluster_mixer_report_server_outlier_detection_ejections_active", + "format": "time_series", + "instant": false, + "intervalFactor": 2, + "legendFormat": "Active Ejections (mixer_report_server)", + "refId": "F" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Outliers", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 19 + }, + "id": 36, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "irate(envoy_cluster_mixer_check_server_upstream_rq_retry[1m])", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Total (mixer_check_server)", + "refId": "A" + }, + { + "expr": "irate(envoy_cluster_mixer_check_server_upstream_rq_retry_success[1m])", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Success (mixer_check_server)", + "refId": "B" + }, + { + "expr": "irate(envoy_cluster_mixer_check_server_upstream_rq_retry_overflow[1m])", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Overflow (mixer_check_server)", + "refId": "C" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Client Retries", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "content": "

      Adapters and Config

      ", + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 26 + }, + "id": 28, + "links": [], + "mode": "html", + "title": "", + "transparent": true, + "type": "text" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 29 + }, + "id": 13, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(irate(mixer_runtime_dispatch_count{adapter=~\"$adapter\"}[1m])) by (adapter)", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ adapter }}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Adapter Dispatch Count", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 29 + }, + "id": 14, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "histogram_quantile(0.5, sum(irate(mixer_runtime_dispatch_duration_bucket{adapter=~\"$adapter\"}[1m])) by (adapter, le))", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ adapter }} - p50", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.9, sum(irate(mixer_runtime_dispatch_duration_bucket{adapter=~\"$adapter\"}[1m])) by (adapter, le))", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ adapter }} - p90 ", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.99, sum(irate(mixer_runtime_dispatch_duration_bucket{adapter=~\"$adapter\"}[1m])) by (adapter, le))", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ adapter }} - p99", + "refId": "C" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Adapter Dispatch Duration", + "tooltip": { + "shared": true, + "sort": 1, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "s", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 36 + }, + "id": 60, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "scalar(topk(1, max(mixer_config_rule_config_count) by (configID)))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Rules", + "refId": "A" + }, + { + "expr": "scalar(topk(1, max(mixer_config_rule_config_error_count) by (configID)))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Config Errors", + "refId": "B" + }, + { + "expr": "scalar(topk(1, max(mixer_config_rule_config_match_error_count) by (configID)))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Match Errors", + "refId": "C" + }, + { + "expr": "scalar(topk(1, max(mixer_config_unsatisfied_action_handler_count) by (configID)))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Unsatisfied Actions", + "refId": "D" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Rules", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 6, + "y": 36 + }, + "id": 56, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "scalar(topk(1, max(mixer_config_instance_config_count) by (configID)))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Instances", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Instances in Latest Config", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 36 + }, + "id": 54, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "scalar(topk(1, max(mixer_config_handler_config_count) by (configID)))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Handlers", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Handlers in Latest Config", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 36 + }, + "id": 58, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "scalar(topk(1, max(mixer_config_attribute_count) by (configID)))", + "format": "time_series", + "instant": false, + "intervalFactor": 1, + "legendFormat": "Attributes", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Attributes in Latest Config", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "content": "

      Individual Adapters

      ", + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 43 + }, + "id": 23, + "links": [], + "mode": "html", + "title": "", + "transparent": true, + "type": "text" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 46 + }, + "id": 46, + "panels": [], + "repeat": "adapter", + "title": "$adapter Adapter", + "type": "row" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 47 + }, + "id": 17, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "label_replace(irate(mixer_runtime_dispatch_count{adapter=\"$adapter\"}[1m]),\"handler\", \"$1 ($3)\", \"handler\", \"(.*)\\\\.(.*)\\\\.(.*)\")", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ handler }} (error: {{ error }})", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Dispatch Count By Handler", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 47 + }, + "id": 18, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "label_replace(histogram_quantile(0.5, sum(rate(mixer_runtime_dispatch_duration_bucket{adapter=\"$adapter\"}[1m])) by (handler, error, le)), \"handler_short\", \"$1 ($3)\", \"handler\", \"(.*)\\\\.(.*)\\\\.(.*)\")", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "p50 - {{ handler_short }} (error: {{ error }})", + "refId": "A" + }, + { + "expr": "label_replace(histogram_quantile(0.9, sum(irate(mixer_runtime_dispatch_duration_bucket{adapter=\"$adapter\"}[1m])) by (handler, error, le)), \"handler_short\", \"$1 ($3)\", \"handler\", \"(.*)\\\\.(.*)\\\\.(.*)\")", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "p90 - {{ handler_short }} (error: {{ error }})", + "refId": "D" + }, + { + "expr": "label_replace(histogram_quantile(0.99, sum(irate(mixer_runtime_dispatch_duration_bucket{adapter=\"$adapter\"}[1m])) by (handler, error, le)), \"handler_short\", \"$1 ($3)\", \"handler\", \"(.*)\\\\.(.*)\\\\.(.*)\")", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "p99 - {{ handler_short }} (error: {{ error }})", + "refId": "E" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Dispatch Duration By Handler", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "s", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "refresh": "5s", + "schemaVersion": 16, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "hide": 0, + "includeAll": true, + "label": "Adapter", + "multi": true, + "name": "adapter", + "options": [], + "query": "label_values(adapter)", + "refresh": 2, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "", + "title": "Mixer Dashboard", + "uid": "2", + "version": 9 +} diff --git a/resources/core/charts/monitoring/charts/grafana/dashboards/nodes-dashboard.json b/resources/core/charts/monitoring/charts/grafana/dashboards/nodes-dashboard.json new file mode 100644 index 000000000000..08b7da759acb --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/dashboards/nodes-dashboard.json @@ -0,0 +1,822 @@ +{ + "__inputs": [ + { + "description": "", + "label": "prometheus", + "name": "DS_PROMETHEUS", + "pluginId": "prometheus", + "pluginName": "Prometheus", + "type": "datasource" + } + ], + "annotations": { + "list": [] + }, + "description": "Dashboard to get an overview of one server", + "editable": true, + "gnetId": 22, + "graphTooltip": 0, + "hideControls": false, + "links": [], + "refresh": false, + "rows": [ + { + "collapse": false, + "editable": true, + "height": "250px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 3, + "isNew": false, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "100 - (avg by (cpu) (irate(node_cpu{mode=\"idle\", instance=\"$server\"}[5m])) * 100)", + "hide": false, + "intervalFactor": 10, + "legendFormat": "{{cpu}}", + "refId": "A", + "step": 50 + } + ], + "title": "Idle CPU", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "percent", + "label": "cpu usage", + "logBase": 1, + "max": 100, + "min": 0, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 9, + "isNew": false, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "node_load1{instance=\"$server\"}", + "intervalFactor": 4, + "legendFormat": "load 1m", + "refId": "A", + "step": 20, + "target": "" + }, + { + "expr": "node_load5{instance=\"$server\"}", + "intervalFactor": 4, + "legendFormat": "load 5m", + "refId": "B", + "step": 20, + "target": "" + }, + { + "expr": "node_load15{instance=\"$server\"}", + "intervalFactor": 4, + "legendFormat": "load 15m", + "refId": "C", + "step": 20, + "target": "" + } + ], + "title": "System Load", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "percentunit", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ] + } + ], + "showTitle": false, + "title": "New Row", + "titleSize": "h6" + }, + { + "collapse": false, + "editable": true, + "height": "250px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 4, + "isNew": false, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "node_memory_SwapFree{instance=\"172.17.0.1:9100\",job=\"prometheus\"}", + "yaxis": 2 + } + ], + "spaceLength": 10, + "span": 9, + "stack": true, + "steppedLine": false, + "targets": [ + { + "expr": "node_memory_MemTotal{instance=\"$server\"} - node_memory_MemFree{instance=\"$server\"} - node_memory_Buffers{instance=\"$server\"} - node_memory_Cached{instance=\"$server\"}", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "memory used", + "metric": "", + "refId": "C", + "step": 10 + }, + { + "expr": "node_memory_Buffers{instance=\"$server\"}", + "interval": "", + "intervalFactor": 2, + "legendFormat": "memory buffers", + "metric": "", + "refId": "E", + "step": 10 + }, + { + "expr": "node_memory_Cached{instance=\"$server\"}", + "intervalFactor": 2, + "legendFormat": "memory cached", + "metric": "", + "refId": "F", + "step": 10 + }, + { + "expr": "node_memory_MemFree{instance=\"$server\"}", + "intervalFactor": 2, + "legendFormat": "memory free", + "metric": "", + "refId": "D", + "step": 10 + } + ], + "title": "Memory Usage", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "logBase": 1, + "min": "0", + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ] + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 5, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "((node_memory_MemTotal{instance=\"$server\"} - node_memory_MemFree{instance=\"$server\"} - node_memory_Buffers{instance=\"$server\"} - node_memory_Cached{instance=\"$server\"}) / node_memory_MemTotal{instance=\"$server\"}) * 100", + "intervalFactor": 2, + "refId": "A", + "step": 60, + "target": "" + } + ], + "thresholds": "80, 90", + "title": "Memory Usage", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + } + ], + "showTitle": false, + "title": "New Row", + "titleSize": "h6" + }, + { + "collapse": false, + "editable": true, + "height": "250px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 6, + "isNew": true, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "read", + "yaxis": 1 + }, + { + "alias": "{instance=\"172.17.0.1:9100\"}", + "yaxis": 2 + }, + { + "alias": "io time", + "yaxis": 2 + } + ], + "spaceLength": 10, + "span": 9, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (instance) (rate(node_disk_bytes_read{instance=\"$server\"}[2m]))", + "hide": false, + "intervalFactor": 4, + "legendFormat": "read", + "refId": "A", + "step": 20, + "target": "" + }, + { + "expr": "sum by (instance) (rate(node_disk_bytes_written{instance=\"$server\"}[2m]))", + "intervalFactor": 4, + "legendFormat": "written", + "refId": "B", + "step": 20 + }, + { + "expr": "sum by (instance) (rate(node_disk_io_time_ms{instance=\"$server\"}[2m]))", + "intervalFactor": 4, + "legendFormat": "io time", + "refId": "C", + "step": 20 + } + ], + "title": "Disk I/O", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "logBase": 1, + "show": true + }, + { + "format": "ms", + "logBase": 1, + "show": true + } + ] + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "percentunit", + "gauge": { + "maxValue": 1, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 7, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "(sum(node_filesystem_size{device!=\"rootfs\",instance=\"$server\"}) - sum(node_filesystem_free{device!=\"rootfs\",instance=\"$server\"})) / sum(node_filesystem_size{device!=\"rootfs\",instance=\"$server\"})", + "intervalFactor": 2, + "refId": "A", + "step": 60, + "target": "" + } + ], + "thresholds": "0.75, 0.9", + "title": "Disk Space Usage", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + } + ], + "showTitle": false, + "title": "New Row", + "titleSize": "h6" + }, + { + "collapse": false, + "editable": true, + "height": "250px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 8, + "isNew": false, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "transmitted", + "yaxis": 2 + } + ], + "spaceLength": 10, + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "rate(node_network_receive_bytes{instance=\"$server\",device!~\"lo\"}[5m])", + "hide": false, + "intervalFactor": 2, + "legendFormat": "{{device}}", + "refId": "A", + "step": 10, + "target": "" + } + ], + "title": "Network Received", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "logBase": 1, + "show": true + }, + { + "format": "bytes", + "logBase": 1, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 10, + "isNew": false, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "transmitted", + "yaxis": 2 + } + ], + "spaceLength": 10, + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "rate(node_network_transmit_bytes{instance=\"$server\",device!~\"lo\"}[5m])", + "hide": false, + "intervalFactor": 2, + "legendFormat": "{{device}}", + "refId": "B", + "step": 10, + "target": "" + } + ], + "title": "Network Transmitted", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "logBase": 1, + "show": true + }, + { + "format": "bytes", + "logBase": 1, + "show": true + } + ] + } + ], + "showTitle": false, + "title": "New Row", + "titleSize": "h6" + } + ], + "schemaVersion": 14, + "sharedCrosshair": false, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "hide": 0, + "includeAll": false, + "label": null, + "multi": false, + "name": "server", + "options": [], + "query": "label_values(node_boot_time, instance)", + "refresh": 1, + "regex": "", + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "Nodes", + "version": 2 +} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/grafana/dashboards/pilot-dashboard.json b/resources/core/charts/monitoring/charts/grafana/dashboards/pilot-dashboard.json new file mode 100755 index 000000000000..a9597f41edd4 --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/dashboards/pilot-dashboard.json @@ -0,0 +1,1724 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "5.0.0-beta2" + }, + { + "type": "panel", + "id": "graph", + "name": "Graph", + "version": "" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "text", + "name": "Text", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": false, + "gnetId": null, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "content": "

      Resource Usage

      ", + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 0 + }, + "height": "40", + "id": 29, + "links": [], + "mode": "html", + "title": "", + "transparent": true, + "type": "text" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 3 + }, + "id": 5, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "process_virtual_memory_bytes{job=\"pilot\"}", + "format": "time_series", + "instant": false, + "intervalFactor": 2, + "legendFormat": "Virtual Memory", + "refId": "I", + "step": 2 + }, + { + "expr": "process_resident_memory_bytes{job=\"pilot\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Resident Memory", + "refId": "H", + "step": 2 + }, + { + "expr": "go_memstats_heap_sys_bytes{job=\"pilot\"}", + "format": "time_series", + "hide": true, + "intervalFactor": 2, + "legendFormat": "heap sys", + "refId": "A" + }, + { + "expr": "go_memstats_heap_alloc_bytes{job=\"pilot\"}", + "format": "time_series", + "hide": true, + "intervalFactor": 2, + "legendFormat": "heap alloc", + "refId": "D" + }, + { + "expr": "go_memstats_alloc_bytes{job=\"pilot\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Alloc", + "refId": "F", + "step": 2 + }, + { + "expr": "go_memstats_heap_inuse_bytes{job=\"pilot\"}", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "Heap in-use", + "refId": "E", + "step": 2 + }, + { + "expr": "go_memstats_stack_inuse_bytes{job=\"pilot\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Stack in-use", + "refId": "G", + "step": 2 + }, + { + "expr": "sum(container_memory_usage_bytes{container_name=~\"discovery|istio-proxy\", pod_name=~\"istio-pilot-.*\"})", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "Total (k8s)", + "refId": "C", + "step": 2 + }, + { + "expr": "container_memory_usage_bytes{container_name=~\"discovery|istio-proxy\", pod_name=~\"istio-pilot-.*\"}", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "{{ container_name }} (k8s)", + "refId": "B", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Memory", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 6, + "y": 3 + }, + "id": 6, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(rate(container_cpu_usage_seconds_total{container_name=~\"discovery|istio-proxy\", pod_name=~\"istio-pilot-.*\"}[1m]))", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "Total (k8s)", + "refId": "A", + "step": 2 + }, + { + "expr": "sum(rate(container_cpu_usage_seconds_total{container_name=~\"discovery|istio-proxy\", pod_name=~\"istio-pilot-.*\"}[1m])) by (container_name)", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "{{ container_name }} (k8s)", + "refId": "B", + "step": 2 + }, + { + "expr": "irate(process_cpu_seconds_total{job=\"pilot\"}[1m])", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "pilot (self-reported)", + "refId": "C", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "CPU", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 3 + }, + "id": 7, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "process_open_fds{job=\"pilot\"}", + "format": "time_series", + "hide": true, + "instant": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "Open FDs (pilot)", + "refId": "A" + }, + { + "expr": "container_fs_usage_bytes{container_name=~\"discovery|istio-proxy\", pod_name=~\"istio-pilot-.*\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ container_name }}", + "refId": "B", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Disk", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "decimals": null, + "format": "none", + "label": "", + "logBase": 1024, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 3 + }, + "id": 4, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "go_goroutines{job=\"pilot\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Number of Goroutines", + "refId": "A", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Goroutines", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "content": "

      Pilot Overview

      ", + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 10 + }, + "height": "40px", + "id": 30, + "links": [], + "mode": "html", + "title": "", + "transparent": true, + "type": "text" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 13 + }, + "id": 9, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(rate(pilot_discovery_calls[1m])) by (method)", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ method }}", + "refId": "A", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Discovery Calls", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "ops", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 6, + "y": 13 + }, + "id": 12, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(irate(pilot_discovery_errors[1m])) by (method)", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ method }}", + "refId": "A", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Discovery Errors", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 13 + }, + "id": 38, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(pilot_discovery_cache_size) by (cache_name)", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ cache_name }}", + "refId": "A", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Discovery Cache Sizes", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 13 + }, + "id": 39, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "histogram_quantile(0.5, sum(irate(pilot_discovery_resources_bucket[1m])) by (le, method))", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ method }} - p50", + "refId": "A", + "step": 2 + }, + { + "expr": "histogram_quantile(0.9, sum(irate(pilot_discovery_resources_bucket[1m])) by (le, method))", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ method }} - p90", + "refId": "B", + "step": 2 + }, + { + "expr": "histogram_quantile(0.99, sum(irate(pilot_discovery_resources_bucket[1m])) by (le, method))", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ method }} - p99", + "refId": "C", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Discovery Returned Resource Counts", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 20 + }, + "id": 37, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(irate(pilot_discovery_cache_hit{cache_name=\"cds\"}[1m]))", + "intervalFactor": 2, + "legendFormat": "Hits", + "refId": "A", + "step": 2 + }, + { + "expr": "sum(irate(pilot_discovery_cache_miss{cache_name=\"cds\"}[1m]))", + "intervalFactor": 2, + "legendFormat": "MIsses", + "refId": "B", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "CDS Cache", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 6, + "y": 20 + }, + "id": 46, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(irate(pilot_discovery_cache_hit{cache_name=\"lds\"}[1m])) ", + "intervalFactor": 2, + "legendFormat": "Hits", + "refId": "A", + "step": 2 + }, + { + "expr": "sum(irate(pilot_discovery_cache_miss{cache_name=\"lds\"}[1m])) ", + "intervalFactor": 2, + "legendFormat": "Misses", + "refId": "B", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "LDS Cache", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 20 + }, + "id": 47, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(irate(pilot_discovery_cache_hit{cache_name=\"rds\"}[1m]))", + "intervalFactor": 2, + "legendFormat": "Hits", + "refId": "A", + "step": 2 + }, + { + "expr": "sum(irate(pilot_discovery_cache_miss{cache_name=\"rds\"}[1m]))", + "intervalFactor": 2, + "legendFormat": "Misses", + "refId": "B", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "RDS Cache", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 20 + }, + "id": 48, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(irate(pilot_discovery_cache_hit{cache_name=\"sds\"}[1m]))", + "intervalFactor": 2, + "legendFormat": "Hits", + "refId": "A", + "step": 2 + }, + { + "expr": "sum(irate(pilot_discovery_cache_miss{cache_name=\"sds\"}[1m]))", + "intervalFactor": 2, + "legendFormat": "Misses", + "refId": "B", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "SDS Cache", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 27 + }, + "id": 45, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(envoy_cluster_rds_membership_healthy)", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Pilot (healthy)", + "refId": "E", + "step": 2 + }, + { + "expr": "sum(envoy_cluster_rds_membership_total)", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Pilot (total)", + "refId": "F", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Cluster Membership", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 27 + }, + "id": 50, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(envoy_cluster_rds_upstream_rq_time) by (quantile)", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ quantile }}", + "refId": "C", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Request Latency", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "ms", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "content": "

      xDS

      ", + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 34 + }, + "id": 28, + "links": [], + "mode": "html", + "title": "", + "transparent": true, + "type": "text" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 6, + "w": 8, + "x": 0, + "y": 37 + }, + "id": 40, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(irate(envoy_cluster_manager_cds_update_attempt[1m]))", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "CDS Attempts", + "refId": "F" + }, + { + "expr": "sum(irate(envoy_cluster_manager_cds_update_success[1m]))", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "CDS Successes", + "refId": "A" + }, + { + "expr": "sum(irate(envoy_listener_manager_lds_update_attempt[1m]))", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "LDS Attempts", + "refId": "E" + }, + { + "expr": "sum(irate(envoy_listener_manager_lds_update_success[1m]))", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "LDS Successes", + "refId": "B" + }, + { + "expr": "sum(irate(envoy_cluster_rds_update_attempt[1m]))", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "RDS Attempts", + "refId": "G" + }, + { + "expr": "sum(irate(envoy_cluster_rds_update_success[1m]))", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "RDS Successes", + "refId": "I" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Updates", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 6, + "w": 8, + "x": 8, + "y": 37 + }, + "id": 42, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(irate(envoy_cluster_manager_cds_update_failure[1m]))", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "CDS", + "refId": "A", + "step": 2 + }, + { + "expr": "sum(irate(envoy_listener_manager_lds_update_failure[1m]))", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "LDS", + "refId": "B", + "step": 2 + }, + { + "expr": "sum(irate(envoy_cluster_rds_update_attempt[1m])) - sum(irate(envoy_cluster_rds_update_success[1m]))", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "RDS", + "refId": "C", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Failures", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 6, + "w": 8, + "x": 16, + "y": 37 + }, + "id": 41, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(envoy_cluster_rds_upstream_cx_active)", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "Pilot (RDS)", + "refId": "C", + "step": 2 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Active Connections", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "refresh": "5s", + "schemaVersion": 16, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "Pilot Dashboard", + "uid": "3", + "version": 2 +} diff --git a/resources/core/charts/monitoring/charts/grafana/dashboards/pods-dashboard.json b/resources/core/charts/monitoring/charts/grafana/dashboards/pods-dashboard.json new file mode 100644 index 000000000000..fba18edf95d2 --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/dashboards/pods-dashboard.json @@ -0,0 +1,418 @@ +{ + "__inputs": [ + { + "description": "", + "label": "prometheus", + "name": "DS_PROMETHEUS", + "pluginId": "prometheus", + "pluginName": "Prometheus", + "type": "datasource" + } + ], + "annotations": { + "list": [] + }, + "editable": true, + "graphTooltip": 1, + "hideControls": false, + "links": [], + "refresh": false, + "rows": [ + { + "collapse": false, + "editable": true, + "height": "250px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 1, + "isNew": false, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 12, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by(container_name) (container_memory_usage_bytes{pod_name=\"$pod\", container_name=~\"$container\", container_name!=\"POD\"})", + "interval": "10s", + "intervalFactor": 1, + "legendFormat": "Current: {{ container_name }}", + "metric": "container_memory_usage_bytes", + "refId": "A", + "step": 15 + }, + { + "expr": "kube_pod_container_resource_requests_memory_bytes{pod=\"$pod\", container=~\"$container\"}", + "interval": "10s", + "intervalFactor": 2, + "legendFormat": "Requested: {{ container }}", + "metric": "kube_pod_container_resource_requests_memory_bytes", + "refId": "B", + "step": 20 + }, + { + "expr": "kube_pod_container_resource_limits_memory_bytes{pod=\"$pod\", container=~\"$container\"}", + "interval": "10s", + "intervalFactor": 2, + "legendFormat": "Limit: {{ container }}", + "metric": "kube_pod_container_resource_limits_memory_bytes", + "refId": "C", + "step": 20 + } + ], + "title": "Memory Usage", + "tooltip": { + "msResolution": true, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ] + } + ], + "showTitle": false, + "title": "Row", + "titleSize": "h6" + }, + { + "collapse": false, + "editable": true, + "height": "250px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 2, + "isNew": false, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 12, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (container_name)(rate(container_cpu_usage_seconds_total{image!=\"\",container_name!=\"POD\",pod_name=\"$pod\"}[1m]))", + "intervalFactor": 2, + "legendFormat": "{{ container_name }}", + "refId": "A", + "step": 30 + }, + { + "expr": "kube_pod_container_resource_requests_cpu_cores{pod=\"$pod\", container=~\"$container\"}", + "interval": "10s", + "intervalFactor": 2, + "legendFormat": "Requested: {{ container }}", + "metric": "kube_pod_container_resource_requests_cpu_cores", + "refId": "B", + "step": 20 + }, + { + "expr": "kube_pod_container_resource_limits_cpu_cores{pod=\"$pod\", container=~\"$container\"}", + "interval": "10s", + "intervalFactor": 2, + "legendFormat": "Limit: {{ container }}", + "metric": "kube_pod_container_resource_limits_memory_bytes", + "refId": "C", + "step": 20 + } + ], + "title": "CPU Usage", + "tooltip": { + "msResolution": true, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ] + } + ], + "showTitle": false, + "title": "Row", + "titleSize": "h6" + }, + { + "collapse": false, + "editable": true, + "height": "250px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 3, + "isNew": false, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 12, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sort_desc(sum by (pod_name) (rate(container_network_receive_bytes_total{pod_name=\"$pod\"}[1m])))", + "intervalFactor": 2, + "legendFormat": "{{ pod_name }}", + "refId": "A", + "step": 30 + } + ], + "title": "Network I/O", + "tooltip": { + "msResolution": true, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ] + } + ], + "showTitle": false, + "title": "New Row", + "titleSize": "h6" + } + ], + "schemaVersion": 14, + "sharedCrosshair": false, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "allValue": ".*", + "current": {}, + "datasource": "Prometheus", + "hide": 0, + "includeAll": true, + "label": "Namespace", + "multi": false, + "name": "namespace", + "options": [], + "query": "label_values(kube_pod_info, namespace)", + "refresh": 1, + "regex": "", + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "hide": 0, + "includeAll": false, + "label": "Pod", + "multi": false, + "name": "pod", + "options": [], + "query": "label_values(kube_pod_info{namespace=~\"$namespace\"}, pod)", + "refresh": 1, + "regex": "", + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": ".*", + "current": {}, + "datasource": "Prometheus", + "hide": 0, + "includeAll": true, + "label": "Container", + "multi": false, + "name": "container", + "options": [], + "query": "label_values(kube_pod_container_info{namespace=\"$namespace\", pod=\"$pod\"}, container)", + "refresh": 1, + "regex": "", + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "Pods", + "version": 1 +} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/grafana/dashboards/statefulset-dashboard.json b/resources/core/charts/monitoring/charts/grafana/dashboards/statefulset-dashboard.json new file mode 100644 index 000000000000..130d43c718b7 --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/dashboards/statefulset-dashboard.json @@ -0,0 +1,706 @@ +{ + "__inputs": [ + { + "description": "", + "label": "prometheus", + "name": "DS_PROMETHEUS", + "pluginId": "prometheus", + "pluginName": "Prometheus", + "type": "datasource" + } + ], + "annotations": { + "list": [] + }, + "editable": true, + "graphTooltip": 1, + "hideControls": false, + "links": [], + "rows": [ + { + "collapse": false, + "height": "200px", + "panels": [ + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 8, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "cores", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 4, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": true + }, + "targets": [ + { + "expr": "sum(rate(container_cpu_usage_seconds_total{namespace=\"$statefulset_namespace\",pod_name=~\"$statefulset_name.*\"}[3m]))", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "title": "CPU", + "type": "singlestat", + "valueFontSize": "110%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 9, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "GB", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "80%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 4, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": true + }, + "targets": [ + { + "expr": "sum(container_memory_usage_bytes{namespace=\"$statefulset_namespace\",pod_name=~\"$statefulset_name.*\"}) / 1024^3", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "title": "Memory", + "type": "singlestat", + "valueFontSize": "110%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "Bps", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": false + }, + "id": 7, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 4, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": true + }, + "targets": [ + { + "expr": "sum(rate(container_network_transmit_bytes_total{namespace=\"$statefulset_namespace\",pod_name=~\"$statefulset_name.*\"}[3m])) + sum(rate(container_network_receive_bytes_total{namespace=\"$statefulset_namespace\",pod_name=~\"$statefulset_name.*\"}[3m]))", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "title": "Network", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + } + ], + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + }, + { + "collapse": false, + "height": "100px", + "panels": [ + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": false + }, + "id": 5, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "max(kube_statefulset_replicas{statefulset=\"$statefulset_name\",namespace=\"$statefulset_namespace\"}) without (instance, pod)", + "intervalFactor": 2, + "metric": "kube_statefulset_replicas", + "refId": "A", + "step": 600 + } + ], + "title": "Desired Replicas", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 6, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "min(kube_statefulset_status_replicas{statefulset=\"$statefulset_name\",namespace=\"$statefulset_namespace\"}) without (instance, pod)", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "title": "Available Replicas", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 3, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "max(kube_statefulset_status_observed_generation{statefulset=\"$statefulset_name\",namespace=\"$statefulset_namespace\"}) without (instance, pod)", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "title": "Observed Generation", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "editable": true, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 2, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "expr": "max(kube_statefulset_metadata_generation{statefulset=\"$statefulset_name\",namespace=\"$statefulset_namespace\"}) without (instance, pod)", + "intervalFactor": 2, + "refId": "A", + "step": 600 + } + ], + "title": "Metadata Generation", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + } + ], + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + }, + { + "collapse": false, + "height": "350px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "id": 1, + "isNew": true, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 12, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "min(kube_statefulset_status_replicas{statefulset=\"$statefulset_name\",namespace=\"$statefulset_namespace\"}) without (instance, pod)", + "intervalFactor": 2, + "legendFormat": "available", + "refId": "B", + "step": 30 + }, + { + "expr": "max(kube_statefulset_replicas{statefulset=\"$statefulset_name\",namespace=\"$statefulset_namespace\"}) without (instance, pod)", + "intervalFactor": 2, + "legendFormat": "desired", + "refId": "E", + "step": 30 + } + ], + "title": "Replicas", + "tooltip": { + "msResolution": true, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "none", + "label": "", + "logBase": 1, + "show": true + }, + { + "format": "short", + "label": "", + "logBase": 1, + "show": false + } + ] + } + ], + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + } + ], + "schemaVersion": 14, + "sharedCrosshair": false, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "allValue": ".*", + "current": {}, + "datasource": "Prometheus", + "hide": 0, + "includeAll": false, + "label": "Namespace", + "multi": false, + "name": "statefulset_namespace", + "options": [], + "query": "label_values(kube_statefulset_metadata_generation, namespace)", + "refresh": 1, + "regex": "", + "sort": 0, + "tagValuesQuery": null, + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "hide": 0, + "includeAll": false, + "label": "StatefulSet", + "multi": false, + "name": "statefulset_name", + "options": [], + "query": "label_values(kube_statefulset_metadata_generation{namespace=\"$statefulset_namespace\"}, statefulset)", + "refresh": 1, + "regex": "", + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "statefulset", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "StatefulSet", + "version": 1 +} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/grafana/templates/_helpers.tpl b/resources/core/charts/monitoring/charts/grafana/templates/_helpers.tpl new file mode 100644 index 000000000000..ec809bb854f6 --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/templates/_helpers.tpl @@ -0,0 +1,52 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "grafana.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 "grafana.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 a fully qualified server name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +*/}} +{{- define "grafana.server.fullname" -}} +{{- printf "%s-%s" .Release.Name "grafana" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "grafana.configmap.dashboard" -}} +{{- printf "%s-%s" .Release.Name "grafana-dashboard-provisioner" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "grafana.configmap.datasource" -}} +{{- printf "%s-%s" .Release.Name "grafana-datasource-provisioner" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Return the appropriate apiVersion value to use for the prometheus-operator managed k8s resources +*/}} +{{- define "prometheus-operator.apiVersion" -}} +{{- if .Capabilities.APIVersions.Has "monitoring.coreos.com/v1" }} +{{- printf "%s" "monitoring.coreos.com/v1" -}} +{{- else -}} +{{- printf "%s" "monitoring.coreos.com/v1alpha1" -}} +{{- end -}} +{{- end -}} diff --git a/resources/core/charts/monitoring/charts/grafana/templates/dasboards-provisioner-configmap.yaml b/resources/core/charts/monitoring/charts/grafana/templates/dasboards-provisioner-configmap.yaml new file mode 100644 index 000000000000..8d1c7904c7ba --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/templates/dasboards-provisioner-configmap.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + app: {{ template "grafana.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + heritage: "{{ .Release.Service }}" + release: "{{ .Release.Name }}" + name: {{ template "grafana.configmap.dashboard" . }} +data: + dashbaord-provisioner.yaml: |- + apiVersion: 1 + + providers: + - name: 'Prometheus' + orgId: 1 + folder: '' + type: file + disableDeletion: false + editable: true + options: + path: /var/lib/grafana/grafana-dashboards \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/grafana/templates/dashboards-configmap.yaml b/resources/core/charts/monitoring/charts/grafana/templates/dashboards-configmap.yaml new file mode 100644 index 000000000000..edcb3e9ecede --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/templates/dashboards-configmap.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + app: {{ template "grafana.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + heritage: "{{ .Release.Service }}" + release: "{{ .Release.Name }}" + name: {{ template "grafana.server.fullname" . }} +data: + {{- if .Values.serverDashboardFiles }} +{{ toYaml .Values.serverDashboardFiles | indent 2 }} + {{ end }} + {{- if .Values.keepOriginalDashboards }} +{{ (.Files.Glob "dashboards/*.json").AsConfig | indent 2 }} + {{- end }} + {{- if .Values.dataSource }} +{{ toYaml .Values.dataSource | indent 2 }} + {{- end }} + {{- if .Values.keepOriginalDatasources }} + {{- end }} diff --git a/resources/core/charts/monitoring/charts/grafana/templates/datasource-configmap.yaml b/resources/core/charts/monitoring/charts/grafana/templates/datasource-configmap.yaml new file mode 100644 index 000000000000..aa6217b898ee --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/templates/datasource-configmap.yaml @@ -0,0 +1,37 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + app: {{ template "grafana.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + heritage: "{{ .Release.Service }}" + release: "{{ .Release.Name }}" + name: {{ template "grafana.configmap.datasource" . }} +data: + datasource.yaml: |- + apiVersion: 1 + + # list of datasources that should be deleted from the database + deleteDatasources: + - name: Prometheus + orgId: 1 + + # list of datasources to insert/update depending + # whats available in the database + datasources: + # name of the datasource. Required + - name: Prometheus + # datasource type. Required + type: prometheus + # access mode. direct or proxy. Required + access: proxy + # org id. will default to orgId 1 if not specified + orgId: 1 + # url + url: http://core-prometheus:9090 + + # enable/disable basic auth + basicAuth: false + + # allow users to edit datasources from the UI. + editable: true \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/grafana/templates/grafana-deployment.yaml b/resources/core/charts/monitoring/charts/grafana/templates/grafana-deployment.yaml new file mode 100644 index 000000000000..6243d7234687 --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/templates/grafana-deployment.yaml @@ -0,0 +1,125 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + labels: + app: {{ template "grafana.fullname" . }} + chart: "{{.Chart.Name}}-{{.Chart.Version}}" + heritage: "{{ .Release.Service }}" + release: "{{ .Release.Name }}" + name: {{ template "grafana.server.fullname" . }} +spec: + serviceName: {{ template "grafana.server.fullname" . }} + replicas: 1 + selector: + matchLabels: + app: {{ template "grafana.fullname" . }} + release: "{{ .Release.Name }}" + updateStrategy: + type: RollingUpdate + rollingUpdate: + partition: 0 + template: + metadata: + annotations: + {{- range $key, $value := .Values.annotations }} + {{ $key }}: {{ $value }} + {{- end }} + labels: + app: {{ template "grafana.fullname" . }} + release: "{{ .Release.Name }}" + spec: + containers: + - name: grafana + image: {{ .Values.image.repository }}:{{ .Values.image.tag }} + env: + - name: GF_PATHS_PROVISIONING + value: /etc/grafana/provisioning + - name: GF_USERS_AUTO_ASSIGN_ORG + value: "true" + - name: GF_USERS_AUTO_ASSIGN_ORG_ROLE + value: Editor + - name: GF_AUTH_BASIC_ENABLED + value: "false" + - name: GF_AUTH_DISABLE_LOGIN_FORM + value: "true" + - name: GF_AUTH_GENERIC_OAUTH_AUTH_URL + value: 'https://dex.{{ .Values.global.domainName }}/auth' + - name: GF_AUTH_GENERIC_OAUTH_CLIENT_ID + value: {{ .Values.containersEnv.gfAuthGenericOauthClientId }} + - name: GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET + value: {{ .Values.containersEnv.gfAuthGenericOauthClientSecret }} + - name: GF_AUTH_GENERIC_OAUTH_ENABLED + value: "true" + - name: GF_AUTH_GENERIC_OAUTH_SCOPES + value: openid profile email groups + - name: GF_AUTH_GENERIC_OAUTH_TOKEN_URL + value: {{ .Values.containersEnv.gfAuthGenericOauthTokenUrlValue }} + - name: GF_AUTH_ANONYMOUS_ENABLED + value: "{{ .Values.auth.anonymous.enabled }}" + - name: GF_SERVER_ROOT_URL + value: 'https://grafana.{{ .Values.global.domainName }}/' +{{- if .Values.extraVars }} +{{ toYaml .Values.extraVars | indent 8 }} +{{- end }} + volumeMounts: + - name: grafana-storage + mountPath: /var/lib/grafana + - name: dashboard-provisioner + mountPath: /etc/grafana/provisioning/dashboards + - name: datasource-provisioner + mountPath: /etc/grafana/provisioning/datasources + - name: grafana-dashboards + mountPath: /var/lib/grafana/grafana-dashboards + {{- if .Values.mountGrafanaConfig }} + - name: grafana-config + mountPath: /etc/grafana + {{- end }} + ports: + - name: web + containerPort: 3000 + readinessProbe: + httpGet: + path: /api/health + port: 3000 + periodSeconds: 1 + timeoutSeconds: 1 + successThreshold: 1 + failureThreshold: 10 + resources: + requests: + memory: 100Mi + limits: + memory: 200Mi + {{- if .Values.nodeSelector }} + nodeSelector: + {{ toYaml .Values.nodeSelector | indent 4 }} + {{- end }} + {{- if .Values.tolerations }} + tolerations: +{{ toYaml .Values.tolerations | indent 8 }} + {{- end }} + securityContext: + fsGroup: 472 + volumes: + - name: grafana-storage + {{- if .Values.persistence }} + persistentVolumeClaim: + claimName: {{ template "grafana.server.fullname" . }} + {{- else }} + emptyDir: {} + {{- end }} + - name: grafana-dashboards + configMap: + name: {{ template "grafana.server.fullname" . }} + - name: dashboard-provisioner + configMap: + name: {{ template "grafana.configmap.dashboard" . }} + - name: datasource-provisioner + configMap: + name: {{ template "grafana.configmap.datasource" . }} + {{- if .Values.mountGrafanaConfig }} + - name: grafana-config + hostPath: + path: /etc/grafana + type: directory + {{- end }} diff --git a/resources/core/charts/monitoring/charts/grafana/templates/ingress.yaml b/resources/core/charts/monitoring/charts/grafana/templates/ingress.yaml new file mode 100644 index 000000000000..d26822c7a155 --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/templates/ingress.yaml @@ -0,0 +1,29 @@ +{{- if .Values.ingress.enabled -}} +{{- $releaseName := .Release.Name -}} +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: +{{- if .Values.ingress.annotations }} + annotations: +{{ toYaml .Values.ingress.annotations | indent 4 }} +{{- end }} + labels: + app: {{ template "grafana.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + heritage: "{{ .Release.Service }}" + release: "{{ .Release.Name }}" +{{- if .Values.ingress.labels }} +{{ toYaml .Values.ingress.labels | indent 4 }} +{{- end }} + name: {{ template "grafana.server.fullname" . }} +spec: + rules: + - host: grafana.{{ .Values.global.domainName }} + http: + paths: + - backend: + serviceName: {{ printf "%s-%s" $releaseName "grafana" | trunc 63 }} + servicePort: 80 + tls: + - secretName: {{.Values.global.istio.tls.secretName }} +{{- end -}} diff --git a/resources/core/charts/monitoring/charts/grafana/templates/pvc.yaml b/resources/core/charts/monitoring/charts/grafana/templates/pvc.yaml new file mode 100644 index 000000000000..6e1cd501735d --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/templates/pvc.yaml @@ -0,0 +1,24 @@ +{{- if .Values.persistence }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + labels: + app: {{ template "grafana.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + heritage: "{{ .Release.Service }}" + release: "{{ .Release.Name }}" + name: {{ template "grafana.server.fullname" . }} +spec: + accessModes: + - {{ .Values.persistence.accessMode }} + 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/resources/core/charts/monitoring/charts/grafana/templates/servicemonitors.yaml b/resources/core/charts/monitoring/charts/grafana/templates/servicemonitors.yaml new file mode 100644 index 000000000000..3974b123221c --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/templates/servicemonitors.yaml @@ -0,0 +1,29 @@ +{{- if .Values.selfServiceMonitor }} +apiVersion: {{ template "prometheus-operator.apiVersion" . }} +kind: ServiceMonitor +metadata: + labels: + app: {{ template "grafana.name" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + component: grafana + heritage: "{{ .Release.Service }}" + release: "{{ .Release.Name }}" + prometheus: {{ .Release.Name }} + {{- if .Values.additionalServiceMonitorLabels }} +{{ toYaml .Values.additionalServiceMonitorLabels | indent 4 }} + {{- end }} + name: {{ template "grafana.fullname" . }} +spec: + jobLabel: {{ template "grafana.fullname" . }} + selector: + matchLabels: + grafana: {{ .Release.Name }} + app: {{ template "grafana.name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + namespaceSelector: + matchNames: + - {{ .Release.Namespace | quote }} + endpoints: + - port: http + interval: 30s +{{- end }} diff --git a/resources/core/charts/monitoring/charts/grafana/templates/svc.yaml b/resources/core/charts/monitoring/charts/grafana/templates/svc.yaml new file mode 100644 index 000000000000..606705b9723a --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/templates/svc.yaml @@ -0,0 +1,38 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: {{ template "grafana.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + heritage: "{{ .Release.Service }}" + release: "{{ .Release.Name }}" + name: {{ template "grafana.server.fullname" . }} + {{- if .Values.service.annotations }} + annotations: +{{ toYaml .Values.service.annotations | indent 4 }} + {{- end }} +spec: + ports: + - name: "http" + port: 80 + protocol: TCP + targetPort: 3000 +{{- if contains "NodePort" .Values.service.type }} + {{- if .Values.service.nodePort }} + nodePort: {{ .Values.service.nodePort }} + {{- end }} +{{- end }} + selector: + app: {{ template "grafana.fullname" . }} + type: "{{ .Values.service.type }}" +{{- if contains "LoadBalancer" .Values.service.type }} + {{- if .Values.service.loadBalancerIP }} + loadBalancerIP: {{ .Values.service.loadBalancerIP }} + {{- end -}} + {{- if .Values.service.loadBalancerSourceRanges}} + loadBalancerSourceRanges: + {{- range .Values.service.loadBalancerSourceRanges }} + - {{ . }} + {{- end }} + {{- end -}} +{{- end -}} diff --git a/resources/core/charts/monitoring/charts/grafana/values.yaml b/resources/core/charts/monitoring/charts/grafana/values.yaml new file mode 100755 index 000000000000..af62335f6433 --- /dev/null +++ b/resources/core/charts/monitoring/charts/grafana/values.yaml @@ -0,0 +1,121 @@ +nodeSelector: {} + +## Tolerations for use with node taints +## Ref: https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/ +## +tolerations: {} + # - key: "key" + # operator: "Equal" + # value: "value" + # effect: "NoSchedule" + +annotations: + sidecar.istio.io/inject: '"true"' + +## If true, create a serviceMonitor for grafana +## +selfServiceMonitor: true + +## Pass extra environment variables to the Grafana container. +## +# extraVars: +# - name: EXTRA_VAR_1 +# value: extra-var-value-1 +# - name: EXTRA_VAR_2 +# value: extra-var-value-2 +extraVars: + +## Change to true override Grafana's default config. +## Make sure grafana.ini is present on /etc/grafana +mountGrafanaConfig: false + +service: + + ## Annotations to be added to the Service + ## + annotations: + auth.istio.io/80: NONE + + ## Cluster-internal IP address for Alertmanager Service + ## + clusterIP: "" + + ## List of external IP addresses at which the Alertmanager Service will be available + ## + externalIPs: [] + + ## External IP address to assign to Alertmanager Service + ## Only used if service.type is 'LoadBalancer' and supported by cloud provider + ## + loadBalancerIP: "" + + ## List of client IPs allowed to access Alertmanager Service + ## Only used if service.type is 'LoadBalancer' and supported by cloud provider + ## + loadBalancerSourceRanges: [] + + ## Port to expose on each node + ## Only used if service.type is 'NodePort' + ## + nodePort: 30902 + + ## Service type + ## + type: ClusterIP + +## Grafana Docker image +## +image: + repository: grafana/grafana + tag: 5.1.0 + +containersEnv: + gfAuthGenericOauthClientId: grafana + gfAuthGenericOauthClientSecret: apie4eeX6hiC9ainieli + gfAuthGenericOauthTokenUrlValue: http://dex-service:5556/token + +persistence: + + ## minio 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: 1Gi + +resources: + requests: + memory: 200Mi + +ingress: + ## If true, Grafana Ingress will be created + ## + enabled: true + + ## Annotations for Alertmanager Ingress + ## + annotations: + kubernetes.io/ingress.class: istio + +# Set datasource in beginning +dataSource: {} + +serverDashboardFiles: {} + +## Keep the Dashboards that are defined in this HELM chart +keepOriginalDashboards: true + +## Keep the Datasources that are defined in this HELM chart +keepOriginalDatasources: true + +auth: + anonymous: + enabled: "false" + +##Custom Labels to be added to Grafana ServiceMonitors +## +additionalServiceMonitorLabels: {} diff --git a/resources/core/charts/monitoring/charts/kube-prometheus/.helmignore b/resources/core/charts/monitoring/charts/kube-prometheus/.helmignore new file mode 100644 index 000000000000..f0c131944441 --- /dev/null +++ b/resources/core/charts/monitoring/charts/kube-prometheus/.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/resources/core/charts/monitoring/charts/kube-prometheus/Chart.yaml b/resources/core/charts/monitoring/charts/kube-prometheus/Chart.yaml new file mode 100644 index 000000000000..b5b1997d8787 --- /dev/null +++ b/resources/core/charts/monitoring/charts/kube-prometheus/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +name: kube-prometheus +description: Manifests, dashboards, and alerting rules for end-to-end Kubernetes cluster monitoring +engine: gotpl +version: 0.0.55 +keywords: +- monitoring +- kyma diff --git a/resources/core/charts/monitoring/charts/kube-prometheus/templates/_helpers.tpl b/resources/core/charts/monitoring/charts/kube-prometheus/templates/_helpers.tpl new file mode 100644 index 000000000000..f1aed57a548f --- /dev/null +++ b/resources/core/charts/monitoring/charts/kube-prometheus/templates/_helpers.tpl @@ -0,0 +1,36 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "kube-prometheus.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 "kube-prometheus.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 -}} + +{{/* +Return the appropriate apiVersion value to use for the prometheus-operator managed k8s resources +*/}} +{{- define "prometheus-operator.apiVersion" -}} +{{- if .Capabilities.APIVersions.Has "monitoring.coreos.com/v1" }} +{{- printf "%s" "monitoring.coreos.com/v1" -}} +{{- else -}} +{{- printf "%s" "monitoring.coreos.com/v1alpha1" -}} +{{- end -}} +{{- end -}} diff --git a/resources/core/charts/monitoring/charts/kube-prometheus/templates/configmap.yaml b/resources/core/charts/monitoring/charts/kube-prometheus/templates/configmap.yaml new file mode 100644 index 000000000000..134be5a4c7b3 --- /dev/null +++ b/resources/core/charts/monitoring/charts/kube-prometheus/templates/configmap.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + app: "prometheus" + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + prometheus: {{ .Release.Name }} + release: {{ .Release.Name }} + role: alert-rules + name: {{ template "kube-prometheus.fullname" . }} +data: +{{- if .Values.prometheusRules }} +{{- $root := . }} +{{- range $key, $val := .Values.prometheusRules }} + {{ $key }}: |- +{{ tpl $val $root | indent 4}} +{{- end }} +{{ else }} + general.rules: |- + {{- include "general.rules.yaml.tpl" . | indent 4}} +{{ end }} diff --git a/resources/core/charts/monitoring/charts/kube-prometheus/templates/general.rules.yaml b/resources/core/charts/monitoring/charts/kube-prometheus/templates/general.rules.yaml new file mode 100644 index 000000000000..afa9e3aed304 --- /dev/null +++ b/resources/core/charts/monitoring/charts/kube-prometheus/templates/general.rules.yaml @@ -0,0 +1,41 @@ +{{ define "general.rules.yaml.tpl" }} +groups: +- name: general.rules + rules: + - alert: TargetDown + expr: 100 * (count(up == 0) BY (job) / count(up) BY (job)) > 10 + for: 10m + labels: + severity: warning + annotations: + description: '{{`{{ $value }}`}}% of {{`{{ $labels.job }}`}} targets are down.' + summary: Targets are down + - alert: DeadMansSwitch + expr: vector(1) + labels: + severity: none + annotations: + description: This is a DeadMansSwitch meant to ensure that the entire Alerting + pipeline is functional. + summary: Alerting DeadMansSwitch + - record: fd_utilization + expr: process_open_fds / process_max_fds + - alert: FdExhaustionClose + expr: predict_linear(fd_utilization[1h], 3600 * 4) > 1 + for: 10m + labels: + severity: warning + annotations: + description: '{{`{{ $labels.job }}`}}: {{`{{ $labels.namespace }}`}}/{{`{{ $labels.pod }}`}} instance + will exhaust in file/socket descriptors within the next 4 hours' + summary: file descriptors soon exhausted + - alert: FdExhaustionClose + expr: predict_linear(fd_utilization[10m], 3600) > 1 + for: 10m + labels: + severity: critical + annotations: + description: '{{`{{ $labels.job }}`}}: {{`{{ $labels.namespace }}`}}/{{`{{ $labels.pod }}`}} instance + will exhaust in file/socket descriptors within the next hour' + summary: file descriptors soon exhausted +{{ end }} \ No newline at end of file diff --git a/resources/core/charts/monitoring/charts/kube-prometheus/values.yaml b/resources/core/charts/monitoring/charts/kube-prometheus/values.yaml new file mode 100644 index 000000000000..0833b4f7dd5a --- /dev/null +++ b/resources/core/charts/monitoring/charts/kube-prometheus/values.yaml @@ -0,0 +1,393 @@ +# exporter-node configuration +deployExporterNode: True + +# Grafana +deployGrafana: True + +## If true, create & use RBAC resources +## +global: + rbacEnable: true + +# AlertManager +deployAlertManager: True + +alertmanager: + ## Alertmanager configuration directives + ## Ref: https://prometheus.io/docs/alerting/configuration/ + ## + config: + global: + resolve_timeout: 5m + route: + group_by: ['job'] + group_wait: 30s + group_interval: 5m + repeat_interval: 12h + receiver: 'null' + routes: + - match: + alertname: DeadMansSwitch + receiver: 'null' + receivers: + - name: 'null' + + ## External URL at which Alertmanager will be reachable + ## + externalUrl: "" + + ## Alertmanager container image + ## + image: + repository: quay.io/prometheus/alertmanager + tag: v0.14.0 + + ingress: + ## If true, Alertmanager Ingress will be created + ## + enabled: false + + ## Annotations for Alertmanager Ingress + ## + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + + fqdn: "" + + ## TLS configuration for Alertmanager Ingress + ## Secret must be manually created in the namespace + ## + tls: [] + # - secretName: alertmanager-general-tls + # hosts: + # - alertmanager.example.com + + ## Node labels for Alertmanager pod assignment + ## Ref: https://kubernetes.io/docs/user-guide/node-selection/ + ## + nodeSelector: {} + + ## If true, the Operator won't process any Alertmanager configuration changes + ## + paused: false + + ## Number of Alertmanager replicas desired + ## + replicaCount: 1 + + ## Pod anti-affinity can prevent the scheduler from placing Alertmanager replicas on the same node. + ## The default value "soft" means that the scheduler should *prefer* to not schedule two replica pods onto the same node but no guarantee is provided. + ## The value "hard" means that the scheduler is *required* to not schedule two replica pods onto the same node. + ## The value "" will disable pod anti-affinity so that no anti-affinity rules will be configured. + podAntiAffinity: "soft" + + ## Resource limits & requests + ## Ref: https://kubernetes.io/docs/user-guide/compute-resources/ + ## + resources: + requests: + memory: 200Mi + + ## List of Secrets in the same namespace as the AlertManager + ## object, which shall be mounted into the AlertManager Pods. + ## Ref: https://github.com/coreos/prometheus-operator/blob/master/Documentation/api.md#alertmanagerspec + ## + secrets: [] + + service: + ## Annotations to be added to the Service + ## + annotations: {} + + ## Cluster-internal IP address for Alertmanager Service + ## + clusterIP: "" + + ## List of external IP addresses at which the Alertmanager Service will be available + ## + externalIPs: [] + + ## External IP address to assign to Alertmanager Service + ## Only used if service.type is 'LoadBalancer' and supported by cloud provider + ## + loadBalancerIP: "" + + ## List of client IPs allowed to access Alertmanager Service + ## Only used if service.type is 'LoadBalancer' and supported by cloud provider + ## + loadBalancerSourceRanges: [] + + ## Port to expose on each node + ## Only used if service.type is 'NodePort' + ## + nodePort: 30903 + + ## Service type + ## + type: ClusterIP + + ## Alertmanager StorageSpec for persistent data + ## Ref: https://github.com/coreos/prometheus-operator/blob/master/Documentation/user-guides/storage.md + ## + storageSpec: {} + # volumeClaimTemplate: + # spec: + # storageClassName: gluster + # accessModes: ["ReadWriteOnce"] + # resources: + # requests: + # storage: 50Gi + # selector: {} + +prometheus: + ## Alertmanagers to which alerts will be sent + ## Ref: https://github.com/coreos/prometheus-operator/blob/master/Documentation/api.md#alertmanagerendpoints + ## + alertingEndpoints: [] + # - name: "" + # namespace: "" + # port: 9093 + # scheme: http + + ## Prometheus configuration directives + ## Ignored if serviceMonitors are defined + ## Ref: https://prometheus.io/docs/operating/configuration/ + ## + config: + specifiedInValues: true + value: {} + + ## External URL at which Prometheus will be reachable + ## + externalUrl: "" + + ## Prometheus container image + ## + image: + repository: quay.io/prometheus/prometheus + tag: v2.2.1 + + ingress: + ## If true, Prometheus Ingress will be created + ## + enabled: false + + ## Annotations for Prometheus Ingress + ## + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + + fqdn: "" + + ## TLS configuration for Prometheus Ingress + ## Secret must be manually created in the namespace + ## + tls: [] + # - secretName: prometheus-k8s-tls + # hosts: + # - prometheus.example.com + + ## Node labels for Prometheus pod assignment + ## Ref: https://kubernetes.io/docs/user-guide/node-selection/ + ## + nodeSelector: {} + + ## If true, the Operator won't process any Prometheus configuration changes + ## + paused: false + + ## Number of Prometheus replicas desired + ## + replicaCount: 1 + + ## Pod anti-affinity can prevent the scheduler from placing Prometheus replicas on the same node. + ## The default value "soft" means that the scheduler should *prefer* to not schedule two replica pods onto the same node but no guarantee is provided. + ## The value "hard" means that the scheduler is *required* to not schedule two replica pods onto the same node. + ## The value "" will disable pod anti-affinity so that no anti-affinity rules will be configured. + podAntiAffinity: "soft" + + ## Resource limits & requests + ## Ref: https://kubernetes.io/docs/user-guide/compute-resources/ + ## + resources: + requests: + memory: 200Mi + + ## List of Secrets in the same namespace as the Prometheus + ## object, which shall be mounted into the Prometheus Pods. + ## Ref: https://github.com/coreos/prometheus-operator/blob/master/Documentation/api.md#prometheusspec + ## + secrets: [] + + ## How long to retain metrics + ## + retention: 24h + + ## Prefix used to register routes, overriding externalUrl route. + ## Useful for proxies that rewrite URLs. + ## + routePrefix: / + + ## Rules configmap selector + ## Ref: https://github.com/coreos/prometheus-operator/blob/master/Documentation/design.md + ## + ## 1. If `matchLabels` is used, `rules.additionalLabels` must contain all the labels from + ## `matchLabels` in order to be be matched by Prometheus + ## 2. If `matchExpressions` is used `rules.additionalLabels` must contain at least one label + ## from `matchExpressions` in order to be matched by Prometheus + ## Ref: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels + rulesSelector: {} + # rulesSelector: { + # matchExpressions: [{key: prometheus, operator: In, values: [example-rules, example-rules-2]}] + # } + ### OR + # rulesSelector: { + # matchLabels: [{role: example-rules}] + # } + + ## Prometheus alerting & recording rules + ## Ref: https://prometheus.io/docs/querying/rules/ + ## Ref: https://prometheus.io/docs/alerting/rules/ + ## + rules: + specifiedInValues: true + ## What additional rules to be added to the ConfigMap + ## You can use this together with `rulesSelector` + additionalLabels: {} + # prometheus: example-rules + # application: etcd + value: {} + + service: + ## Annotations to be added to the Service + ## + annotations: {} + + ## Cluster-internal IP address for Prometheus Service + ## + clusterIP: "" + + ## List of external IP addresses at which the Prometheus Service will be available + ## + externalIPs: [] + + ## External IP address to assign to Prometheus Service + ## Only used if service.type is 'LoadBalancer' and supported by cloud provider + ## + loadBalancerIP: "" + + ## List of client IPs allowed to access Prometheus Service + ## Only used if service.type is 'LoadBalancer' and supported by cloud provider + ## + loadBalancerSourceRanges: [] + + ## Port to expose on each node + ## Only used if service.type is 'NodePort' + ## + nodePort: 30900 + + ## Service type + ## + type: ClusterIP + + ## Service monitors selector + ## Ref: https://github.com/coreos/prometheus-operator/blob/master/Documentation/design.md + ## + serviceMonitorsSelector: {} + + ## ServiceMonitor CRDs to create & be scraped by the Prometheus instance. + ## Ref: https://github.com/coreos/prometheus-operator/blob/master/Documentation/service-monitor.md + ## + serviceMonitors: [] + ## Name of the ServiceMonitor to create + ## + # - name: "" + + ## Service label for use in assembling a job name of the form

    p&6$H*Ud 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 + + +Draw.io Diagram + + + + +

    bMX8U|#r5G@`qH~Mi4-k~v?z7c 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